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)', () => {