import { beforeEach, describe, expect, it, vi } from 'vitest'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; 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. * * Bindung an forTenant() je Schleifendurchlauf (260909-laa, Aufgabe 3, * Befund C/H): `__makeBoundClient()` liefert denselben protokollierenden * Wrapper wie in den Nutzer-CRUD-Diensten dieses Bereichs (Muster aus * `groups.service.spec.ts`) — die uebergreifende Kandidatenabfrage * (`tenderMatch.findMany` mit `distinct`) bleibt UNGEBUNDEN, die drei * Zugriffe je Kandidatenzeile (`tenderNotificationPref.findUnique`, * `tenderMatch.findMany`/`updateMany`, `user.findUnique`) binden an den * Mandanten DIESER Zeile. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), // Systemkontext (260914-eym): die Kandidatenabfrage laeuft ueber forSystem(). forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()), })); 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 boundCallLog: { tenantId: string; model: string; method: string }[] = []; 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 }; }), }; const tenderNotificationPref = { findUnique: vi.fn(async ({ where }: any) => prefs.get(where.userId) ?? null), }; const user = { findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null), }; const modelsByName: Record = { tenderMatch, tenderNotificationPref, user }; const systemCallLog: { model: string; method: string }[] = []; return { tenderMatch, tenderNotificationPref, user, __store: { matches, prefs, users }, __boundCallLog: boundCallLog, __systemCallLog: systemCallLog, // Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog, // tenderMatch.findMany unveraendert (dieselbe Fake-Implementierung). __makeSystemClient() { return { tenderMatch: { findMany: async (args: any) => { systemCallLog.push({ model: 'tenderMatch', method: 'findMany' }); return tenderMatch.findMany(args); }, }, }; }, __makeBoundClient(tenantId: string) { const bound: any = {}; for (const [modelName, model] of Object.entries(modelsByName)) { const wrapped: any = {}; for (const method of Object.keys(model)) { wrapped[method] = async (...args: any[]) => { boundCallLog.push({ tenantId, model: modelName, method }); return model[method](...args); }; } bound[modelName] = wrapped; } return bound; }, }; } 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'); }); }); // --- Bindung an forTenant() je Schleifendurchlauf (260909-laa, Aufgabe 3) --- describe('TenderDigestScheduler — Bindung an forTenant() (260909-laa)', () => { it('die uebergreifende Kandidatenabfrage bindet NICHT — forTenant() wird fuer sie nicht aufgerufen', async () => { vi.mocked(forTenant).mockClear(); 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(TUESDAY); // Die Kandidatenabfrage selbst (findMany mit distinct, VOR der Schleife) // lief auf dem unveraenderten `this.prisma`, nie auf einem gebundenen // Client — genau EIN forTenant()-Aufruf (fuer den einen Kandidaten, // dessen Praeferenz 'off' vor jedem weiteren Zugriff abbricht), nicht // zwei oder mehr fuer die Kandidatenabfrage selbst. expect(forTenant).toHaveBeenCalledTimes(1); }); it('die Kandidatenabfrage laeuft ueber den System-Klienten: __systemCallLog enthaelt genau tenderMatch.findMany, nichts aus der Schleife (260914-eym)', async () => { vi.mocked(forSystem).mockClear(); 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', tenantId: 'tenant-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(forSystem).toHaveBeenCalledTimes(1); expect(forSystem).toHaveBeenCalledWith(prisma); expect(prisma.__systemCallLog).toEqual([{ model: 'tenderMatch', method: 'findMany' }]); // Die Schleife (Praeferenz, Treffer, Benutzer, Stempelung) lief gebunden, // nicht ueber den System-Klienten. expect(prisma.__boundCallLog.length).toBeGreaterThan(0); expect(mail.sendDigest).toHaveBeenCalledTimes(1); }); it('die Zugriffe je Kandidatenzeile binden an den Mandanten DIESER Zeile — Zugriffe innerhalb der Schleife laufen auf dem gebundenen Client', 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', tenantId: 'tenant-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); const boundModels = prisma.__boundCallLog .filter((c: any) => c.tenantId === 'tenant-1') .map((c: any) => `${c.model}.${c.method}`); expect(boundModels).toEqual( expect.arrayContaining([ 'tenderNotificationPref.findUnique', 'tenderMatch.findMany', 'user.findUnique', 'tenderMatch.updateMany', ]), ); }); it('zwei Mandanten in einem Lauf: jeder Kandidat bindet an den Mandanten SEINER Zeile, nicht einmal global mit dem ersten', 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); const tenantsBoundForUserFindUnique = prisma.__boundCallLog .filter((c: any) => c.model === 'user' && c.method === 'findUnique') .map((c: any) => c.tenantId) .sort(); expect(tenantsBoundForUserFindUnique).toEqual(['tenant-a', 'tenant-b']); }); it('lautlose Fehlerform: liefert der gebundene Lesezugriff auf den Benutzer nichts, wird nichts versendet UND notifiedAt bleibt NULL (wiederholbar)', async () => { const users = new Map(); // kein Konto sichtbar unter dem gebundenen Kontext const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]); const openMatch = makeMatch({ id: 'm-open', userId: 'user-1', tenantId: 'tenant-1' }); const matches = [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); expect(mail.sendDigest).not.toHaveBeenCalled(); expect(openMatch.notifiedAt).toBeNull(); // wiederholbar beim naechsten Lauf }); });