feat(12-01): wire matchDelta into pollDueSources; register TenderMatchingService

pollDueSources now collects genuinely-new tender IDs via an indexed
dedupKey pre-check (existing upsert doesn't report create-vs-update),
and calls TenderMatchingService.matchDelta(newTenderIds) once at the
end of the tick — the delta-only matching boundary (D-07). Changed/
re-seen rows are excluded, only genuinely new rows trigger matching.

TenderMatchingService registered as a provider in TendersModule and
injected into TenderIngestionService. Ingestion spec extended to
assert matchDelta receives only the new IDs, and is not called when
no new tenders were ingested this tick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:05:41 +02:00
parent 92d4c969de
commit b45047f0b3
3 changed files with 111 additions and 3 deletions
@@ -53,6 +53,7 @@ function makeFakePrisma() {
}),
},
tender: {
findUnique: vi.fn(async ({ where }: any) => tenders.get(where.dedupKey) ?? null),
upsert: vi.fn(async ({ where, update, create }: any) => {
const existing = tenders.get(where.dedupKey);
if (existing) {
@@ -121,8 +122,13 @@ const BASE_RECORD = {
publishedAt: new Date(),
};
function makeService(prisma: any, doeAdapter: any, normalizer: any) {
const service = new TenderIngestionService(prisma, doeAdapter, normalizer);
function makeService(
prisma: any,
doeAdapter: any,
normalizer: any,
matching: any = { matchDelta: vi.fn() },
) {
const service = new TenderIngestionService(prisma, doeAdapter, normalizer, matching);
// Override the polite catch-up delay so tests don't sleep for real.
(service as any).politeDelayMs = 0;
return service;
@@ -200,6 +206,68 @@ describe('TenderIngestionService.pollDueSources — SCHEMA-02 change detection',
});
});
describe('TenderIngestionService.pollDueSources — delta-only matching (Plan 12-01, D-07)', () => {
it('calls matching.matchDelta with ONLY the genuinely-new tender IDs of this tick — a pre-existing record reappearing (even with a changed contentHash) is excluded', async () => {
const prisma = makeFakePrisma();
// Pre-seed one tender that already exists BEFORE this tick — simulates
// a previously-ingested row that reappears in this tick's fetch.
prisma.__store.tenders.set('existing-notice', {
id: 'id-existing',
dedupKey: 'existing-notice',
contentHash: 'old-hash',
status: 'active',
});
prisma.__store.configs.get('doe-opendata').lastIngestedDay = dayDate(-2); // single catch-up day
const existingRecord = {
...BASE_RECORD,
dedupKey: 'existing-notice',
contentHash: 'updated-hash', // changed, but NOT genuinely new
};
const newRecord1 = { ...BASE_RECORD, dedupKey: 'new-notice-1' };
const newRecord2 = { ...BASE_RECORD, dedupKey: 'new-notice-2' };
const doeAdapter = {
fetchTenders: vi.fn().mockResolvedValue([existingRecord, newRecord1, newRecord2]),
};
const normalizer = { normalize: vi.fn((raw: any) => raw) };
const matching = { matchDelta: vi.fn() };
const service = makeService(prisma, doeAdapter, normalizer, matching);
await service.pollDueSources();
expect(matching.matchDelta).toHaveBeenCalledTimes(1);
const calledIds: string[] = matching.matchDelta.mock.calls[0][0];
const newIds = [
prisma.__store.tenders.get('new-notice-1').id,
prisma.__store.tenders.get('new-notice-2').id,
];
expect(calledIds.sort()).toEqual(newIds.sort());
expect(calledIds).not.toContain('id-existing');
});
it('does NOT call matchDelta when the tick yields zero genuinely-new tenders', async () => {
const prisma = makeFakePrisma();
prisma.__store.tenders.set('existing-notice', {
id: 'id-existing',
dedupKey: 'existing-notice',
contentHash: 'old-hash',
status: 'active',
});
prisma.__store.configs.get('doe-opendata').lastIngestedDay = dayDate(-2);
const existingRecord = { ...BASE_RECORD, dedupKey: 'existing-notice' };
const doeAdapter = { fetchTenders: vi.fn().mockResolvedValue([existingRecord]) };
const normalizer = { normalize: vi.fn((raw: any) => raw) };
const matching = { matchDelta: vi.fn() };
const service = makeService(prisma, doeAdapter, normalizer, matching);
await service.pollDueSources();
expect(matching.matchDelta).not.toHaveBeenCalled();
});
});
describe('TenderIngestionService.pollDueSources — catch-up cursor advance', () => {
it('advances lastIngestedDay by one day per successful fetch, looping from lastIngestedDay+1 up to today-1', async () => {
const prisma = makeFakePrisma();