test(12-01): add failing test for TenderMatchingService.matchDelta
RED — covers delta-only matching (D-07), match creation, idempotent upsert preserving notifiedAt (D-06), and empty-delta no-op. Service does not exist yet (Cannot find module). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>;
|
||||
existingMatches?: Map<string, any>;
|
||||
}) {
|
||||
const matches = opts.existingMatches ?? new Map<string, any>();
|
||||
const savedSearches = opts.savedSearches ?? [PROFILE_A, PROFILE_B, PROFILE_C];
|
||||
const matchingTenderIds = opts.matchingTenderIds ?? new Set<string>();
|
||||
|
||||
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<string, any>([
|
||||
[
|
||||
`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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user