Files
petfeed/src/__tests__/notify-push.test.ts
T
adminandClaude Opus 5.5 cccda31525 feat: Mitbetreuer – ein zweites Konto kümmert sich mit um ein Tier
- Neue Tabelle PetCaretaker (max. 1 pro Tier): Code einladen (7 Tage) ->
  einlösen (PENDING) -> Freigabe durch den Haupt-Besitzer (ACTIVE)
- assertPetOwnership erlaubt aktiven Mitbetreuer; assertPetMainOwner für
  Löschen, Privatsphäre, Gedenkmodus, Gesundheits-Einwilligung
- pets.list/byId inkl. mitbetreuter Tiere mit Rolle; Push auch an die Geräte
  des Mitbetreuers; Konto-Löschung übergibt das Tier an ihn
- Web: Verwaltung in "Profil bearbeiten", Code einlösen unter "Meine Tiere"
  und im Onboarding, Link /mitbetreuen/<code>, Mitteilung CARETAKER_REQUEST
- App: dieselben Abläufe (OTA-fähig, Teilen statt Kopieren)
- Datenschutz: Absatz zum Mitbetreuer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:23:34 +02:00

222 lines
10 KiB
TypeScript

/**
* Unit tests for notify.ts's best-effort Expo push add-on (Gitea mobile
* push-notifications task). The in-app Notification row is covered by the
* existing per-router tests (follows, reactions, comments, messages); this
* file covers only the push side-effect notify() fires alongside it.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
const { sendExpoPushToTokens } = vi.hoisted(() => ({
sendExpoPushToTokens: vi.fn().mockResolvedValue({ invalidTokens: [] }),
}));
vi.mock("@/lib/push", () => ({ sendExpoPushToTokens }));
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
import { notify } from "@/lib/notify";
// notify() fires the push asynchronously and doesn't expose a handle to await —
// flush microtasks so the fire-and-forget chain settles before assertions.
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("notify() push add-on", () => {
beforeEach(() => {
sendExpoPushToTokens.mockClear();
});
it("does not push when the recipient owner has no registered token", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: null }]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" });
await flush();
expect(sendExpoPushToTokens).not.toHaveBeenCalled();
});
it("does not push when the recipient pet no longer exists", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValue(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "MESSAGE" });
await flush();
expect(sendExpoPushToTokens).not.toHaveBeenCalled();
expect(mockPrisma.owner.findMany).not.toHaveBeenCalled();
});
it("sends a personalized push with the actor pet's name when a token is registered", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique
.mockResolvedValueOnce({ ownerId: "owner-1" }) // recipient lookup
.mockResolvedValueOnce({ name: "Rex" }); // actor lookup
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
notify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma as any,
{ recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" }
);
await flush();
expect(sendExpoPushToTokens).toHaveBeenCalledWith(
["ExponentPushToken[abc]"],
expect.objectContaining({ title: "Neuer Follower", body: "Rex folgt dir jetzt." })
);
});
it("falls back to generic copy when there is no actor pet (e.g. birthday/anniversary cron)", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
notify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma as any,
{ recipientPetId: "pet-1", type: "BIRTHDAY" }
);
await flush();
expect(sendExpoPushToTokens).toHaveBeenCalledWith(
["ExponentPushToken[abc]"],
expect.objectContaining({ title: "Geburtstag", body: "Ein Tier hat heute Geburtstag." })
);
});
it("sends the PET_SUGGESTION copy with the suggested pet's name", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique
.mockResolvedValueOnce({ ownerId: "owner-1" }) // recipient lookup
.mockResolvedValueOnce({ name: "Luna" }); // actor (suggested pet) lookup
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
notify(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPrisma as any,
{ recipientPetId: "pet-1", type: "PET_SUGGESTION", actorPetId: "pet-2" }
);
await flush();
expect(sendExpoPushToTokens).toHaveBeenCalledWith(
["ExponentPushToken[abc]"],
expect.objectContaining({ title: "Vorschlag für dich", body: "Luna könnte dir gefallen!" })
);
});
it("never throws from notify() even if the push request itself rejects", async () => {
sendExpoPushToTokens.mockRejectedValueOnce(new Error("Expo push down"));
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[abc]" }]);
expect(() =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "COMMENT", actorPetId: "pet-2" })
).not.toThrow();
await flush();
});
});
describe("notify() push to multiple devices", () => {
beforeEach(() => {
sendExpoPushToTokens.mockClear();
});
it("pushes to every registered device plus the legacy token, without duplicates", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1" }).mockResolvedValueOnce({ name: "Rex" });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[phone]" }, { token: "ExponentPushToken[tablet]" }]);
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[phone]" }]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" });
await flush();
expect(sendExpoPushToTokens).toHaveBeenCalledWith(["ExponentPushToken[phone]", "ExponentPushToken[tablet]"], expect.anything());
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: { in: ["owner-1"] } }, select: { token: true } });
});
it("deletes tokens Expo reports as no longer registered, including the legacy one", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1" }).mockResolvedValueOnce(null);
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[old]" }, { token: "ExponentPushToken[new]" }]);
mockPrisma.owner.findMany.mockResolvedValue([{ id: "owner-1", expoPushToken: "ExponentPushToken[old]" }]);
sendExpoPushToTokens.mockResolvedValueOnce({ invalidTokens: ["ExponentPushToken[old]"] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "BIRTHDAY" });
await flush();
expect(mockPrisma.pushToken.deleteMany).toHaveBeenCalledWith({ where: { token: { in: ["ExponentPushToken[old]"] } } });
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({ where: { id: { in: ["owner-1"] } }, data: { expoPushToken: null } });
});
});
describe("notify() push to a co-caretaker (Mitbetreuer)", () => {
beforeEach(() => {
sendExpoPushToTokens.mockClear();
});
it("also pushes to the devices of an ACTIVE co-caretaker", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique
.mockResolvedValueOnce({ ownerId: "owner-1", caretaker: { ownerId: "owner-2", status: "ACTIVE" } })
.mockResolvedValueOnce({ name: "Rex" });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[main]" }, { token: "ExponentPushToken[care]" }]);
mockPrisma.owner.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "FOLLOW", actorPetId: "pet-2" });
await flush();
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: { in: ["owner-1", "owner-2"] } }, select: { token: true } });
expect(sendExpoPushToTokens).toHaveBeenCalledWith(["ExponentPushToken[main]", "ExponentPushToken[care]"], expect.anything());
});
it("ignores a co-caretaker whose request is still PENDING", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1", caretaker: { ownerId: "owner-2", status: "PENDING" } });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[main]" }]);
mockPrisma.owner.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "BIRTHDAY" });
await flush();
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: { in: ["owner-1"] } }, select: { token: true } });
});
it("sends CARETAKER_REQUEST only to the main owner", async () => {
const mockPrisma = createMockPrisma();
mockPrisma.notification.create.mockResolvedValue({});
mockPrisma.pet.findUnique.mockResolvedValueOnce({ ownerId: "owner-1", caretaker: { ownerId: "owner-2", status: "ACTIVE" } });
mockPrisma.pushToken.findMany.mockResolvedValue([{ token: "ExponentPushToken[main]" }]);
mockPrisma.owner.findMany.mockResolvedValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
notify(mockPrisma as any, { recipientPetId: "pet-1", type: "CARETAKER_REQUEST" });
await flush();
expect(mockPrisma.pushToken.findMany).toHaveBeenCalledWith({ where: { ownerId: { in: ["owner-1"] } }, select: { token: true } });
expect(sendExpoPushToTokens).toHaveBeenCalledWith(["ExponentPushToken[main]"], expect.objectContaining({ title: "Anfrage zur Mitbetreuung" }));
});
});