import { describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "@prisma/client"; import { buildInstantEmailData, collectDigest, decideEmail, handleNotificationCreated, isSelfNotification, notificationCreatedPayloadSchema, resolveDeepLink, runDigestRun, DIGEST_MAX_ITEMS, type NotificationCreatedPayload } from "../processors/email-decision.processor.js"; import { renderEmail, emailJobSchema, stampEmailSent } from "../processors/email.processor.js"; import { parseEvent, tryParseEvent } from "../events.js"; import type { JobsOptions } from "bullmq"; const ENV = { PUBLIC_URL: "http://factory.test" }; const WS = { slug: "acme", id: "ws1", name: "Acme" }; // --------------------------------------------------------------------------- // fakes // --------------------------------------------------------------------------- interface FakePrismaOpts { notification?: (where: { id: string }) => unknown; user?: (where: { id: string }) => unknown; workspace?: (where: { id: string }) => unknown; databaseRecord?: (where: { id: string }) => unknown; comment?: (where: { id: string }) => unknown; notificationMany?: () => unknown[]; } function fakePrisma(opts: FakePrismaOpts = {}) { const updateMany = vi.fn(async () => ({ count: 1 })); const prisma = { notification: { findUnique: vi.fn(async (a: { where: { id: string } }) => opts.notification?.(a.where) ?? null), findMany: vi.fn(async () => opts.notificationMany?.() ?? []), updateMany }, user: { findUnique: vi.fn(async (a: { where: { id: string } }) => opts.user?.(a.where) ?? null) }, workspace: { findUnique: vi.fn(async (a: { where: { id: string } }) => opts.workspace?.(a.where) ?? null) }, databaseRecord: { findUnique: vi.fn(async (a: { where: { id: string } }) => opts.databaseRecord?.(a.where) ?? null) }, comment: { findUnique: vi.fn(async (a: { where: { id: string } }) => opts.comment?.(a.where) ?? null) } }; return { prisma: prisma as unknown as PrismaClient, updateMany }; } type EnqCall = { queue: string; data: Record; opts: JobsOptions | undefined }; function fakeEnqueue() { const calls: EnqCall[] = []; // Signature is contravariant over `typeof enqueue`: opts must accept // `JobsOptions | undefined`. const fn = async ( _c: unknown, queue: string, data: Record, opts?: JobsOptions ) => { calls.push({ queue, data, opts }); return {} as never; }; return { fn, calls }; } // --------------------------------------------------------------------------- // decideEmail (pure) // --------------------------------------------------------------------------- describe("decideEmail", () => { const user = { id: "u1" }; it("MENTION with default prefs → instant", () => { expect(decideEmail({ type: "MENTION", emailSentAt: null, actorId: "u2" }, user, {})).toEqual({ kind: "instant" }); }); it("COMMENT with default prefs → digest", () => { expect(decideEmail({ type: "COMMENT", emailSentAt: null, actorId: "u2" }, user, {})).toEqual({ kind: "digest" }); }); it("pref 'off' → skip(pref-off)", () => { expect( decideEmail({ type: "MENTION", emailSentAt: null, actorId: "u2" }, user, { MENTION: "off" }) ).toEqual({ kind: "skip", reason: "pref-off" }); }); it("pref 'all' → instant", () => { expect( decideEmail({ type: "COMMENT", emailSentAt: null, actorId: "u2" }, user, { COMMENT: "all" }) ).toEqual({ kind: "instant" }); }); it("already emailed (emailSentAt set) → skip(already-sent)", () => { expect( decideEmail({ type: "MENTION", emailSentAt: new Date(), actorId: "u2" }, user, {}) ).toEqual({ kind: "skip", reason: "already-sent" }); }); it("user gone → skip(user-not-found)", () => { expect( decideEmail({ type: "MENTION", emailSentAt: null, actorId: "u2" }, null, {}) ).toEqual({ kind: "skip", reason: "user-not-found" }); }); it("self-notification (actorId === userId) → skip(self-notification)", () => { expect( decideEmail({ type: "MENTION", emailSentAt: null, actorId: "u1" }, { id: "u1" }, {}) ).toEqual({ kind: "skip", reason: "self-notification" }); }); it("unknown type → skip(unknown-type)", () => { expect(decideEmail({ type: "WEIRD", emailSentAt: null, actorId: "u2" }, user, {})).toEqual({ kind: "skip", reason: "unknown-type" }); }); it("malformed stored prefs fall back to defaults (never throw)", () => { expect(decideEmail({ type: "MENTION", emailSentAt: null, actorId: "u2" }, user, "garbage")).toEqual( { kind: "instant" } ); }); it("partial stored prefs merge over defaults (COMMENT override → instant)", () => { expect(decideEmail({ type: "COMMENT", emailSentAt: null, actorId: "u2" }, user, { COMMENT: "instant" })).toEqual( { kind: "instant" } ); }); }); describe("isSelfNotification", () => { it("true when actorId === userId", () => { expect(isSelfNotification("u1", "u1")).toBe(true); }); it("false on a different actor or null", () => { expect(isSelfNotification("u2", "u1")).toBe(false); expect(isSelfNotification(null, "u1")).toBe(false); }); }); // --------------------------------------------------------------------------- // buildInstantEmailData // --------------------------------------------------------------------------- describe("buildInstantEmailData", () => { it("builds title + actor + absolute link, carries the notificationIds", () => { const d = buildInstantEmailData({ to: "u@x.io", workspaceName: "Acme", title: "Bob mentioned you", actorName: "Bob", link: { type: "page", id: "p1" }, url: "http://factory.test/w/acme/page/p1", notificationId: "n1" }); expect(d.to).toBe("u@x.io"); expect(d.subject).toBe("Acme: Bob mentioned you"); expect(d.notificationIds).toEqual(["n1"]); expect(String(d.html)).toContain("http://factory.test/w/acme/page/p1"); expect(String(d.html)).toContain("Bob"); expect(String(d.text)).toContain("Bob mentioned you"); }); it("omits the link line when the target is unresolvable", () => { const d = buildInstantEmailData({ to: "u@x.io", workspaceName: "Acme", title: "T", actorName: null, link: null, url: null, notificationId: "n2" }); expect(String(d.html)).toContain("(open the workspace for details)"); }); }); // --------------------------------------------------------------------------- // resolveDeepLink (fake prisma) // --------------------------------------------------------------------------- describe("resolveDeepLink", () => { const ws = () => fakePrisma({ workspace: () => WS }); it("page → /w/{slug}/page/{id}", async () => { expect(await resolveDeepLink(ws().prisma, ENV, "ws1", { type: "page", id: "p1" })).toBe( "http://factory.test/w/acme/page/p1" ); }); it("database → /w/{slug}/databases/{id}", async () => { expect(await resolveDeepLink(ws().prisma, ENV, "ws1", { type: "database", id: "db1" })).toBe( "http://factory.test/w/acme/databases/db1" ); }); it("record → its database", async () => { const { prisma } = fakePrisma({ workspace: () => WS, databaseRecord: () => ({ databaseId: "db1" }) }); expect(await resolveDeepLink(prisma, ENV, "ws1", { type: "record", id: "r1" })).toBe( "http://factory.test/w/acme/databases/db1" ); }); it("comment → its page", async () => { const { prisma } = fakePrisma({ workspace: () => WS, comment: () => ({ pageId: "p1", recordId: null }) }); expect(await resolveDeepLink(prisma, ENV, "ws1", { type: "comment", id: "c1" })).toBe( "http://factory.test/w/acme/page/p1" ); }); it("comment → its record's database (no page)", async () => { const { prisma } = fakePrisma({ workspace: () => WS, comment: () => ({ pageId: null, recordId: "r1" }), databaseRecord: () => ({ databaseId: "db9" }) }); expect(await resolveDeepLink(prisma, ENV, "ws1", { type: "comment", id: "c1" })).toBe( "http://factory.test/w/acme/databases/db9" ); }); it("missing workspace → null", async () => { const { prisma } = fakePrisma({}); expect(await resolveDeepLink(prisma, ENV, "ghost", { type: "page", id: "p1" })).toBeNull(); }); it("no link → workspace root; unknown type → workspace root", async () => { expect(await resolveDeepLink(ws().prisma, ENV, "ws1", null)).toBe("http://factory.test/w/acme"); expect(await resolveDeepLink(ws().prisma, ENV, "ws1", { type: "mystery", id: "x" })).toBe( "http://factory.test/w/acme" ); }); it("strips trailing slash from PUBLIC_URL", async () => { expect(await resolveDeepLink(ws().prisma, { PUBLIC_URL: "http://factory.test/" }, "ws1", { type: "page", id: "p1" })).toBe( "http://factory.test/w/acme/page/p1" ); }); }); // --------------------------------------------------------------------------- // collectDigest (≤ 20 cap + "n more" + type filter) // --------------------------------------------------------------------------- describe("collectDigest", () => { const row = (i: number, type = "COMMENT") => ({ id: `n${i}`, type, title: `T${i}`, link: { type: "page", id: `p${i}` }, actorId: "u2" }); it("caps at 20 and reports the remainder", async () => { const { prisma } = fakePrisma({ workspace: () => WS }); // page-type links resolve without extra reads. const rows = Array.from({ length: 25 }, (_, i) => row(i)); const { items, remaining } = await collectDigest(prisma, ENV, { userId: "u1", workspaceId: "ws1", storedPrefs: {}, rows }); expect(items.length).toBe(DIGEST_MAX_ITEMS); expect(remaining).toBe(5); expect(items[0]).toMatchObject({ notificationId: "n0", title: "T0" }); expect(items[0]?.url).toBe("http://factory.test/w/acme/page/p0"); }); it("excludes non-digest types ('all' / 'off' overrides)", async () => { const { prisma } = fakePrisma({ workspace: () => WS }); const rows = [row(0, "COMMENT"), row(1, "COMMENT"), row(2, "COMMENT")]; const { items } = await collectDigest(prisma, ENV, { userId: "u1", workspaceId: "ws1", storedPrefs: {}, rows }); expect(items.map((i) => i.notificationId)).toEqual(["n0", "n1", "n2"]); // 'all' override: COMMENT no longer batches into the digest. const all = await collectDigest(prisma, ENV, { userId: "u1", workspaceId: "ws1", storedPrefs: { COMMENT: "all" }, rows: [row(0, "COMMENT")] }); expect(all.items).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // runDigestRun (hourly collector: one email per (user, workspace)) // --------------------------------------------------------------------------- describe("runDigestRun", () => { it("enqueues ONE digest email per (user, workspace) with the collected ids", async () => { const rows = [ { id: "n1", userId: "u1", workspaceId: "ws1", type: "COMMENT", title: "A comment", link: { type: "page", id: "p1" }, actorId: "u2" }, { id: "n2", userId: "u1", workspaceId: "ws1", type: "DATABASE_CHANGE", title: "A db change", link: { type: "database", id: "db1" }, actorId: "u2" } ]; const { prisma } = fakePrisma({ notificationMany: () => rows, user: () => ({ email: "u@x.io", notificationPrefs: {} }), workspace: () => WS }); const { fn, calls } = fakeEnqueue(); const res = await runDigestRun(prisma, "conn" as never, ENV, fn as never); expect(res.enqueued).toBe(1); expect(calls).toHaveLength(1); expect(calls[0]?.queue).toBe("email"); expect(calls[0]?.opts?.jobId).toBe("digest:u1:ws1"); const data = calls[0]?.data as { to: string; subject: string; template: string; vars: { items: string[]; url: string }; notificationIds: string[]; }; expect(data.to).toBe("u@x.io"); expect(data.subject).toBe("Your Factory digest"); expect(data.template).toBe("digest"); expect(data.notificationIds).toEqual(["n1", "n2"]); expect(data.vars.url).toBe("http://factory.test/w/acme/notifications"); expect(data.vars.items).toContain("u2 — A comment"); }); it("does nothing when there are no unsent rows (idempotent re-run)", async () => { const { prisma } = fakePrisma({ notificationMany: () => [] }); const { fn, calls } = fakeEnqueue(); const res = await runDigestRun(prisma, "conn" as never, ENV, fn as never); expect(res.enqueued).toBe(0); expect(calls).toHaveLength(0); }); it("appends a '+ n more' line past the 20-item cap", async () => { const rows = Array.from({ length: 25 }, (_, i) => ({ id: `n${i}`, userId: "u1", workspaceId: "ws1", type: "COMMENT", title: `T${i}`, link: { type: "page", id: `p${i}` }, actorId: "u2" })); const { prisma } = fakePrisma({ notificationMany: () => rows, user: () => ({ email: "u@x.io", notificationPrefs: {} }), workspace: () => WS }); const { fn, calls } = fakeEnqueue(); await runDigestRun(prisma, "conn" as never, ENV, fn as never); const data = calls[0]?.data as { notificationIds: string[]; vars: { items: string[] } }; expect(data.notificationIds).toHaveLength(DIGEST_MAX_ITEMS); expect(data.vars.items.at(-1)).toBe("+ 5 more (open your inbox)"); }); }); // --------------------------------------------------------------------------- // handleNotificationCreated (full decision→enqueue path) // --------------------------------------------------------------------------- const payload = (over: Partial = {}): NotificationCreatedPayload => ({ workspaceId: "ws1", notificationId: "n1", userId: "u1", type: "MENTION", title: "Bob mentioned you", deepLink: { type: "page", id: "p1" }, actorName: "Bob", ...over }) as NotificationCreatedPayload; describe("handleNotificationCreated", () => { it("MENTION + default prefs → enqueues an instant email with the job key", async () => { const { prisma } = fakePrisma({ notification: () => ({ type: "MENTION", emailSentAt: null, actorId: "u2", userId: "u1" }), user: () => ({ id: "u1", email: "u@x.io", notificationPrefs: {} }), workspace: () => WS }); const { fn, calls } = fakeEnqueue(); const res = await handleNotificationCreated(prisma, "conn" as never, ENV, payload(), fn); expect(res.action).toBe("instant"); expect(calls).toHaveLength(1); expect(calls[0]?.queue).toBe("email"); // The dedup key is colon-FREE: BullMQ 5.x rejects custom jobIds containing // `:` (2-segment) — `email:n1` made every instant-enqueue throw. expect(calls[0]?.opts?.jobId).toBe("email_n1"); expect(calls[0]?.data.notificationIds).toEqual(["n1"]); expect(calls[0]?.data.subject).toBe("Acme: Bob mentioned you"); expect(String(calls[0]?.data.html)).toContain("http://factory.test/w/acme/page/p1"); }); it("COMMENT + digest → does NOT enqueue (left for the hourly digest)", async () => { const { prisma } = fakePrisma({ notification: () => ({ type: "COMMENT", emailSentAt: null, actorId: "u2", userId: "u1" }), user: () => ({ id: "u1", email: "u@x.io", notificationPrefs: {} }) }); const { fn, calls } = fakeEnqueue(); const res = await handleNotificationCreated(prisma, "conn" as never, ENV, payload({ type: "COMMENT" }), fn); expect(res.action).toBe("digest"); expect(calls).toHaveLength(0); }); it("self-notification → no enqueue (suppression)", async () => { const { prisma } = fakePrisma({ notification: () => ({ type: "MENTION", emailSentAt: null, actorId: "u1", userId: "u1" }), user: () => ({ id: "u1", email: "u@x.io", notificationPrefs: {} }) }); const { fn, calls } = fakeEnqueue(); const res = await handleNotificationCreated(prisma, "conn" as never, ENV, payload(), fn); expect(res).toEqual({ action: "skip", reason: "self-notification" }); expect(calls).toHaveLength(0); }); it("already emailed → no enqueue (the redelivery guard)", async () => { const { prisma } = fakePrisma({ notification: () => ({ type: "MENTION", emailSentAt: new Date(), actorId: "u2", userId: "u1" }), user: () => ({ id: "u1", email: "u@x.io", notificationPrefs: {} }) }); const { fn, calls } = fakeEnqueue(); const res = await handleNotificationCreated(prisma, "conn" as never, ENV, payload(), fn); expect(res).toEqual({ action: "skip", reason: "already-sent" }); expect(calls).toHaveLength(0); }); it("row gone → skip(row-not-found), no enqueue", async () => { const { prisma } = fakePrisma({}); const { fn, calls } = fakeEnqueue(); const res = await handleNotificationCreated(prisma, "conn" as never, ENV, payload(), fn); expect(res).toEqual({ action: "skip", reason: "row-not-found" }); expect(calls).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // notificationCreatedPayloadSchema // --------------------------------------------------------------------------- describe("notificationCreatedPayloadSchema", () => { it("accepts a valid payload and passes through extra fields", () => { const ok = notificationCreatedPayloadSchema.safeParse(payload()); expect(ok.success).toBe(true); if (ok.success) expect(ok.data.deepLink).toEqual({ type: "page", id: "p1" }); }); it("rejects a payload missing required fields", () => { const bad = notificationCreatedPayloadSchema.safeParse({ workspaceId: "ws1" }); expect(bad.success).toBe(false); }); }); // --------------------------------------------------------------------------- // email.processor — digest template + schema backward-compat + stamp // --------------------------------------------------------------------------- describe("email.processor", () => { it("digest template: subject 'Your Factory digest', lists items, inbox link", () => { const r = renderEmail({ to: "u@x.io", subject: "Your Factory digest", template: "digest", vars: { items: ["Bob — hi", "+ 3 more (open your inbox)"], url: "http://factory.test/w/acme/notifications" } }); expect(r.subject).toBe("Your Factory digest"); expect(r.html).toContain("Your Factory digest"); expect(r.html).toContain("Bob — hi"); expect(r.text).toContain("- Bob — hi"); expect(r.html).toContain("http://factory.test/w/acme/notifications"); }); it("emailJobSchema is backward-compatible (old shape without notificationIds)", () => { expect(emailJobSchema.safeParse({ to: "u@x.io", subject: "hi", text: "t" }).success).toBe(true); expect(emailJobSchema.safeParse({ to: "u@x.io", subject: "hi", notificationIds: ["n1", "n2"] }).success).toBe(true); }); it("stampEmailSent marks rows (only unsent) and swallows errors", async () => { const { prisma, updateMany } = fakePrisma({}); await stampEmailSent(prisma, ["n1", "n2"]); expect(updateMany).toHaveBeenCalledWith({ where: { id: { in: ["n1", "n2"] }, emailSentAt: null }, data: { emailSentAt: expect.any(Date) } }); updateMany.mockRejectedValueOnce(new Error("db down")); await expect(stampEmailSent(prisma, ["n1"])).resolves.toBeUndefined(); }); }); // --------------------------------------------------------------------------- // events.ts — parseEvent regression (pre-existing `fields` bug) // --------------------------------------------------------------------------- describe("parseEvent (regression)", () => { it("parses a valid envelope (was: ReferenceError → every entry dropped)", () => { const r = parseEvent({ id: "1-0", fields: { event: "notification.created", payload: JSON.stringify({ notificationId: "n1" }), ts: new Date().toISOString() } }); expect(r.envelope.event).toBe("notification.created"); expect(r.envelope.payload).toEqual({ notificationId: "n1" }); }); it("drops a malformed envelope (returns null)", () => { expect( tryParseEvent({ id: "1-1", fields: { event: "", payload: "not-json", ts: "t" } }) ).toBeNull(); }); });