feat(14-02): add RSS feed admin routes, API client, and settings UI
Adds GET/POST/DELETE /modules/tender-radar/rss-feeds (Roles-guarded
ADMIN/SUPER_ADMIN), declared before the existing @Get(':id') handler to
avoid NestJS route-order shadowing. Delegates to
TenderRssFeedSourceService; the denylist/SSRF rejection (D-14) surfaces
as a 400 unchanged.
Web: tender-radar-api.ts gains listRssFeeds/createRssFeed/deleteRssFeed
(relaying the backend's specific rejection message via
extractErrorMessage), and a new RssFeedListForm client component renders
an "RSS-Feeds" section on the tender-radar settings page (D-09) — list,
add (with inline denylist error), and remove global feed URLs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TendersController } from './tenders.controller';
|
||||
|
||||
@@ -85,6 +85,21 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') {
|
||||
return { user: { id: userId, tenantId }, tenantId } as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake TenderRssFeedSourceService for controller-level wiring tests (Plan
|
||||
* 14-02, Task 3). Default stubs echo/list-nothing; individual tests
|
||||
* override via `.mockResolvedValueOnce`/reassigning the mock — including
|
||||
* `create` rejecting with BadRequestException to prove the denylist error
|
||||
* surfaces through the controller unchanged.
|
||||
*/
|
||||
function makeFakeRssFeedService() {
|
||||
return {
|
||||
list: vi.fn(async () => [] as any[]),
|
||||
create: vi.fn(async (dto: any) => ({ id: 'feed-1', isActive: true, ...dto })),
|
||||
remove: vi.fn(async (_id: string) => ({ success: true })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake TenderSavedSearchService for controller-level wiring tests (Plan
|
||||
* 11-06, Task 2). Default stubs return empty/echo results — individual
|
||||
@@ -131,6 +146,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({});
|
||||
@@ -150,6 +166,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.getTender('t1');
|
||||
@@ -169,6 +186,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.getTender('t1');
|
||||
@@ -203,6 +221,7 @@ describe('TendersController — global read (not tenant-scoped)', () => {
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||
@@ -220,6 +239,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
||||
@@ -239,6 +259,7 @@ describe('TendersController — admin source-config applies live to the schedule
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.saveSourceConfig({ isActive: false });
|
||||
@@ -276,6 +297,22 @@ describe('TendersController — route declaration order (static route before :id
|
||||
expect(coverageIdx).toBeLessThan(idIdx);
|
||||
});
|
||||
|
||||
it('declares listRssFeeds/createRssFeed/removeRssFeed before getTender so GET /:id cannot shadow "rss-feeds" (Plan 14-02, Pitfall 5)', () => {
|
||||
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||
const listIdx = methods.indexOf('listRssFeeds');
|
||||
const createIdx = methods.indexOf('createRssFeed');
|
||||
const removeIdx = methods.indexOf('removeRssFeed');
|
||||
const idIdx = methods.indexOf('getTender');
|
||||
|
||||
expect(listIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(createIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(removeIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(listIdx).toBeLessThan(idIdx);
|
||||
expect(createIdx).toBeLessThan(idIdx);
|
||||
expect(removeIdx).toBeLessThan(idIdx);
|
||||
});
|
||||
|
||||
it('declares listTriage and setTriage before getTender so GET /:id cannot shadow "triage" (Plan 11-05, Pitfall 5)', () => {
|
||||
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||
const listTriageIdx = methods.indexOf('listTriage');
|
||||
@@ -325,6 +362,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
prefService as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.getNotificationPref(makeFakeRequest('u-real'));
|
||||
@@ -343,6 +381,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user,
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
prefService as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.setNotificationPref(
|
||||
@@ -366,6 +405,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
triageService as any,
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listSavedSearches(makeFakeRequest('u-real'));
|
||||
@@ -384,6 +424,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
triageService as any,
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.createSavedSearch(
|
||||
@@ -408,6 +449,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
triageService as any,
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.updateSavedSearch(
|
||||
@@ -432,6 +474,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1
|
||||
triageService as any,
|
||||
savedSearchService as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1'));
|
||||
@@ -452,6 +495,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any);
|
||||
@@ -480,6 +524,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ sort: 'not-whitelisted' } as any);
|
||||
@@ -498,6 +543,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ page: 3, limit: 10 } as any);
|
||||
@@ -519,6 +565,7 @@ describe('TendersController — GET /coverage', () => {
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
const result = await controller.getCoverage();
|
||||
@@ -547,6 +594,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1'));
|
||||
@@ -564,6 +612,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTriage('t1', makeFakeRequest('u-real'));
|
||||
@@ -581,6 +630,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () =
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(',');
|
||||
@@ -602,6 +652,7 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () =>
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.setTriage(
|
||||
@@ -628,6 +679,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||
@@ -650,6 +702,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1'));
|
||||
@@ -670,6 +723,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
triageService as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
makeFakeRssFeedService() as any,
|
||||
);
|
||||
|
||||
await controller.listTenders({} as any, makeFakeRequest('u1'));
|
||||
@@ -677,3 +731,91 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
||||
expect(triageService.favoriteIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', () => {
|
||||
it('GET /rss-feeds delegates to tenderRssFeedSource.list()', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||
const rssFeedService = makeFakeRssFeedService();
|
||||
rssFeedService.list.mockResolvedValueOnce([
|
||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' },
|
||||
]);
|
||||
const controller = new TendersController(
|
||||
prisma as any,
|
||||
scheduler,
|
||||
makeFakeTriageService() as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
rssFeedService as any,
|
||||
);
|
||||
|
||||
const result = await controller.listRssFeeds();
|
||||
|
||||
expect(rssFeedService.list).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual([
|
||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('POST /rss-feeds delegates to tenderRssFeedSource.create(dto)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||
const rssFeedService = makeFakeRssFeedService();
|
||||
const controller = new TendersController(
|
||||
prisma as any,
|
||||
scheduler,
|
||||
makeFakeTriageService() as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
rssFeedService as any,
|
||||
);
|
||||
|
||||
const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any;
|
||||
await controller.createRssFeed(dto);
|
||||
|
||||
expect(rssFeedService.create).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('POST /rss-feeds surfaces a BadRequestException from the service when the URL is denylisted (D-14)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||
const rssFeedService = makeFakeRssFeedService();
|
||||
rssFeedService.create.mockRejectedValueOnce(
|
||||
new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"),
|
||||
);
|
||||
const controller = new TendersController(
|
||||
prisma as any,
|
||||
scheduler,
|
||||
makeFakeTriageService() as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
rssFeedService as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.createRssFeed({
|
||||
url: 'https://www.vergabe24.de/rss.xml',
|
||||
label: 'vergabe24',
|
||||
} as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('DELETE /rss-feeds/:feedId delegates to tenderRssFeedSource.remove(feedId)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||
const rssFeedService = makeFakeRssFeedService();
|
||||
const controller = new TendersController(
|
||||
prisma as any,
|
||||
scheduler,
|
||||
makeFakeTriageService() as any,
|
||||
makeFakeSavedSearchService() as any,
|
||||
makeFakeNotificationPrefService() as any,
|
||||
rssFeedService as any,
|
||||
);
|
||||
|
||||
const result = await controller.removeRssFeed('feed-1');
|
||||
|
||||
expect(rssFeedService.remove).toHaveBeenCalledWith('feed-1');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,10 @@ import { UpdateNotificationPrefDto } from './dto/notification-pref.dto';
|
||||
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
||||
import { SourceConfigDto } from './dto/source-config.dto';
|
||||
import { TenderQueryDto } from './dto/tender-query.dto';
|
||||
import { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
|
||||
import { TenderTriageDto } from './dto/tender-triage.dto';
|
||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
||||
import { TenderSavedSearchService } from './tender-saved-search.service';
|
||||
import { TenderSchedulerService } from './tender-scheduler.service';
|
||||
import { TenderTriageService } from './tender-triage.service';
|
||||
@@ -61,6 +63,7 @@ export class TendersController {
|
||||
private readonly tenderTriage: TenderTriageService,
|
||||
private readonly tenderSavedSearch: TenderSavedSearchService,
|
||||
private readonly tenderNotificationPref: TenderNotificationPrefService,
|
||||
private readonly tenderRssFeedSource: TenderRssFeedSourceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -156,6 +159,49 @@ export class TendersController {
|
||||
return config;
|
||||
}
|
||||
|
||||
// ─── RSS-Feeds admin CRUD (Roles-guarded, GLOBAL, D-08/D-14) ───────────────
|
||||
|
||||
/**
|
||||
* GET /modules/tender-radar/rss-feeds — list every admin-managed RSS feed
|
||||
* (global, no tenantId — D-08). `@Roles`-guarded: this is a
|
||||
* platform-admin action, not a per-tenant module feature, same stance as
|
||||
* `source-config` above.
|
||||
*
|
||||
* MUST be declared before `@Get(':id')` below — same route-order pitfall
|
||||
* as `source-config`/`coverage`/`triage`/... above (Pitfall 5): NestJS
|
||||
* matches routes in declaration order, so a `@Get(':id')` placed first
|
||||
* would capture "rss-feeds" as an id and shadow this handler.
|
||||
*/
|
||||
@Get('rss-feeds')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async listRssFeeds() {
|
||||
return this.tenderRssFeedSource.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/tender-radar/rss-feeds — add a new global RSS feed URL.
|
||||
* The save-time hostname/SSRF guard (D-14, T-14-02-01) lives in
|
||||
* `TenderRssFeedSourceService.create()` — a denylisted/private-host URL
|
||||
* surfaces as a 400 (BadRequestException) here, unchanged.
|
||||
*/
|
||||
@Post('rss-feeds')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async createRssFeed(@Body() dto: TenderRssFeedDto) {
|
||||
return this.tenderRssFeedSource.create(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /modules/tender-radar/rss-feeds/:feedId — remove a global RSS
|
||||
* feed. Uses `:feedId` (not `:id`) so this route can never be confused
|
||||
* with the Tender `:id` route below (Pitfall 5, same convention as
|
||||
* `saved-searches/:searchId`).
|
||||
*/
|
||||
@Delete('rss-feeds/:feedId')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async removeRssFeed(@Param('feedId') feedId: string) {
|
||||
return this.tenderRssFeedSource.remove(feedId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /modules/tender-radar/coverage — distribution of active tenders
|
||||
* by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a
|
||||
|
||||
Reference in New Issue
Block a user