test(12-03): add failing tests for instant-alert dispatch in matchDelta
RED: covers D-04 (instantAlert=true only), D-05 (bundling per profile/tick), D-06 (stamp notifiedAt/channel=instant only after success), retry-safety on send failure (per-profile catch, other profiles unaffected), and no-op when a profile has no fresh notifiedAt=NULL matches this tick. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,10 +38,14 @@ function makeFakePrisma(opts: {
|
|||||||
savedSearches?: any[];
|
savedSearches?: any[];
|
||||||
matchingTenderIds?: Set<string>;
|
matchingTenderIds?: Set<string>;
|
||||||
existingMatches?: Map<string, any>;
|
existingMatches?: Map<string, any>;
|
||||||
|
tenders?: Map<string, any>;
|
||||||
|
users?: Map<string, any>;
|
||||||
}) {
|
}) {
|
||||||
const matches = opts.existingMatches ?? new Map<string, any>();
|
const matches = opts.existingMatches ?? new Map<string, any>();
|
||||||
const savedSearches = opts.savedSearches ?? [PROFILE_A, PROFILE_B, PROFILE_C];
|
const savedSearches = opts.savedSearches ?? [PROFILE_A, PROFILE_B, PROFILE_C];
|
||||||
const matchingTenderIds = opts.matchingTenderIds ?? new Set<string>();
|
const matchingTenderIds = opts.matchingTenderIds ?? new Set<string>();
|
||||||
|
const tenders = opts.tenders ?? new Map<string, any>();
|
||||||
|
const users = opts.users ?? new Map<string, any>();
|
||||||
|
|
||||||
const prisma = {
|
const prisma = {
|
||||||
tenderSavedSearch: {
|
tenderSavedSearch: {
|
||||||
@@ -67,22 +71,64 @@ function makeFakePrisma(opts: {
|
|||||||
matches.set(key, { ...existing, ...update });
|
matches.set(key, { ...existing, ...update });
|
||||||
return matches.get(key);
|
return matches.get(key);
|
||||||
}
|
}
|
||||||
const created = { id: `match-${matches.size + 1}`, ...create };
|
const created = {
|
||||||
|
id: `match-${matches.size + 1}`,
|
||||||
|
notifiedAt: null,
|
||||||
|
notifiedChannel: null,
|
||||||
|
...create,
|
||||||
|
};
|
||||||
matches.set(key, created);
|
matches.set(key, created);
|
||||||
return created;
|
return created;
|
||||||
}),
|
}),
|
||||||
|
// Instant-dispatch fresh-match lookup (Plan 12-03): savedSearchId +
|
||||||
|
// notifiedAt:null + tenderId IN newTenderIds, joined with `tender`.
|
||||||
|
findMany: vi.fn(async ({ where }: any) => {
|
||||||
|
const savedSearchId = where.savedSearchId;
|
||||||
|
const tenderIdIn: string[] | undefined = where.tenderId?.in;
|
||||||
|
const wantsUnnotified = 'notifiedAt' in where && where.notifiedAt === null;
|
||||||
|
const rows = Array.from(matches.values()).filter((m: any) => {
|
||||||
|
if (savedSearchId !== undefined && m.savedSearchId !== savedSearchId) return false;
|
||||||
|
if (wantsUnnotified && m.notifiedAt != null) return false;
|
||||||
|
if (tenderIdIn && !tenderIdIn.includes(m.tenderId)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return rows.map((m: any) => ({
|
||||||
|
...m,
|
||||||
|
tender: tenders.get(m.tenderId) ?? { id: m.tenderId, title: m.tenderId },
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
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 };
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
__store: { matches, savedSearches },
|
user: {
|
||||||
|
findUnique: vi.fn(async ({ where }: any) => users.get(where.id) ?? null),
|
||||||
|
},
|
||||||
|
__store: { matches, savedSearches, tenders, users },
|
||||||
};
|
};
|
||||||
|
|
||||||
return prisma;
|
return prisma;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeFakeMail(opts: { sendInstant?: ReturnType<typeof vi.fn> } = {}) {
|
||||||
|
return {
|
||||||
|
sendInstant: opts.sendInstant ?? vi.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('TenderMatchingService.matchDelta — delta-only (D-07, Pitfall 2)', () => {
|
describe('TenderMatchingService.matchDelta — delta-only (D-07, Pitfall 2)', () => {
|
||||||
it('queries only newTenderIds (id IN newTenderIds) via buildTenderWhere per profile — never an unfiltered full-table scan', async () => {
|
it('queries only newTenderIds (id IN newTenderIds) via buildTenderWhere per profile — never an unfiltered full-table scan', async () => {
|
||||||
const matchingTenderIds = new Set(['new-1', 'new-2']);
|
const matchingTenderIds = new Set(['new-1', 'new-2']);
|
||||||
const prisma = makeFakePrisma({ matchingTenderIds });
|
const prisma = makeFakePrisma({ matchingTenderIds });
|
||||||
const service = new TenderMatchingService(prisma as any);
|
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
|
||||||
|
|
||||||
await service.matchDelta(['new-1', 'new-2']);
|
await service.matchDelta(['new-1', 'new-2']);
|
||||||
|
|
||||||
@@ -102,7 +148,7 @@ describe('TenderMatchingService.matchDelta — delta-only (D-07, Pitfall 2)', ()
|
|||||||
const preExistingIds = Array.from({ length: 2188 }, (_, i) => `existing-${i}`);
|
const preExistingIds = Array.from({ length: 2188 }, (_, i) => `existing-${i}`);
|
||||||
const matchingTenderIds = new Set(preExistingIds); // even if ALL of them would match the filter...
|
const matchingTenderIds = new Set(preExistingIds); // even if ALL of them would match the filter...
|
||||||
const prisma = makeFakePrisma({ matchingTenderIds });
|
const prisma = makeFakePrisma({ matchingTenderIds });
|
||||||
const service = new TenderMatchingService(prisma as any);
|
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
|
||||||
|
|
||||||
await service.matchDelta([]); // ...an empty delta yields 0 matches
|
await service.matchDelta([]); // ...an empty delta yields 0 matches
|
||||||
|
|
||||||
@@ -118,7 +164,7 @@ describe('TenderMatchingService.matchDelta — Match-Erzeugung', () => {
|
|||||||
savedSearches: [PROFILE_A],
|
savedSearches: [PROFILE_A],
|
||||||
matchingTenderIds,
|
matchingTenderIds,
|
||||||
});
|
});
|
||||||
const service = new TenderMatchingService(prisma as any);
|
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
|
||||||
|
|
||||||
await service.matchDelta(['new-1']);
|
await service.matchDelta(['new-1']);
|
||||||
|
|
||||||
@@ -142,7 +188,7 @@ describe('TenderMatchingService.matchDelta — Match-Erzeugung', () => {
|
|||||||
savedSearches: [PROFILE_A, PROFILE_B],
|
savedSearches: [PROFILE_A, PROFILE_B],
|
||||||
matchingTenderIds,
|
matchingTenderIds,
|
||||||
});
|
});
|
||||||
const service = new TenderMatchingService(prisma as any);
|
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
|
||||||
|
|
||||||
await service.matchDelta(['new-1']);
|
await service.matchDelta(['new-1']);
|
||||||
|
|
||||||
@@ -172,7 +218,7 @@ describe('TenderMatchingService.matchDelta — Idempotenz (D-06)', () => {
|
|||||||
matchingTenderIds,
|
matchingTenderIds,
|
||||||
existingMatches,
|
existingMatches,
|
||||||
});
|
});
|
||||||
const service = new TenderMatchingService(prisma as any);
|
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
|
||||||
|
|
||||||
await service.matchDelta(['new-1']);
|
await service.matchDelta(['new-1']);
|
||||||
|
|
||||||
@@ -187,7 +233,7 @@ describe('TenderMatchingService.matchDelta — Idempotenz (D-06)', () => {
|
|||||||
describe('TenderMatchingService.matchDelta — leeres Delta', () => {
|
describe('TenderMatchingService.matchDelta — leeres Delta', () => {
|
||||||
it('does nothing (no DB access at all) when newTenderIds is empty', async () => {
|
it('does nothing (no DB access at all) when newTenderIds is empty', async () => {
|
||||||
const prisma = makeFakePrisma({});
|
const prisma = makeFakePrisma({});
|
||||||
const service = new TenderMatchingService(prisma as any);
|
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
|
||||||
|
|
||||||
await expect(service.matchDelta([])).resolves.toBeUndefined();
|
await expect(service.matchDelta([])).resolves.toBeUndefined();
|
||||||
|
|
||||||
@@ -196,3 +242,153 @@ describe('TenderMatchingService.matchDelta — leeres Delta', () => {
|
|||||||
expect(prisma.tenderMatch.upsert).not.toHaveBeenCalled();
|
expect(prisma.tenderMatch.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instant-Dispatch (Plan 12-03, Task 1, RED first) — NOTIFY-02 + die
|
||||||
|
* Instant-Hälfte von NOTIFY-03: am Ende von matchDelta (NACH allen
|
||||||
|
* Match-Upserts dieses Ticks) werden Profile mit instantAlert=true auf
|
||||||
|
* frische (notifiedAt=NULL) Treffer dieses Ticks geprüft und — falls
|
||||||
|
* vorhanden — zu EINER Sammel-Sofort-Mail gebündelt (D-05), NUR bei
|
||||||
|
* erfolgreichem sendInstant sofort mit notifiedAt='instant' gestempelt
|
||||||
|
* (D-06 — dasselbe Gate, das der Digest liest).
|
||||||
|
*/
|
||||||
|
const INSTANT_PROFILE = {
|
||||||
|
id: 'search-instant',
|
||||||
|
userId: 'user-instant',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
name: 'Instant-Profil',
|
||||||
|
filters: { q: 'Straßenbau' },
|
||||||
|
instantAlert: true,
|
||||||
|
};
|
||||||
|
const NON_INSTANT_PROFILE = {
|
||||||
|
id: 'search-normal',
|
||||||
|
userId: 'user-normal',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
name: 'Normal-Profil',
|
||||||
|
filters: { q: 'Straßenbau' },
|
||||||
|
instantAlert: false,
|
||||||
|
};
|
||||||
|
const INSTANT_USER = { id: 'user-instant', email: 'instant@tenant-1.de', tenantId: 'tenant-1' };
|
||||||
|
const NORMAL_USER = { id: 'user-normal', email: 'normal@tenant-1.de', tenantId: 'tenant-1' };
|
||||||
|
|
||||||
|
describe('TenderMatchingService.matchDelta — Instant-Dispatch nur instantAlert=true (D-04)', () => {
|
||||||
|
it('a profile with instantAlert=true triggers sendInstant, a matching profile with instantAlert=false does NOT', async () => {
|
||||||
|
const matchingTenderIds = new Set(['new-1']);
|
||||||
|
const users = new Map([
|
||||||
|
['user-instant', INSTANT_USER],
|
||||||
|
['user-normal', NORMAL_USER],
|
||||||
|
]);
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
savedSearches: [INSTANT_PROFILE, NON_INSTANT_PROFILE],
|
||||||
|
matchingTenderIds,
|
||||||
|
users,
|
||||||
|
});
|
||||||
|
const sendInstant = vi.fn().mockResolvedValue(true);
|
||||||
|
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
|
||||||
|
|
||||||
|
await service.matchDelta(['new-1']);
|
||||||
|
|
||||||
|
expect(sendInstant).toHaveBeenCalledTimes(1);
|
||||||
|
const [recipient, tenantId, searchArg] = sendInstant.mock.calls[0];
|
||||||
|
expect(recipient).toEqual({ email: INSTANT_USER.email });
|
||||||
|
expect(tenantId).toBe(INSTANT_PROFILE.tenantId);
|
||||||
|
expect(searchArg).toEqual({ name: INSTANT_PROFILE.name });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenderMatchingService.matchDelta — Bündelung pro Profil/Tick (D-05)', () => {
|
||||||
|
it('three freshly-matched tenders of the same instantAlert profile in one tick yield exactly ONE sendInstant call with all three tenders', async () => {
|
||||||
|
const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']);
|
||||||
|
const users = new Map([['user-instant', INSTANT_USER]]);
|
||||||
|
const tenders = new Map([
|
||||||
|
['new-1', { id: 'new-1', title: 'Tender 1' }],
|
||||||
|
['new-2', { id: 'new-2', title: 'Tender 2' }],
|
||||||
|
['new-3', { id: 'new-3', title: 'Tender 3' }],
|
||||||
|
]);
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
savedSearches: [INSTANT_PROFILE],
|
||||||
|
matchingTenderIds,
|
||||||
|
users,
|
||||||
|
tenders,
|
||||||
|
});
|
||||||
|
const sendInstant = vi.fn().mockResolvedValue(true);
|
||||||
|
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
|
||||||
|
|
||||||
|
await service.matchDelta(['new-1', 'new-2', 'new-3']);
|
||||||
|
|
||||||
|
expect(sendInstant).toHaveBeenCalledTimes(1);
|
||||||
|
const tendersArg = sendInstant.mock.calls[0][3];
|
||||||
|
expect(tendersArg).toHaveLength(3);
|
||||||
|
expect(tendersArg.map((t: any) => t.id).sort()).toEqual(['new-1', 'new-2', 'new-3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenderMatchingService.matchDelta — Stempelung nach Erfolg (D-06)', () => {
|
||||||
|
it('after a successful sendInstant, exactly the sent matches carry notifiedAt=now, notifiedChannel="instant"', async () => {
|
||||||
|
const matchingTenderIds = new Set(['new-1']);
|
||||||
|
const users = new Map([['user-instant', INSTANT_USER]]);
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
savedSearches: [INSTANT_PROFILE],
|
||||||
|
matchingTenderIds,
|
||||||
|
users,
|
||||||
|
});
|
||||||
|
const sendInstant = vi.fn().mockResolvedValue(true);
|
||||||
|
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
|
||||||
|
|
||||||
|
await service.matchDelta(['new-1']);
|
||||||
|
|
||||||
|
const stored = prisma.__store.matches.get(`new-1::${INSTANT_PROFILE.id}`);
|
||||||
|
expect(stored.notifiedAt).toBeInstanceOf(Date);
|
||||||
|
expect(stored.notifiedChannel).toBe('instant');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenderMatchingService.matchDelta — Retry-Sicherheit (Robustheit)', () => {
|
||||||
|
it('when sendInstant fails/throws for one profile, notifiedAt stays NULL for that profile and the rest of the tick (other profiles) still runs', async () => {
|
||||||
|
const matchingTenderIds = new Set(['new-1', 'new-2']);
|
||||||
|
const FAILING_PROFILE = { ...INSTANT_PROFILE, id: 'search-fail', userId: 'user-fail', name: 'Fail-Profil' };
|
||||||
|
const OK_PROFILE = { ...INSTANT_PROFILE, id: 'search-ok', userId: 'user-ok', name: 'OK-Profil' };
|
||||||
|
const users = new Map([
|
||||||
|
['user-fail', { id: 'user-fail', email: 'fail@tenant-1.de', tenantId: 'tenant-1' }],
|
||||||
|
['user-ok', { id: 'user-ok', email: 'ok@tenant-1.de', tenantId: 'tenant-1' }],
|
||||||
|
]);
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
savedSearches: [FAILING_PROFILE, OK_PROFILE],
|
||||||
|
matchingTenderIds,
|
||||||
|
users,
|
||||||
|
});
|
||||||
|
const sendInstant = vi.fn(async (_recipient: any, _tenantId: string, search: any) => {
|
||||||
|
if (search.name === 'Fail-Profil') throw new Error('SMTP down');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
|
||||||
|
|
||||||
|
await expect(service.matchDelta(['new-1', 'new-2'])).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(sendInstant).toHaveBeenCalledTimes(2); // both profiles attempted
|
||||||
|
const failStored = prisma.__store.matches.get(`new-1::${FAILING_PROFILE.id}`);
|
||||||
|
expect(failStored.notifiedAt).toBeNull();
|
||||||
|
const okStored = prisma.__store.matches.get(`new-1::${OK_PROFILE.id}`);
|
||||||
|
expect(okStored.notifiedAt).toBeInstanceOf(Date);
|
||||||
|
expect(okStored.notifiedChannel).toBe('instant');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenderMatchingService.matchDelta — nichts Neues löst keinen Alert aus', () => {
|
||||||
|
it('an instantAlert profile without any fresh notifiedAt=NULL matches this tick never calls sendInstant', async () => {
|
||||||
|
// matchingTenderIds empty -> no new TenderMatch rows created this tick,
|
||||||
|
// so the fresh (notifiedAt=NULL, tenderId IN newTenderIds) lookup is empty.
|
||||||
|
const users = new Map([['user-instant', INSTANT_USER]]);
|
||||||
|
const prisma = makeFakePrisma({
|
||||||
|
savedSearches: [INSTANT_PROFILE],
|
||||||
|
matchingTenderIds: new Set<string>(),
|
||||||
|
users,
|
||||||
|
});
|
||||||
|
const sendInstant = vi.fn().mockResolvedValue(true);
|
||||||
|
const service = new TenderMatchingService(prisma as any, makeFakeMail({ sendInstant }) as any);
|
||||||
|
|
||||||
|
await service.matchDelta(['new-1']);
|
||||||
|
|
||||||
|
expect(sendInstant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user