- 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>
222 lines
10 KiB
TypeScript
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" }));
|
|
});
|
|
});
|