import { describe, expect, it, vi } from 'vitest'; import { TenderMatchingService } from './tender-matching.service'; /** * TenderMatchingService.matchDelta — delta-only matching (Plan 12-01, Task * 2, RED first). Covers the NOTIFY-03 core invariant: matching runs ONLY * against the tender IDs newly upserted in the current poll tick * (`newTenderIds`), never a historical/full-table rescan (D-07 — this is * the structural backfill-flood prevention), and match creation is an * idempotent upsert on @@unique([tenderId, savedSearchId]) with `update: * {}` so a re-match never resets an already-set `notifiedAt` (D-06). * * A hand-rolled Prisma-shaped mock is used (this repo's established * convention — see tender-ingestion.service.spec.ts) rather than a live * DB connection. */ const PROFILE_A = { id: 'search-a', userId: 'user-1', tenantId: 'tenant-1', filters: { q: 'Straßenbau' }, }; const PROFILE_B = { id: 'search-b', userId: 'user-2', tenantId: 'tenant-1', filters: { plz: '10' }, }; const PROFILE_C = { id: 'search-c', userId: 'user-3', tenantId: 'tenant-2', filters: { bundesland: 'Bayern' }, }; 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: { findMany: vi.fn(async () => savedSearches), }, tender: { // Simulates buildTenderWhere(profile.filters) AND id IN newTenderIds: // the fake ignores the actual `where` shape (buildTenderWhere is // unit-tested elsewhere) and just intersects the caller-provided // `id: { in: [...] }` filter against matchingTenderIds — the set of // tender IDs that would satisfy this profile's filters. findMany: vi.fn(async ({ where }: any) => { const idFilter: string[] = where.AND.find((c: any) => c.id)?.id?.in ?? []; const hits = idFilter.filter((id) => matchingTenderIds.has(id)); return hits.map((id) => ({ id })); }), }, 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; }), // 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 }; }), }, 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, makeFakeMail() as any); await service.matchDelta(['new-1', 'new-2']); for (const call of (prisma.tender.findMany as any).mock.calls) { const [{ where }] = call; expect(where.AND).toBeDefined(); const idClause = where.AND.find((c: any) => c.id); expect(idClause).toBeDefined(); expect(idClause.id.in).toEqual(['new-1', 'new-2']); } }); it('produces ZERO matches when ~2188 pre-existing tenders are simulated as NOT in newTenderIds (profile creation causes no backfill flood)', async () => { // Simulate a large pre-existing catalog: none of it is ever passed as // newTenderIds, so it can never surface as a match — delta-only is a // structural guarantee, not merely a query optimization. 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, makeFakeMail() as any); await service.matchDelta([]); // ...an empty delta yields 0 matches expect(prisma.tenderMatch.upsert).not.toHaveBeenCalled(); expect(prisma.__store.matches.size).toBe(0); }); }); describe('TenderMatchingService.matchDelta — Match-Erzeugung', () => { it('creates exactly one tenderMatch.upsert per (matching tender x profile) pair, with notifiedAt left undefined (stays NULL)', async () => { const matchingTenderIds = new Set(['new-1']); const prisma = makeFakePrisma({ savedSearches: [PROFILE_A], matchingTenderIds, }); const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); await service.matchDelta(['new-1']); expect(prisma.tenderMatch.upsert).toHaveBeenCalledTimes(1); const call = (prisma.tenderMatch.upsert as any).mock.calls[0][0]; expect(call.where).toEqual({ tenderId_savedSearchId: { tenderId: 'new-1', savedSearchId: PROFILE_A.id }, }); expect(call.create).toEqual({ tenderId: 'new-1', savedSearchId: PROFILE_A.id, userId: PROFILE_A.userId, tenantId: PROFILE_A.tenantId, }); expect(call.create.notifiedAt).toBeUndefined(); }); it('evaluates every active profile independently — a tender matching two profiles yields two match rows', async () => { const matchingTenderIds = new Set(['new-1']); const prisma = makeFakePrisma({ savedSearches: [PROFILE_A, PROFILE_B], matchingTenderIds, }); const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); await service.matchDelta(['new-1']); expect(prisma.tenderMatch.upsert).toHaveBeenCalledTimes(2); }); }); describe('TenderMatchingService.matchDelta — Idempotenz (D-06)', () => { it('upserts with update:{} — a re-match of the same pair does NOT overwrite an already-set notifiedAt', async () => { const matchingTenderIds = new Set(['new-1']); const existingMatches = new Map([ [ `new-1::${PROFILE_A.id}`, { id: 'match-existing', tenderId: 'new-1', savedSearchId: PROFILE_A.id, userId: PROFILE_A.userId, tenantId: PROFILE_A.tenantId, notifiedAt: new Date('2026-07-01T00:00:00Z'), notifiedChannel: 'instant', }, ], ]); const prisma = makeFakePrisma({ savedSearches: [PROFILE_A], matchingTenderIds, existingMatches, }); const service = new TenderMatchingService(prisma as any, makeFakeMail() as any); await service.matchDelta(['new-1']); const call = (prisma.tenderMatch.upsert as any).mock.calls[0][0]; expect(call.update).toEqual({}); const stored = prisma.__store.matches.get(`new-1::${PROFILE_A.id}`); expect(stored.notifiedAt).toEqual(new Date('2026-07-01T00:00:00Z')); expect(stored.notifiedChannel).toBe('instant'); }); }); 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, makeFakeMail() as any); await expect(service.matchDelta([])).resolves.toBeUndefined(); expect(prisma.tenderSavedSearch.findMany).not.toHaveBeenCalled(); expect(prisma.tender.findMany).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(), 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(); }); });