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:
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user