import { readFileSync } from 'fs'; import { join } from 'path'; import { describe, expect, it, vi } from 'vitest'; import { TenderIngestionService } from './tender-ingestion.service'; /** * TenderIngestionService.spec — day-cursor gate, SCHEMA-02 change detection, * and D-05 retention (Plan 10-04, Task 1). * * A minimal in-memory fake PrismaService (Maps for tenderSourcePollConfig and * tender) is used, matching this repo's established test convention of * hand-rolled prisma-shaped mocks rather than a real DB connection (see * ldap.service.spec.ts). doeAdapter/normalizer are also plain fakes — the * normalizer is stubbed as an identity function so test fixtures can be * pre-shaped as NormalizedTenderFields directly. */ function berlinTodayStr(): string { return new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/Berlin' }); } /** Day string, offset from Berlin-today by `offsetDays` (negative = past). */ function dayString(offsetDays: number): string { const base = new Date(`${berlinTodayStr()}T00:00:00Z`); base.setUTCDate(base.getUTCDate() + offsetDays); return base.toISOString().slice(0, 10); } /** UTC-midnight Date for the given day offset — day-granularity, no time-of-day drift. */ function dayDate(offsetDays: number): Date { return new Date(`${dayString(offsetDays)}T00:00:00Z`); } function makeFakePrisma() { const tenders = new Map(); const configs = new Map(); configs.set('doe-opendata', { id: 'cfg1', sourceType: 'doe-opendata', pollIntervalMin: 60, isActive: true, lastIngestedDay: null as Date | null, }); const prisma = { tenderSourcePollConfig: { findUnique: vi.fn(async ({ where }: any) => configs.get(where.sourceType) ?? null), update: vi.fn(async ({ where, data }: any) => { const existing = configs.get(where.sourceType); const updated = { ...existing, ...data }; configs.set(where.sourceType, updated); return updated; }), }, tender: { upsert: vi.fn(async ({ where, update, create }: any) => { const existing = tenders.get(where.dedupKey); if (existing) { const updated = { ...existing, ...update }; tenders.set(where.dedupKey, updated); return updated; } const created = { id: `id-${tenders.size + 1}`, ...create }; tenders.set(where.dedupKey, created); return created; }), updateMany: vi.fn(async ({ where, data }: any) => { let count = 0; for (const [key, row] of tenders) { if ( row.status === where.status && row.deadlineAt instanceof Date && where.deadlineAt?.lt && row.deadlineAt < where.deadlineAt.lt ) { tenders.set(key, { ...row, ...data }); count++; } } return { count }; }), deleteMany: vi.fn(async ({ where }: any) => { let count = 0; for (const [key, row] of tenders) { if ( row.status === where.status && row.deadlineAt instanceof Date && where.deadlineAt?.lt && row.deadlineAt < where.deadlineAt.lt ) { tenders.delete(key); count++; } } return { count }; }), }, __store: { tenders, configs }, }; return prisma; } const BASE_RECORD = { sourcePortal: 'doe-opendata', sourceNoticeId: 'notice-1', ocid: 'ocds-1', dedupKey: 'ocds-1', title: 'Test-Ausschreibung', buyerName: 'Stadt Testhausen', cpvCodes: [] as string[], region: null as string | null, plz: null as string | null, bundesland: null as string | null, deadlineAt: null as Date | null, estimatedValue: null as number | null, procedureType: null as string | null, status: 'active', sourceUrl: null as string | null, contentHash: 'hash-1', publishedAt: new Date(), }; function makeService(prisma: any, doeAdapter: any, normalizer: any) { const service = new TenderIngestionService(prisma, doeAdapter, normalizer); // Override the polite catch-up delay so tests don't sleep for real. (service as any).politeDelayMs = 0; return service; } describe('TenderIngestionService.pollDueSources — day-cursor gate (Pitfall A)', () => { it('makes NO adapter call and returns when the day-cursor is not strictly before Berlin-today (no-op tick, expected)', async () => { const prisma = makeFakePrisma(); prisma.__store.configs.get('doe-opendata').lastIngestedDay = dayDate(-1); // -> next day is today, gated out const doeAdapter = { fetchTenders: vi.fn() }; const normalizer = { normalize: vi.fn() }; const service = makeService(prisma, doeAdapter, normalizer); await service.pollDueSources(); expect(doeAdapter.fetchTenders).not.toHaveBeenCalled(); }); it('does nothing when the singleton doe-opendata config is not active', async () => { const prisma = makeFakePrisma(); prisma.__store.configs.get('doe-opendata').isActive = false; prisma.__store.configs.get('doe-opendata').lastIngestedDay = dayDate(-3); const doeAdapter = { fetchTenders: vi.fn() }; const normalizer = { normalize: vi.fn() }; const service = makeService(prisma, doeAdapter, normalizer); await service.pollDueSources(); expect(doeAdapter.fetchTenders).not.toHaveBeenCalled(); }); }); describe('TenderIngestionService.pollDueSources — SCHEMA-02 change detection', () => { it('inserts a fresh notice, and does NOT duplicate when the identical notice (same dedupKey/contentHash) reappears the next catch-up day', async () => { const prisma = makeFakePrisma(); prisma.__store.configs.get('doe-opendata').lastIngestedDay = dayDate(-3); const record = { ...BASE_RECORD }; const doeAdapter = { fetchTenders: vi.fn().mockResolvedValue([record]) }; const normalizer = { normalize: vi.fn((raw: any) => raw) }; const service = makeService(prisma, doeAdapter, normalizer); await service.pollDueSources(); // Catch-up loop covers 2 days (today-2, today-1) — same record both days. expect(doeAdapter.fetchTenders).toHaveBeenCalledTimes(2); expect(prisma.__store.tenders.size).toBe(1); expect(prisma.__store.tenders.get('ocds-1').contentHash).toBe('hash-1'); }); it('updates the existing row in place when the same dedupKey reappears with a changed contentHash (extended deadline)', async () => { const prisma = makeFakePrisma(); prisma.__store.configs.get('doe-opendata').lastIngestedDay = dayDate(-3); const day1Record = { ...BASE_RECORD, contentHash: 'hash-1', deadlineAt: new Date('2026-08-01T10:00:00Z'), }; const day2Record = { ...BASE_RECORD, contentHash: 'hash-2', deadlineAt: new Date('2026-08-15T10:00:00Z'), }; const doeAdapter = { fetchTenders: vi.fn().mockResolvedValueOnce([day1Record]).mockResolvedValueOnce([day2Record]), }; const normalizer = { normalize: vi.fn((raw: any) => raw) }; const service = makeService(prisma, doeAdapter, normalizer); await service.pollDueSources(); expect(prisma.__store.tenders.size).toBe(1); // updated in place, no duplicate row const row = prisma.__store.tenders.get('ocds-1'); expect(row.contentHash).toBe('hash-2'); expect(row.deadlineAt).toEqual(new Date('2026-08-15T10:00:00Z')); }); }); 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(); prisma.__store.configs.get('doe-opendata').lastIngestedDay = dayDate(-3); const doeAdapter = { fetchTenders: vi.fn().mockResolvedValue([]) }; const normalizer = { normalize: vi.fn() }; const service = makeService(prisma, doeAdapter, normalizer); await service.pollDueSources(); const calledDays = doeAdapter.fetchTenders.mock.calls.map((c: any[]) => c[0]); expect(calledDays).toEqual([dayString(-2), dayString(-1)]); const finalConfig = prisma.__store.configs.get('doe-opendata'); expect(finalConfig.lastIngestedDay.toISOString().slice(0, 10)).toBe(dayString(-1)); }); }); describe('TenderIngestionService.pruneExpiredTenders — D-05 retention', () => { it('marks past-deadline active rows expired, deletes expired rows older than 90 days, and never touches null-deadline rows', async () => { const prisma = makeFakePrisma(); prisma.__store.tenders.set('t-active-past-deadline', { dedupKey: 't-active-past-deadline', status: 'active', deadlineAt: dayDate(-1), }); prisma.__store.tenders.set('t-expired-old', { dedupKey: 't-expired-old', status: 'expired', deadlineAt: dayDate(-91), }); prisma.__store.tenders.set('t-expired-recent', { dedupKey: 't-expired-recent', status: 'expired', deadlineAt: dayDate(-30), }); prisma.__store.tenders.set('t-null-deadline', { dedupKey: 't-null-deadline', status: 'active', deadlineAt: null, }); prisma.__store.tenders.set('t-active-future', { dedupKey: 't-active-future', status: 'active', deadlineAt: dayDate(1), }); const service = makeService(prisma, { fetchTenders: vi.fn() }, { normalize: vi.fn() }); await service.pruneExpiredTenders(); expect(prisma.__store.tenders.get('t-active-past-deadline').status).toBe('expired'); expect(prisma.__store.tenders.has('t-expired-old')).toBe(false); expect(prisma.__store.tenders.get('t-expired-recent').status).toBe('expired'); expect(prisma.__store.tenders.get('t-null-deadline').status).toBe('active'); expect(prisma.__store.tenders.get('t-active-future').status).toBe('active'); }); }); describe('TenderIngestionService — multi-tenant safety invariant (D-03)', () => { it('never calls forTenant() — uses the plain global PrismaService on Tender/TenderSourcePollConfig', () => { const source = readFileSync(join(__dirname, 'tender-ingestion.service.ts'), 'utf8'); expect(source).not.toMatch(/forTenant/); }); });