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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user