From 5395ce6b6d2c3e54367b7a37949ff2bb2ec4bd3f Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 22 Jul 2026 09:22:04 +0200 Subject: [PATCH] test(12-03): prove instant+digest single-mail guarantee across both channels Integration spec runs TenderMatchingService.matchDelta and TenderDigestScheduler.runDigest against one shared mocked-Prisma store (no live DB, mocked TenderMailService, no live SMTP) to prove the NOTIFY-03 core invariant end-to-end: - instantAlert=true: matchDelta sends exactly one instant mail and stamps notifiedAt='instant'; the subsequent digest run then sees zero eligible matches for that user and sends zero digest mails (sendInstant=1, sendDigest=0). - instantAlert=false: matchDelta never dispatches instant; the digest run is the only channel and sends exactly one mail (sendInstant=0, sendDigest=1). Both scenarios assert total mail count across channels === 1. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tender-notifications.integration.spec.ts | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 apps/api/src/tenders/tender-notifications.integration.spec.ts diff --git a/apps/api/src/tenders/tender-notifications.integration.spec.ts b/apps/api/src/tenders/tender-notifications.integration.spec.ts new file mode 100644 index 0000000..3512e98 --- /dev/null +++ b/apps/api/src/tenders/tender-notifications.integration.spec.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TenderMatchingService } from './tender-matching.service'; +import { TenderDigestScheduler } from './tender-digest.scheduler'; + +/** + * Integration spec (Plan 12-03, Task 2) — proves the NOTIFY-03 core + * invariant across BOTH notification channels: a tender x saved-search + * pair is notified EXACTLY ONCE, never once via instant AND once via + * digest. + * + * `matchDelta` (instant channel, 12-03) and `runDigest` (digest channel, + * 12-02) both read/write the SAME `TenderMatch.notifiedAt` eligibility + * gate. This spec runs both services against ONE shared, hand-rolled + * Prisma-shaped fake (this repo's established convention — see + * tender-matching.service.spec.ts / tender-digest.scheduler.spec.ts) with a + * mocked `TenderMailService`, so state genuinely flows from the instant + * dispatch into the digest's candidate selection — no live DB, no live + * SMTP. + * + * Scenario A (instantAlert=true): matchDelta sends exactly one instant + * mail and stamps notifiedAt='instant' — the subsequent digest run must + * then see zero eligible matches for that user and send NO digest mail. + * + * Scenario B (instantAlert=false): matchDelta never dispatches instant — + * the subsequent digest run is the only channel that ever notifies, and + * sends exactly one digest mail. + * + * Together: sendInstant + sendDigest === 1, always, regardless of channel. + */ + +function makeSharedFakePrisma(opts: { + savedSearch: any; + user: any; + pref?: any; + tenderId: string; + tenderTitle: string; +}) { + const { savedSearch, user, pref, tenderId, tenderTitle } = opts; + const matches = new Map(); + const users = new Map([[user.id, user]]); + const prefs = new Map(pref ? [[pref.userId, pref]] : []); + const tenders = new Map([[tenderId, { id: tenderId, title: tenderTitle }]]); + + const tenderMatch = { + upsert: vi.fn(async ({ where, update, create }: any) => { + const key = `${where.tenderId_savedSearchId.tenderId}::${where.tenderId_savedSearchId.savedSearchId}`; + const existing = matches.get(key); + if (existing) { + matches.set(key, { ...existing, ...update }); + return matches.get(key); + } + const created = { + id: `match-${matches.size + 1}`, + notifiedAt: null, + notifiedChannel: null, + ...create, + }; + matches.set(key, created); + return created; + }), + // Shared findMany handles BOTH matchDelta's fresh-match lookup + // ({savedSearchId, notifiedAt:null, tenderId:{in}}) AND runDigest's two + // lookups (candidate distinct-userId scan; per-user un-notified matches). + findMany: vi.fn(async ({ where, distinct }: any) => { + let rows = Array.from(matches.values()).filter((m) => { + if (where?.savedSearchId !== undefined && m.savedSearchId !== where.savedSearchId) { + return false; + } + if (where?.userId !== undefined && m.userId !== where.userId) return false; + if ('notifiedAt' in (where ?? {}) && where.notifiedAt === null && m.notifiedAt != null) { + return false; + } + const tenderIdIn: string[] | undefined = where?.tenderId?.in; + if (tenderIdIn && !tenderIdIn.includes(m.tenderId)) return false; + return true; + }); + if (distinct) { + const seen = new Set(); + rows = rows.filter((m) => { + const key = distinct.map((f: string) => m[f]).join(':'); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + return rows.map((m) => ({ + ...m, + tender: tenders.get(m.tenderId) ?? { id: m.tenderId, title: m.tenderId }, + savedSearch: m.savedSearchId === savedSearch.id ? savedSearch : undefined, + })); + }), + updateMany: vi.fn(async ({ where, data }: any) => { + const ids: string[] = where.id.in; + let count = 0; + for (const m of matches.values()) { + if (ids.includes(m.id)) { + Object.assign(m, data); + count++; + } + } + return { count }; + }), + }; + + return { + tenderSavedSearch: { findMany: vi.fn(async () => [savedSearch]) }, + tender: { + findMany: vi.fn(async ({ where }: any) => { + const idFilter: string[] = where.AND.find((c: any) => c.id)?.id?.in ?? []; + return idFilter.filter((id) => id === tenderId).map((id) => ({ id })); + }), + }, + tenderMatch, + tenderNotificationPref: { + findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null), + }, + user: { + findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null), + }, + __store: { matches }, + }; +} + +function makeSchedulerRegistry() { + return { + addCronJob: vi.fn(), + getCronJob: vi.fn(() => { + throw new Error('not registered'); + }), + deleteCronJob: vi.fn(), + }; +} + +const FIXED_TUESDAY = new Date('2026-07-28T10:00:00Z'); // verified non-Monday (Europe/Berlin) + +describe('Tender-Notifications Integration — instant + digest ergeben genau EINE Mail (NOTIFY-03/D-06)', () => { + it('instantAlert=true: matchDelta sendet genau eine Instant-Mail und stempelt notifiedAt — der Digest-Lauf danach sieht kein offenes Match mehr und sendet KEINE Mail (sendInstant=1, sendDigest=0)', async () => { + const savedSearch = { + id: 'search-instant', + userId: 'user-instant', + tenantId: 'tenant-1', + name: 'Profil Instant', + filters: { q: 'Straßenbau' }, + instantAlert: true, + }; + const user = { id: 'user-instant', email: 'instant@tenant-1.de', tenantId: 'tenant-1' }; + const pref = { userId: 'user-instant', digestInterval: 'daily' }; + const prisma = makeSharedFakePrisma({ + savedSearch, + user, + pref, + tenderId: 'tender-instant', + tenderTitle: 'Neuer Tender (Instant)', + }); + + const sendInstant = vi.fn().mockResolvedValue(true); + const sendDigest = vi.fn().mockResolvedValue(true); + const mail = { sendInstant, sendDigest }; + + const matching = new TenderMatchingService(prisma as any, mail as any); + await matching.matchDelta(['tender-instant']); + + // (1) matchDelta dispatched exactly one instant mail and stamped the match. + expect(sendInstant).toHaveBeenCalledTimes(1); + const stamped = prisma.__store.matches.get('tender-instant::search-instant'); + expect(stamped.notifiedAt).toBeInstanceOf(Date); + expect(stamped.notifiedChannel).toBe('instant'); + + // (2) The subsequent digest run sees zero eligible (notifiedAt=NULL) matches + // for this user -> no sendDigest call at all. + const digest = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + await digest.runDigest(FIXED_TUESDAY); + + expect(sendDigest).not.toHaveBeenCalled(); + + // Overall: exactly ONE mail across both channels. + expect(sendInstant.mock.calls.length + sendDigest.mock.calls.length).toBe(1); + }); + + it('instantAlert=false: matchDelta sendet NIE eine Instant-Mail — der Digest-Lauf ist der einzige Kanal und sendet genau eine Mail (sendInstant=0, sendDigest=1)', async () => { + const savedSearch = { + id: 'search-normal', + userId: 'user-normal', + tenantId: 'tenant-1', + name: 'Profil Digest', + filters: { q: 'Straßenbau' }, + instantAlert: false, + }; + const user = { id: 'user-normal', email: 'normal@tenant-1.de', tenantId: 'tenant-1' }; + const pref = { userId: 'user-normal', digestInterval: 'daily' }; + const prisma = makeSharedFakePrisma({ + savedSearch, + user, + pref, + tenderId: 'tender-normal', + tenderTitle: 'Neuer Tender (Digest)', + }); + + const sendInstant = vi.fn().mockResolvedValue(true); + const sendDigest = vi.fn().mockResolvedValue(true); + const mail = { sendInstant, sendDigest }; + + const matching = new TenderMatchingService(prisma as any, mail as any); + await matching.matchDelta(['tender-normal']); + + // (1) instantAlert=false -> matchDelta never dispatches an instant mail; + // the match remains un-notified (notifiedAt still NULL). + expect(sendInstant).not.toHaveBeenCalled(); + const unstamped = prisma.__store.matches.get('tender-normal::search-normal'); + expect(unstamped.notifiedAt).toBeNull(); + + // (2) The digest run is now the only channel: it picks up the still-open + // match and sends exactly one digest mail, stamping notifiedAt='digest'. + const digest = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + await digest.runDigest(FIXED_TUESDAY); + + expect(sendDigest).toHaveBeenCalledTimes(1); + const stamped = prisma.__store.matches.get('tender-normal::search-normal'); + expect(stamped.notifiedAt).toBeInstanceOf(Date); + expect(stamped.notifiedChannel).toBe('digest'); + + // Overall: exactly ONE mail across both channels. + expect(sendInstant.mock.calls.length + sendDigest.mock.calls.length).toBe(1); + }); +});