diff --git a/apps/api/src/tenders/tender-ingestion.service.spec.ts b/apps/api/src/tenders/tender-ingestion.service.spec.ts new file mode 100644 index 0000000..7e2a31b --- /dev/null +++ b/apps/api/src/tenders/tender-ingestion.service.spec.ts @@ -0,0 +1,267 @@ +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/); + }); +});