feat(260723-lvg): add admin-gated POST /poll-now endpoint for tender radar

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:52:44 +02:00
parent e1358d70fd
commit 7502f97e85
2 changed files with 88 additions and 0 deletions
@@ -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)', () => {
@@ -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) ───────────────
/**