From 86c184f8d5bca95793ff91064b53d05072f11c1a Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 22 Jul 2026 09:20:43 +0200 Subject: [PATCH] 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) --- .../tenders/tender-matching.service.spec.ts | 212 +++++++++++++++++- 1 file changed, 204 insertions(+), 8 deletions(-) diff --git a/apps/api/src/tenders/tender-matching.service.spec.ts b/apps/api/src/tenders/tender-matching.service.spec.ts index 73c44f4..8984ed0 100644 --- a/apps/api/src/tenders/tender-matching.service.spec.ts +++ b/apps/api/src/tenders/tender-matching.service.spec.ts @@ -38,10 +38,14 @@ function makeFakePrisma(opts: { savedSearches?: any[]; matchingTenderIds?: Set; existingMatches?: Map; + tenders?: Map; + users?: Map; }) { const matches = opts.existingMatches ?? new Map(); const savedSearches = opts.savedSearches ?? [PROFILE_A, PROFILE_B, PROFILE_C]; const matchingTenderIds = opts.matchingTenderIds ?? new Set(); + const tenders = opts.tenders ?? new Map(); + const users = opts.users ?? new Map(); const prisma = { tenderSavedSearch: { @@ -67,22 +71,64 @@ function makeFakePrisma(opts: { matches.set(key, { ...existing, ...update }); 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); 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; } +function makeFakeMail(opts: { sendInstant?: ReturnType } = {}) { + return { + sendInstant: opts.sendInstant ?? vi.fn().mockResolvedValue(true), + }; +} + 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 () => { const matchingTenderIds = new Set(['new-1', 'new-2']); 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']); @@ -102,7 +148,7 @@ describe('TenderMatchingService.matchDelta — delta-only (D-07, Pitfall 2)', () const preExistingIds = Array.from({ length: 2188 }, (_, i) => `existing-${i}`); const matchingTenderIds = new Set(preExistingIds); // even if ALL of them would match the filter... 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 @@ -118,7 +164,7 @@ describe('TenderMatchingService.matchDelta — Match-Erzeugung', () => { savedSearches: [PROFILE_A], matchingTenderIds, }); - const service = new TenderMatchingService(prisma as any); + const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); await service.matchDelta(['new-1']); @@ -142,7 +188,7 @@ describe('TenderMatchingService.matchDelta — Match-Erzeugung', () => { savedSearches: [PROFILE_A, PROFILE_B], matchingTenderIds, }); - const service = new TenderMatchingService(prisma as any); + const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); await service.matchDelta(['new-1']); @@ -172,7 +218,7 @@ describe('TenderMatchingService.matchDelta — Idempotenz (D-06)', () => { matchingTenderIds, existingMatches, }); - const service = new TenderMatchingService(prisma as any); + const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); await service.matchDelta(['new-1']); @@ -187,7 +233,7 @@ describe('TenderMatchingService.matchDelta — Idempotenz (D-06)', () => { describe('TenderMatchingService.matchDelta — leeres Delta', () => { it('does nothing (no DB access at all) when newTenderIds is empty', async () => { 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(); @@ -196,3 +242,153 @@ describe('TenderMatchingService.matchDelta — leeres Delta', () => { 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(), + 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(); + }); +});