diff --git a/apps/api/src/tenders/tender-digest.scheduler.spec.ts b/apps/api/src/tenders/tender-digest.scheduler.spec.ts new file mode 100644 index 0000000..beb3835 --- /dev/null +++ b/apps/api/src/tenders/tender-digest.scheduler.spec.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TenderDigestScheduler } from './tender-digest.scheduler'; + +/** + * TenderDigestScheduler.spec — ONE global cron, findMany over ALL due users + * (Plan 12-02, Task 2, RED first). Covers NOTIFY-01/03: due-date selection + * (daily always, weekly only on Monday Europe/Berlin, off never, missing + * pref row defaults to daily — D-01), multi-tenant safety via findMany + * (never findFirst — Pitfall 1), one sectioned mail per user (D-02), the + * no-double-send notifiedAt gate (D-06), and per-user robustness so one + * broken user/tenant never aborts the whole run (Pitfall 6). + * + * A hand-rolled Prisma-shaped fake is used (this repo's established + * convention — see tender-matching.service.spec.ts / tender-ingestion.service.spec.ts). + * + * 2026-07-27 is a verified Monday, 2026-07-28 a verified Tuesday + * (Europe/Berlin) — passed explicitly to `runDigest(now)` rather than faking + * the system clock. + */ + +const MONDAY = new Date('2026-07-27T10:00:00Z'); +const TUESDAY = new Date('2026-07-28T10:00:00Z'); + +function makeFakePrisma(opts: { + matches?: any[]; + prefs?: Map; + users?: Map; +}) { + const matches = opts.matches ?? []; + const prefs = opts.prefs ?? new Map(); + const users = opts.users ?? new Map(); + + const tenderMatch = { + findMany: vi.fn(async ({ where, distinct }: any) => { + let rows = matches.filter((m) => { + if (where?.userId !== undefined && m.userId !== where.userId) return false; + if ('notifiedAt' in (where ?? {}) && where.notifiedAt === null && m.notifiedAt !== null) { + 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 })); + }), + updateMany: vi.fn(async ({ where, data }: any) => { + const ids: string[] = where.id.in; + let count = 0; + for (const m of matches) { + if (ids.includes(m.id)) { + Object.assign(m, data); + count++; + } + } + return { count }; + }), + }; + + return { + 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, prefs, users }, + }; +} + +function makeMatch(overrides: Partial> = {}) { + return { + id: overrides.id ?? `match-${Math.random().toString(36).slice(2)}`, + tenderId: overrides.tenderId ?? 'tender-1', + savedSearchId: overrides.savedSearchId ?? 'search-1', + userId: overrides.userId ?? 'user-1', + tenantId: overrides.tenantId ?? 'tenant-1', + notifiedAt: overrides.notifiedAt ?? null, + notifiedChannel: overrides.notifiedChannel ?? null, + tender: overrides.tender ?? { id: 'tender-1', title: 'Tender A' }, + savedSearch: overrides.savedSearch ?? { id: 'search-1', name: 'Profil A' }, + }; +} + +function makeSchedulerRegistry() { + return { + addCronJob: vi.fn(), + getCronJob: vi.fn(() => { + throw new Error('not registered'); + }), + deleteCronJob: vi.fn(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('TenderDigestScheduler — Fälligkeit (D-01)', () => { + it('a daily user is due on every run', async () => { + const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]); + const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]); + const matches = [makeMatch({ userId: 'user-1' })]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + expect(mail.sendDigest).toHaveBeenCalledTimes(1); + }); + + it('a weekly user is due ONLY on Monday (Europe/Berlin) — skipped on other weekdays', async () => { + const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]); + const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'weekly' }]]); + const matches = [makeMatch({ userId: 'user-1' })]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + expect(mail.sendDigest).not.toHaveBeenCalled(); + + await scheduler.runDigest(MONDAY); + expect(mail.sendDigest).toHaveBeenCalledTimes(1); + }); + + it('an "off" user is never due, even with open matches', async () => { + const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]); + const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'off' }]]); + const matches = [makeMatch({ userId: 'user-1' })]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(MONDAY); + await scheduler.runDigest(TUESDAY); + + expect(mail.sendDigest).not.toHaveBeenCalled(); + }); + + it('a user WITHOUT a pref row is treated as daily (default, D-01)', async () => { + const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]); + const matches = [makeMatch({ userId: 'user-1' })]; + const prisma = makeFakePrisma({ matches, users }); // no prefs map entry at all + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + expect(mail.sendDigest).toHaveBeenCalledTimes(1); + }); +}); + +describe('TenderDigestScheduler — Multi-Tenant via findMany (Pitfall 1)', () => { + it('two users in two different tenants, both daily, both receive their own sendDigest call with their own tenantId — never only the first', async () => { + const users = new Map([ + ['user-1', { id: 'user-1', email: 'a@tenant-a.de', tenantId: 'tenant-a' }], + ['user-2', { id: 'user-2', email: 'b@tenant-b.de', tenantId: 'tenant-b' }], + ]); + const prefs = new Map([ + ['user-1', { userId: 'user-1', digestInterval: 'daily' }], + ['user-2', { userId: 'user-2', digestInterval: 'daily' }], + ]); + const matches = [ + makeMatch({ id: 'm1', userId: 'user-1', tenantId: 'tenant-a' }), + makeMatch({ id: 'm2', userId: 'user-2', tenantId: 'tenant-b' }), + ]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + expect(mail.sendDigest).toHaveBeenCalledTimes(2); + const tenantIds = mail.sendDigest.mock.calls.map((call: any[]) => call[1]); + expect(tenantIds).toContain('tenant-a'); + expect(tenantIds).toContain('tenant-b'); + }); + + it('never uses findFirst anywhere in the module source — findMany over all due users is mandatory (Pitfall 1)', async () => { + const { readFileSync } = await import('fs'); + const { join } = await import('path'); + const source = readFileSync(join(__dirname, 'tender-digest.scheduler.ts'), 'utf8'); + expect(source).not.toMatch(/findFirst/); + }); +}); + +describe('TenderDigestScheduler — Gruppierung + eine Mail (D-02)', () => { + it('a user with matches from 2 profiles gets exactly ONE sendDigest call, sections containing both profiles', async () => { + const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]); + const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]); + const matches = [ + makeMatch({ + id: 'm1', + userId: 'user-1', + savedSearchId: 'search-a', + savedSearch: { id: 'search-a', name: 'Profil A' }, + tender: { id: 'tender-1', title: 'Tender A' }, + }), + makeMatch({ + id: 'm2', + userId: 'user-1', + savedSearchId: 'search-b', + savedSearch: { id: 'search-b', name: 'Profil B' }, + tender: { id: 'tender-2', title: 'Tender B' }, + }), + ]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + expect(mail.sendDigest).toHaveBeenCalledTimes(1); + const sections = mail.sendDigest.mock.calls[0][2]; + expect(Object.keys(sections)).toEqual(expect.arrayContaining(['Profil A', 'Profil B'])); + expect(sections['Profil A']).toHaveLength(1); + expect(sections['Profil B']).toHaveLength(1); + }); +}); + +describe('TenderDigestScheduler — kein Doppelversand (D-06)', () => { + it('only notifiedAt=NULL matches are selected; after a successful send, updateMany stamps notifiedAt=now + channel=digest on exactly those match IDs', async () => { + const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]); + const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]); + const alreadyInstantNotified = makeMatch({ + id: 'm-instant', + userId: 'user-1', + notifiedAt: new Date('2026-07-01T00:00:00Z'), + notifiedChannel: 'instant', + }); + const openMatch = makeMatch({ id: 'm-open', userId: 'user-1' }); + const matches = [alreadyInstantNotified, openMatch]; + const prisma = makeFakePrisma({ matches, prefs, users }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await scheduler.runDigest(TUESDAY); + + // Only the un-notified match is passed to sendDigest's sections. + const sections = mail.sendDigest.mock.calls[0][2]; + const allTenders = Object.values(sections).flat(); + expect(allTenders).toHaveLength(1); + + // Already-instant-notified match is untouched; the open one is now stamped. + expect(alreadyInstantNotified.notifiedAt).toEqual(new Date('2026-07-01T00:00:00Z')); + expect(alreadyInstantNotified.notifiedChannel).toBe('instant'); + expect(openMatch.notifiedAt).toBeInstanceOf(Date); + expect(openMatch.notifiedChannel).toBe('digest'); + }); +}); + +describe('TenderDigestScheduler — Robustheit (Pitfall 6)', () => { + it('when sendDigest fails/skips for one user, their matches stay notifiedAt=NULL and the run continues with other users (no cron crash)', async () => { + const users = new Map([ + ['user-1', { id: 'user-1', email: 'a@tenant-a.de', tenantId: 'tenant-a' }], + ['user-2', { id: 'user-2', email: 'b@tenant-b.de', tenantId: 'tenant-b' }], + ]); + const prefs = new Map([ + ['user-1', { userId: 'user-1', digestInterval: 'daily' }], + ['user-2', { userId: 'user-2', digestInterval: 'daily' }], + ]); + const failingMatch = makeMatch({ id: 'm-fail', userId: 'user-1', tenantId: 'tenant-a' }); + const succeedingMatch = makeMatch({ id: 'm-ok', userId: 'user-2', tenantId: 'tenant-b' }); + const matches = [failingMatch, succeedingMatch]; + const prisma = makeFakePrisma({ matches, prefs, users }); + // tenant-a has no SMTP config -> sendDigest resolves false (skip, no throw) + const mail = { + sendDigest: vi.fn(async (_user: any, tenantId: string) => tenantId !== 'tenant-a'), + }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await expect(scheduler.runDigest(TUESDAY)).resolves.toBeUndefined(); + + expect(mail.sendDigest).toHaveBeenCalledTimes(2); // both users attempted + expect(failingMatch.notifiedAt).toBeNull(); // stays NULL -> retried next run + expect(succeedingMatch.notifiedAt).toBeInstanceOf(Date); + expect(succeedingMatch.notifiedChannel).toBe('digest'); + }); + + it('an unexpected thrown error for one user (e.g. broken pref lookup) does not abort the run for other users', async () => { + const users = new Map([ + ['user-1', { id: 'user-1', email: 'a@tenant-a.de', tenantId: 'tenant-a' }], + ['user-2', { id: 'user-2', email: 'b@tenant-b.de', tenantId: 'tenant-b' }], + ]); + const prefs = new Map([['user-2', { userId: 'user-2', digestInterval: 'daily' }]]); + const matches = [ + makeMatch({ id: 'm1', userId: 'user-1', tenantId: 'tenant-a' }), + makeMatch({ id: 'm2', userId: 'user-2', tenantId: 'tenant-b' }), + ]; + const prisma = makeFakePrisma({ matches, prefs, users }); + prisma.tenderNotificationPref.findUnique = vi.fn(async ({ where }: any) => { + if (where.userId === 'user-1') throw new Error('DB connection lost'); + return prefs.get(where.userId) ?? null; + }); + const mail = { sendDigest: vi.fn().mockResolvedValue(true) }; + const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any); + + await expect(scheduler.runDigest(TUESDAY)).resolves.toBeUndefined(); + + expect(mail.sendDigest).toHaveBeenCalledTimes(1); // user-2 still processed + expect(mail.sendDigest.mock.calls[0][1]).toBe('tenant-b'); + }); +}); + +describe('TenderDigestScheduler — Cron-Registrierung (Phase-10-Muster)', () => { + it('onModuleInit registers exactly one global cron job named "tender-digest"', () => { + const registry = makeSchedulerRegistry(); + const prisma = makeFakePrisma({}); + const mail = { sendDigest: vi.fn() }; + const scheduler = new TenderDigestScheduler(registry as any, prisma as any, mail as any); + + scheduler.onModuleInit(); + + expect(registry.addCronJob).toHaveBeenCalledTimes(1); + expect(registry.addCronJob.mock.calls[0][0]).toBe('tender-digest'); + }); +});