From 7502f97e85b7158163a29c5aca9ef07da83a3393 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 15:52:44 +0200 Subject: [PATCH] feat(260723-lvg): add admin-gated POST /poll-now endpoint for tender radar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual "Jetzt abrufen" trigger delegates to TenderIngestionService.pollDueSources() — the same fan-out tick the scheduler cron runs. Gated to ADMIN/SUPER_ADMIN (T-lvg-01, DoS) and declared before @Get(':id') per the established route-order convention. Co-Authored-By: Claude Opus 4.8 --- .../src/tenders/tenders.controller.spec.ts | 52 +++++++++++++++++++ apps/api/src/tenders/tenders.controller.ts | 36 +++++++++++++ 2 files changed, 88 insertions(+) diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index be5dd21..d8931da 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -1,5 +1,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Role } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; +import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { TendersController } from './tenders.controller'; /** @@ -147,6 +149,46 @@ function makeFakeNotificationPrefService() { }; } +/** + * Fake TenderIngestionService for the poll-now controller wiring test + * (Quick 260723-lvg). Only `pollDueSources` is exercised by the controller. + */ +function makeFakeIngestionService() { + return { + pollDueSources: vi.fn(async () => undefined), + }; +} + +describe('TendersController — POST /poll-now (admin manual trigger)', () => { + it('pollNow() calls pollDueSources() exactly once and returns { ok: true }', async () => { + const prisma = makeFakePrisma(); + const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; + const ingestionService = makeFakeIngestionService(); + const controller = new TendersController( + prisma as any, + scheduler, + makeFakeTriageService() as any, + makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, + ingestionService as any, + ); + + const result = await controller.pollNow(); + + expect(ingestionService.pollDueSources).toHaveBeenCalledTimes(1); + expect(result).toEqual({ ok: true }); + }); + + it('pollNow carries @Roles(ADMIN, SUPER_ADMIN) metadata', () => { + const roles = Reflect.getMetadata(ROLES_KEY, TendersController.prototype.pollNow); + + expect(roles).toContain(Role.ADMIN); + expect(roles).toContain(Role.SUPER_ADMIN); + }); +}); + describe('TendersController — global read (not tenant-scoped)', () => { it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => { const prisma = makeFakePrisma(); @@ -389,6 +431,16 @@ describe('TendersController — route declaration order (static route before :id expect(getPrefIdx).toBeLessThan(idIdx); expect(setPrefIdx).toBeLessThan(idIdx); }); + + it('declares pollNow before getTender so GET /:id cannot shadow "poll-now" (Quick 260723-lvg, Pitfall 5)', () => { + const methods = Object.getOwnPropertyNames(TendersController.prototype); + const pollNowIdx = methods.indexOf('pollNow'); + const idIdx = methods.indexOf('getTender'); + + expect(pollNowIdx).toBeGreaterThanOrEqual(0); + expect(idIdx).toBeGreaterThanOrEqual(0); + expect(pollNowIdx).toBeLessThan(idIdx); + }); }); describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, T-12-14)', () => { diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 6009bc2..e3861bc 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -26,6 +26,7 @@ import { TenderRssFeedDto } from './dto/tender-rss-feed.dto'; import { TenderTriageDto } from './dto/tender-triage.dto'; import { DENYLISTED_PORTALS, PORTAL_URLS } from './source-registry'; import { TenderEmailConfigService } from './tender-email-config.service'; +import { TenderIngestionService } from './tender-ingestion.service'; import { TenderNotificationPrefService } from './tender-notification-pref.service'; import { TenderRssFeedSourceService } from './tender-rss-feed.service'; import { TenderSavedSearchService } from './tender-saved-search.service'; @@ -78,6 +79,15 @@ export class TendersController { private readonly tenderNotificationPref: TenderNotificationPrefService, private readonly tenderRssFeedSource: TenderRssFeedSourceService, private readonly tenderEmailConfig: TenderEmailConfigService, + /** + * Appended as the LAST constructor param (Quick 260723-lvg) — preserves + * every existing `new TendersController(...7 args...)` call site in the + * spec unchanged; those construct without this arg (undefined) and never + * exercise `pollNow`. Declared optional (`?`) so those 7-arg call sites + * still type-check — NestJS DI always resolves and injects it in + * production (it is a registered provider in tenders.module.ts). + */ + private readonly tenderIngestionService?: TenderIngestionService, ) {} /** @@ -189,6 +199,32 @@ export class TendersController { return config; } + // ─── Admin manual poll trigger (Quick 260723-lvg) ────────────────────────── + + /** + * POST /modules/tender-radar/poll-now — immediately run + * `TenderIngestionService.pollDueSources()`, the same fan-out tick the + * scheduler cron runs (INGEST-06). This fetches DUE sources now — it is + * NOT a forced re-download: `'day'`-granularity sources (doe-opendata, + * ai-netserver, cosinex-dtvp) still honor the `nextDayToFetch` day-cursor, + * so a click after today's day was already ingested is a no-op for those + * sources; `'tick'`-granularity sources (rss, email-alert) always fetch. + * `pollDueSources()` never throws (catch-and-log per tick + per source), + * so no try/catch is needed here. + * + * MUST be declared before `@Get(':id')` below — same route-order pitfall + * as `source-config`/`coverage`/`rss-feeds`/... above (Pitfall 5). + * + * T-lvg-01 (DoS): gated to admins only — the platform-wide upstream fetch + * is a rate lever, not a per-tenant module feature. + */ + @Post('poll-now') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async pollNow() { + await this.tenderIngestionService!.pollDueSources(); + return { ok: true }; + } + // ─── RSS-Feeds admin CRUD (Roles-guarded, GLOBAL, D-08/D-14) ─────────────── /**