feat(14-04): denylisted-portals read endpoint sourced from DENYLISTED_PORTALS
- Add PORTAL_URLS map (vergabe24, aumass) in source-registry.ts, keyed off
the existing DENYLISTED_PORTALS constant so the portal set is never
re-declared
- Add GET /modules/tender-radar/denylisted-portals, declared before
@Get(':id') (route-order pitfall), mapping over DENYLISTED_PORTALS
- Extend tenders.controller.spec.ts: response shape + route-order guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,19 @@ import type { TenderSourceAdapter } from './adapters/tender-source-adapter.inter
|
|||||||
*/
|
*/
|
||||||
export const DENYLISTED_PORTALS = ['vergabe24', 'aumass'] as const;
|
export const DENYLISTED_PORTALS = ['vergabe24', 'aumass'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical direct-link URL per denylisted portal (UI-06/D-12). Keyed by
|
||||||
|
* the same portal slugs as `DENYLISTED_PORTALS` — the portal SET is never
|
||||||
|
* re-declared here, only the URL each already-listed portal maps to.
|
||||||
|
* `TendersController.getDenylistedPortals()` maps over `DENYLISTED_PORTALS`
|
||||||
|
* and looks up each entry's URL here, so a future denylist addition needs
|
||||||
|
* only a URL added to this map (not a second hardcoded list anywhere else).
|
||||||
|
*/
|
||||||
|
export const PORTAL_URLS: Record<(typeof DENYLISTED_PORTALS)[number], string> = {
|
||||||
|
vergabe24: 'https://www.vergabe24.de',
|
||||||
|
aumass: 'https://www.aumass.de',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown by `SourceRegistry.register()` when an adapter declares a
|
* Thrown by `SourceRegistry.register()` when an adapter declares a
|
||||||
* denylisted portal. Proves Erfolgskriterium 4 (13-CONTEXT.md D-06) —
|
* denylisted portal. Proves Erfolgskriterium 4 (13-CONTEXT.md D-06) —
|
||||||
|
|||||||
@@ -315,6 +315,16 @@ describe('TendersController — route declaration order (static route before :id
|
|||||||
expect(coverageIdx).toBeLessThan(idIdx);
|
expect(coverageIdx).toBeLessThan(idIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('declares getDenylistedPortals before getTender so GET /:id cannot shadow "denylisted-portals" (Plan 14-04, Pitfall 5)', () => {
|
||||||
|
const methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||||
|
const denylistIdx = methods.indexOf('getDenylistedPortals');
|
||||||
|
const idIdx = methods.indexOf('getTender');
|
||||||
|
|
||||||
|
expect(denylistIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(denylistIdx).toBeLessThan(idIdx);
|
||||||
|
});
|
||||||
|
|
||||||
it('declares listRssFeeds/createRssFeed/removeRssFeed before getTender so GET /:id cannot shadow "rss-feeds" (Plan 14-02, Pitfall 5)', () => {
|
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 methods = Object.getOwnPropertyNames(TendersController.prototype);
|
||||||
const listIdx = methods.indexOf('listRssFeeds');
|
const listIdx = methods.indexOf('listRssFeeds');
|
||||||
@@ -624,6 +634,31 @@ describe('TendersController — GET /coverage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TendersController — GET /denylisted-portals (Plan 14-04, UI-06/D-12)', () => {
|
||||||
|
it('returns vergabe24 and aumass each with their canonical URL, derived from DENYLISTED_PORTALS', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
|
const controller = new TendersController(
|
||||||
|
prisma as any,
|
||||||
|
scheduler,
|
||||||
|
makeFakeTriageService() as any,
|
||||||
|
makeFakeSavedSearchService() as any,
|
||||||
|
makeFakeNotificationPrefService() as any,
|
||||||
|
makeFakeRssFeedService() as any,
|
||||||
|
makeFakeEmailConfigService() as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await controller.getDenylistedPortals();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
portals: [
|
||||||
|
{ portal: 'vergabe24', url: 'https://www.vergabe24.de' },
|
||||||
|
{ portal: 'aumass', url: 'https://www.aumass.de' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () => {
|
describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () => {
|
||||||
it('parses the comma-separated ids param and delegates to tenderTriage.listForUser(userId, ids)', async () => {
|
it('parses the comma-separated ids param and delegates to tenderTriage.listForUser(userId, ids)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
|||||||
import { TenderQueryDto } from './dto/tender-query.dto';
|
import { TenderQueryDto } from './dto/tender-query.dto';
|
||||||
import { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
|
import { TenderRssFeedDto } from './dto/tender-rss-feed.dto';
|
||||||
import { TenderTriageDto } from './dto/tender-triage.dto';
|
import { TenderTriageDto } from './dto/tender-triage.dto';
|
||||||
|
import { DENYLISTED_PORTALS, PORTAL_URLS } from './source-registry';
|
||||||
import { TenderEmailConfigService } from './tender-email-config.service';
|
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||||
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
import { TenderNotificationPrefService } from './tender-notification-pref.service';
|
||||||
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
||||||
@@ -295,6 +296,29 @@ export class TendersController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /modules/tender-radar/denylisted-portals — the AGB-prohibited
|
||||||
|
* portals (vergabe24, aumass) with their canonical direct-link URLs
|
||||||
|
* (UI-06/D-12). Maps over `DENYLISTED_PORTALS` (source-registry.ts) —
|
||||||
|
* the portal SET is never re-declared here, so a future denylist entry
|
||||||
|
* (with a URL added to `PORTAL_URLS`) flows through automatically.
|
||||||
|
*
|
||||||
|
* MUST be declared before `@Get(':id')` below — same route-order
|
||||||
|
* pitfall as `source-config`/`coverage`/... above (Pitfall 5). Read-
|
||||||
|
* surface, gated by @UseModule (not admin-only) — same stance as
|
||||||
|
* `getCoverage`.
|
||||||
|
*/
|
||||||
|
@Get('denylisted-portals')
|
||||||
|
@UseModule('tender-radar')
|
||||||
|
async getDenylistedPortals() {
|
||||||
|
return {
|
||||||
|
portals: DENYLISTED_PORTALS.map((portal) => ({
|
||||||
|
portal,
|
||||||
|
url: PORTAL_URLS[portal],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /modules/tender-radar/triage?ids=<csv> — batch-fetch the current
|
* GET /modules/tender-radar/triage?ids=<csv> — batch-fetch the current
|
||||||
* user's triage state (gelesen/ungelesen, Favorit) for the given
|
* user's triage state (gelesen/ungelesen, Favorit) for the given
|
||||||
|
|||||||
Reference in New Issue
Block a user