diff --git a/apps/api/src/tenders/tender-matching.service.spec.ts b/apps/api/src/tenders/tender-matching.service.spec.ts new file mode 100644 index 0000000..73c44f4 --- /dev/null +++ b/apps/api/src/tenders/tender-matching.service.spec.ts @@ -0,0 +1,198 @@ +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; +}) { + const matches = opts.existingMatches ?? new Map(); + const savedSearches = opts.savedSearches ?? [PROFILE_A, PROFILE_B, PROFILE_C]; + const matchingTenderIds = opts.matchingTenderIds ?? new Set(); + + 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}`, ...create }; + matches.set(key, created); + return created; + }), + }, + __store: { matches, savedSearches }, + }; + + return prisma; +} + +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); + + 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); + + 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); + + 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); + + 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); + + 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); + + await expect(service.matchDelta([])).resolves.toBeUndefined(); + + expect(prisma.tenderSavedSearch.findMany).not.toHaveBeenCalled(); + expect(prisma.tender.findMany).not.toHaveBeenCalled(); + expect(prisma.tenderMatch.upsert).not.toHaveBeenCalled(); + }); +});