test(10-04): add failing spec for TenderIngestionService (day-cursor gate, SCHEMA-02, D-05)
- Day-cursor no-op gate (Pitfall A) - SCHEMA-02 change detection: fresh insert, identical no-dup, changed-content update - Catch-up cursor advance across missed days - D-05 retention: expire past-deadline, prune >90d expired, never touch null-deadline - Multi-tenant safety: asserts no forTenant() call in the implementation
This commit is contained in:
@@ -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<string, any>();
|
||||||
|
const configs = new Map<string, any>();
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user