feat(14-02): add TenderRssFeedSource CRUD, tick poll gate, and wire RssAdapter

Global admin-managed RSS feed list (TenderRssFeedSource, D-08/D-14) with
a save-time hostname/SSRF guard (TenderRssFeedSourceService) — RSS feed
URLs are runtime admin input, so the code-level SourceRegistry denylist
gate does not cover them; a separate check rejects DENYLISTED_PORTALS
hostnames, non-http(s) schemes, and private/loopback hosts.

Adds TenderSourcePollConfig.pollGranularity ('day' | 'tick', D-15):
pollDueSources() branches per source — 'day' sources keep the existing
lastIngestedDay gate byte-unchanged, 'tick' sources (rss) fetch on every
active scheduler tick regardless of lastIngestedDay, since the day-cursor
gate was built for a genuine daily batch-export API and would otherwise
silently cap RSS to one fetch per calendar day.

Wires RssAdapter.fetchTenders() to fan out over active feed rows (native
fetch + AbortController 15s + response-size ceiling, catch-per-feed),
registers it in tenders.module.ts, and seeds the 'rss' poll config
active with pollGranularity='tick' plus a default-active service.bund.de
feed row (subreport-elvis has no single canonical URL — zero rows seeded,
admin adds relevant municipality feeds).

Migration applied locally per project convention (host -> container IP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:24:36 +02:00
parent 3a96cbbbe6
commit e812738c3a
10 changed files with 809 additions and 21 deletions
@@ -299,6 +299,59 @@ describe('TenderIngestionService.pollDueSources — catch-per-source error isola
});
});
describe("TenderIngestionService.pollDueSources — pollGranularity 'tick' gate (D-15, Phase 14 Plan 02)", () => {
it("fetches a pollGranularity='tick' source on every tick, while a 'day' source with today's next-day gated-out lastIngestedDay is skipped in the SAME tick", async () => {
const prisma = makeFakePrisma({
'doe-opendata': { lastIngestedDay: dayDate(-1) }, // -> next day is today, gated out ('day' path unchanged)
rss: { pollGranularity: 'tick', lastIngestedDay: null }, // 'tick' — no day-cursor gate at all
});
const doeAdapter = { fetchTenders: vi.fn() };
const rssAdapter = {
fetchTenders: vi.fn().mockResolvedValue([
{ ...BASE_RECORD, sourcePortal: 'rss', sourceNoticeId: 'rss-1', ocid: null, dedupKey: 'rss:rss-1' },
]),
};
const registry = makeFakeRegistry({ 'doe-opendata': doeAdapter, rss: rssAdapter });
const normalizer = { normalize: vi.fn((raw: any) => raw) };
const service = makeService(prisma, registry, normalizer);
await service.pollDueSources();
expect(doeAdapter.fetchTenders).not.toHaveBeenCalled(); // 'day' gate unchanged
expect(rssAdapter.fetchTenders).toHaveBeenCalledTimes(1); // 'tick' — fetched regardless
expect(prisma.__store.tenders.size).toBe(1);
});
it("does NOT advance or read lastIngestedDay for a 'tick' source — it is fetched again next tick even with lastIngestedDay already set to today", async () => {
const prisma = makeFakePrisma({
rss: { pollGranularity: 'tick', lastIngestedDay: dayDate(0) }, // today — would gate a 'day' source out entirely
});
const rssAdapter = { fetchTenders: vi.fn().mockResolvedValue([]) };
const registry = makeFakeRegistry({ rss: rssAdapter });
const normalizer = { normalize: vi.fn() };
const service = makeService(prisma, registry, normalizer);
await service.pollDueSources();
await service.pollDueSources();
expect(rssAdapter.fetchTenders).toHaveBeenCalledTimes(2);
// lastIngestedDay is never touched by the 'tick' path.
expect(prisma.__store.configs.get('rss').lastIngestedDay).toEqual(dayDate(0));
});
it("passes berlinToday (not a day-cursor loop) as the single argument to a 'tick' adapter's fetchTenders", async () => {
const prisma = makeFakePrisma({ rss: { pollGranularity: 'tick' } });
const rssAdapter = { fetchTenders: vi.fn().mockResolvedValue([]) };
const registry = makeFakeRegistry({ rss: rssAdapter });
const normalizer = { normalize: vi.fn() };
const service = makeService(prisma, registry, normalizer);
await service.pollDueSources();
expect(rssAdapter.fetchTenders).toHaveBeenCalledWith(berlinTodayStr());
});
});
describe('TenderIngestionService.pollDueSources — dedupActive gate (D-05)', () => {
it('passes dedupActive=false to resolve() when exactly one config is active', async () => {
const prisma = makeFakePrisma({ 'doe-opendata': { lastIngestedDay: dayDate(-2) } });