test(12-03): prove instant+digest single-mail guarantee across both channels

Integration spec runs TenderMatchingService.matchDelta and
TenderDigestScheduler.runDigest against one shared mocked-Prisma store (no
live DB, mocked TenderMailService, no live SMTP) to prove the NOTIFY-03
core invariant end-to-end:

- instantAlert=true: matchDelta sends exactly one instant mail and stamps
  notifiedAt='instant'; the subsequent digest run then sees zero eligible
  matches for that user and sends zero digest mails
  (sendInstant=1, sendDigest=0).
- instantAlert=false: matchDelta never dispatches instant; the digest run
  is the only channel and sends exactly one mail
  (sendInstant=0, sendDigest=1).

Both scenarios assert total mail count across channels === 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:22:04 +02:00
parent ec032ad89d
commit 5395ce6b6d
@@ -0,0 +1,225 @@
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<string, any>();
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<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,
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);
});
});