Files
tessera-ctl/apps/api/src/tenders/tender-digest.scheduler.spec.ts
T
schalli 6e2a641d76 feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig
- mail: MailerModule-Fabrik und DB-Startpfad (findFirst beim Boot) ersatzlos
  entfernt; MailService baut je Versand einen nodemailer-Transport aus
  getDecryptedSmtpConfig(tenantId) des Empfaenger-Mandanten, Umgebungs-Kette
  (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) nur als Rueckfall; Fehler
  weiter verschluckt (T-02-12), close() im finally; neue mail.service.spec.ts
  (4 Tests, T-GWH-03 geschlossen)
- settings: Startpfad-Methode samt vier Spec-Tests geloescht;
  auth: requestPasswordReset reicht user.tenantId durch (Spec-Zusicherung)
- ldap: getAllActiveConfigs und Nachverschluesselung lesen ueber forSystem
  (zwei Zuweisungen), Schreibzeile je Altzeile ueber forTenant(config.tenantId);
  Tests 301/306 umgedreht, neuer Altzeilen-Test
- tender-digest: Kandidatenabfrage ueber forSystem, Schleife gebunden (+1 Test)
- tender-matching: Profilabfrage ueber forSystem, Katalog (D-03) ungebunden (+1 Test)
- tender-notifications.integration.spec: Mock um forSystem
- Werkzeug: LdapConfig (15 Spalten), LdapFieldMapping (6), TenderMatch (8),
  TenderSavedSearch (8) je neun Kennungen plus Relations-Kennung
  ldapconfig-systemkontext-include-fieldmappings-beider-mandanten
  -> Alle 253 Pruefungen bestanden
- Detektor: FORSYSTEM_ALLOWED_CALL_SITES auf 4 Dateien / 5 Aufrufe;
  Proben-Empfaenger sysPrisma (Gate-Zaehlung, Name nicht hartkodiert)
- Klassifikation: 6 Zeilen system-gebunden, settings/smtpConfig gebunden
- Falsifizierung durch Rueckbau ausgefuehrt und zurueckgenommen:
  (a) FOR SELECT bei TenderMatch entfernt -> 5 von 253 rot (Insert gelingt,
  cmd ALL); (b) Regel TenderSavedSearch aus der Datei entfernt -> 1 von 245
  rot (Extraktion), lebende DB bleibt bei 34; (c) local=false -> gruen, plus
  Reset entfernt -> 5 rot (Erben sichtbar); (d) Zahl 0 -> 2 rot, Fremddatei
  admin-seed -> 3 rot
- Baseline: 64 Dateien / 1054 Tests, tsc 0, Werkzeug 253

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 11:46:08 +02:00

483 lines
22 KiB
TypeScript

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<string, any>;
users?: Map<string, any>;
}) {
const matches = opts.matches ?? [];
const prefs = opts.prefs ?? new Map<string, any>();
const users = opts.users ?? new Map<string, any>();
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<string>();
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<string, any> = { 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<Record<string, any>> = {}) {
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<string, any>(); // 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
});
});