/** * T-176/177/178 recorded-conflict REGRESSION (fixed 2026-10-12) — the worker's * instant-notification email jobId must be accepted by REAL BullMQ. * * Background: the old dedup key `email:{notificationId}` (two-segment) is * rejected by BullMQ 5.x `Job.validateOptions` (`Custom Id cannot contain :` — * only a 3-segment colon id is legal, for repeatable compatibility). Every * instant email therefore THREW at enqueue: the stream consumer re-delivered * the entry, `emailSentAt` never stamped, and the user never got the email. * The worker's unit test masked the bug — it injects a MOCKED `enqueue` and * only asserts the jobId string, so it stayed green while the real BullMQ * path was broken. This spec is the real-BullMQ half of the contract: * * 1. `handleNotificationCreated` with the REAL `enqueue` (no enqueueFn * seam) enqueues onto the live test Redis WITHOUT throwing — the exact * line that used to throw. * 2. A second enqueue with the SAME jobId is deduped by BullMQ (the job * stays one) — the property the stable key exists for: the at-least- * once stream redelivers `notification.created`, so the decision runs * twice, and the job-key must absorb the duplicate. * 3. The `email` worker (injected `smtp` — no mail server needed) processes * the job and stamps `Notification.emailSentAt` — the pipeline the old * bug broke end-to-end. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Queue } from "bullmq"; import Redis from "ioredis"; import { createTestApp, type TestAppHandle } from "./helpers/api.js"; import { getRedisUrl } from "./helpers/containers.js"; import { buildInstantEmailData, handleNotificationCreated } from "../../apps/worker/src/processors/email-decision.processor.js"; import { createEmailWorker } from "../../apps/worker/src/processors/email.processor.js"; import { QUEUES, enqueue } from "../../apps/worker/src/queues.js"; import type { WorkerEnv } from "../../apps/worker/src/config.js"; import type { PrismaClient } from "@prisma/client"; describe("instant-notification email jobId (real BullMQ)", () => { let app: TestAppHandle; let redis: Redis; let q: Queue; let worker: ReturnType; let prisma: PrismaClient; let notifId: string; let sent: { to: string; subject: string } | null; beforeAll(async () => { app = await createTestApp(); prisma = app.prisma; redis = new Redis(await getRedisUrl(), { maxRetriesPerRequest: null }); q = new Queue("email", { connection: redis }); // SMTP is FAKE: the worker's `smtp` seam stands in for the transport, so // the job runs the REAL send path (schema parse → render → "send" → // emailSentAt stamp) without a mail server. Worker starts PAUSED — the // dedup assertions below need the job in the WAITING state. sent = null; const env = { SMTP_HOST: "smtp.fake", SMTP_PORT: 587, SMTP_FROM: "noreply@factory.test", SMTP_STARTTLS: false } as unknown as WorkerEnv; worker = createEmailWorker({ connection: redis, prisma, env, concurrency: 1, smtp: async (o) => { sent = { to: String(o.to[0]), subject: o.subject }; } }); await worker.pause(); }, 180_000); afterAll(async () => { await worker?.close(); await q?.close(); await redis?.quit().catch(() => {}); await app?.close(); }, 30_000); it("real enqueue does not throw + dedup on redelivery + worker stamps emailSentAt", async () => { const run = Math.random().toString(36).slice(2, 8); const user = await prisma.user.create({ data: { email: `jobid-probe-${run}@example.com`, name: "JobId Probe" } }); const actor = await prisma.user.create({ data: { email: `jobid-actor-${run}@example.com`, name: "JobId Actor" } }); const ws = await prisma.workspace.create({ data: { name: `JobId WS ${run}`, slug: `jobid-${run}` } }); const notif = await prisma.notification.create({ data: { workspaceId: ws.id, userId: user.id, actorId: actor.id, type: "MENTION", title: "JobId probe mention" } }); notifId = notif.id; // --- 1. the exact line that used to throw ------------------------------- // Real `enqueue` (default, no seam) → real BullMQ `Job.validateOptions`. // Old key `email:${id}`: throws `Custom Id cannot contain :` here. const res = await handleNotificationCreated( prisma, redis, { PUBLIC_URL: "http://factory.test" }, { workspaceId: ws.id, notificationId: notif.id, userId: user.id, type: "MENTION", title: "JobId probe mention", deepLink: { type: "page", id: "p-nonexistent" }, actorName: "JobId Actor" } ); expect(res.action).toBe("instant"); const job = await q.getJob(`email_${notif.id}`); expect(job).toBeTruthy(); // --- 2. dedup: the stream redelivers notification.created — the second // decision runs the SAME enqueue and must NOT add a job. const data = buildInstantEmailData({ to: user.email, workspaceName: ws.name, title: "JobId probe mention", actorName: "JobId Actor", link: { type: "page", id: "p-nonexistent" }, url: "http://factory.test/w/jobid/page/p-nonexistent", notificationId: notif.id }); const re = await enqueue(redis, QUEUES.email, data, { jobId: `email_${notif.id}` }); expect(re.id).toBe(`email_${notif.id}`); const waiting = await q.getJobs(["waiting"], 0, -1); expect(waiting.filter((j) => j.id === `email_${notif.id}`)).toHaveLength(1); // --- 3. the worker sends + stamps emailSentAt --------------------------- // The worker is PAUSED (needed for the dedup assertions above) — arm the // listener, RESUME, THEN await. (Await-before-resume would deadlock: the // paused worker never completes the waiting job.) const finished: Promise = new Promise((resolve, reject) => { const t = setTimeout( () => reject(new Error("email job did not complete in 15 s")), 15_000 ); worker.once("failed", (j, err) => { clearTimeout(t); reject(new Error(`email job FAILED: ${err?.message ?? "?"}`)); }); worker.once("completed", (j) => { clearTimeout(t); resolve(j); }); }); await worker.resume(); const done = (await finished) as { id: string }; expect(done.id).toBe(`email_${notif.id}`); expect(sent?.to).toBe(user.email); expect(sent?.subject).toBeTruthy(); const stamped = await prisma.notification.findUniqueOrThrow({ where: { id: notif.id }, select: { emailSentAt: true } }); expect(stamped.emailSentAt).toBeInstanceOf(Date); // cleanup await prisma.notification.deleteMany({ where: { id: notif.id } }); await prisma.workspace.deleteMany({ where: { id: ws.id } }); await prisma.user.deleteMany({ where: { id: { in: [user.id, actor.id] } } }); }, 30_000); });