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); }); });