test(12-02): add failing TenderDigestScheduler spec (RED)
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 over ALL due users across ALL tenants (never findFirst — the documented DkvSchedulerService v1-gap, Pitfall 1), one sectioned mail per user grouping matches by saved search (D-02), the no-double-send notifiedAt eligibility gate (only notifiedAt=NULL selected, stamped notifiedAt+channel=digest only after a successful send — D-06), and per-user robustness so one failing/ skipped/throwing user never aborts the run for the rest (Pitfall 6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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 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 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user