fix(10): resolve GET /source-config 404 shadowed by :id route

The admin source-config settings form failed to load with "Failed to
fetch tender-radar source config". Network trace showed
GET /modules/tender-radar/source-config returning 404.

Root cause: NestJS RouterExplorer maps routes in method-declaration
order. `@Get(':id')` was declared before `@Get('source-config')`, so
the param route captured "source-config" as an id and shadowed the
static handler (401 unauthenticated, 404 past the guard — no Tender
with id "source-config").

Fix: declare `@Get('source-config')` before `@Get(':id')`. Add a
declaration-order regression test — unit tests call controller methods
directly, bypass routing, and could never catch route shadowing.

Verified live: settings form now loads real config, interval save
persists and live-re-registers the scheduler (INGEST-06). Phase 10
verification raised human_needed -> passed after full browser UAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 14:52:17 +02:00
parent eb10bd3116
commit 48d1246043
4 changed files with 73 additions and 26 deletions
+7 -7
View File
@@ -4,17 +4,17 @@ milestone: v1.1
milestone_name: Ausschreibungs-Radar milestone_name: Ausschreibungs-Radar
current_phase: 10 current_phase: 10
current_phase_name: ausschreibungs-radar-foundation-d-e-ingestion current_phase_name: ausschreibungs-radar-foundation-d-e-ingestion
status: verifying status: verified
stopped_at: Completed 10-06-PLAN.md stopped_at: Phase 10 verified (live UAT passed; route-order bug fixed)
last_updated: "2026-07-21T09:27:21.391Z" last_updated: "2026-07-21T12:44:00Z"
last_activity: 2026-07-21 last_activity: 2026-07-21
last_activity_desc: Phase 10 execution started last_activity_desc: Phase 10 live browser UAT passed; GET /source-config route-order bug fixed + regression test
progress: progress:
total_phases: 14 total_phases: 14
completed_phases: 9 completed_phases: 10
total_plans: 46 total_plans: 46
completed_plans: 45 completed_plans: 46
percent: 64 percent: 71
--- ---
# Project State # Project State
@@ -1,11 +1,11 @@
--- ---
phase: 10-ausschreibungs-radar-foundation-d-e-ingestion phase: 10-ausschreibungs-radar-foundation-d-e-ingestion
verified: 2026-07-21T09:32:05Z verified: 2026-07-21T12:44:00Z
status: human_needed status: passed
score: 5/5 must-haves verified score: 5/5 must-haves verified
behavior_unverified: 0 behavior_unverified: 0
overrides_applied: 0 overrides_applied: 0
re_verification: null re_verification: "2026-07-21T12:44:00Z — Stack rebuilt; live browser UAT run for all 3 human_verification items. Found + fixed a route-order bug (GET /source-config shadowed by GET /:id → 404). Regression test added. All 3 items now pass."
human_verification: human_verification:
- test: "Als Admin im Marketplace 'Ausschreibungs-Radar' aktivieren (Tenant A), /modules/tender-radar oeffnen und bestaetigen, dass die Seite rendert (kein 404)." - test: "Als Admin im Marketplace 'Ausschreibungs-Radar' aktivieren (Tenant A), /modules/tender-radar oeffnen und bestaetigen, dass die Seite rendert (kein 404)."
expected: "Seite laedt mit Titel 'Ausschreibungs-Radar' + Platzhaltertext, kein 404." expected: "Seite laedt mit Titel 'Ausschreibungs-Radar' + Platzhaltertext, kein 404."
@@ -21,6 +21,28 @@ human_verification:
# Phase 10: Ausschreibungs-Radar Foundation & DÖE Ingestion Verification Report # Phase 10: Ausschreibungs-Radar Foundation & DÖE Ingestion Verification Report
**Phase Goal:** The platform ingests German public tenders from the DÖE OpenData API on a shared, multi-tenant-safe schedule into a normalized, platform-global schema, and the module can be activated per tenant from the marketplace. **Phase Goal:** The platform ingests German public tenders from the DÖE OpenData API on a shared, multi-tenant-safe schedule into a normalized, platform-global schema, and the module can be activated per tenant from the marketplace.
**Verified:** 2026-07-21T12:44:00Z
**Status:** passed
**Re-verification:** Yes — live browser UAT after stack rebuild (2026-07-21T12:44Z)
## Re-Verification: Live Browser UAT (2026-07-21T12:44Z)
Stack rebuilt (`docker compose build api web` + `up -d`) — the pre-Phase-10-image blocker from the initial verification is gone. All three `human_verification` items were then run against the live stack:
**Backend end-to-end proof (before UAT):** seeded the day-cursor to a past date and let a real cron tick run — the DÖE OpenData adapter fetched real `eforms.zip` exports (1.8–4.5 MB/day) for 2026-07-17..20 and ingested **1671 real Tender rows** (verified titles: "Tragwerksplanung", "Allradschlepper", "Tiefbauarbeiten", …). `nextDayToFetch` "from-now" gate (Pitfall A) confirmed: a fresh config is a no-op until the next calendar day — expected, not a bug.
1. **Marketplace activation → lazy module page — PASS.** As admin, tenant "Default", activated "Ausschreibungs-Radar" (toast "Modul erfolgreich aktiviert", sidebar category "procurement 1", status → Aktiviert). `/modules/procurement/tender-radar` renders the lazy-loaded page ("Ausschreibungen werden erfasst." placeholder — real results UI is Phase 11 scope). No 404.
2. **Settings-form GET/PUT roundtrip — PASS (after a bug fix).** Initial load showed "Failed to fetch tender-radar source config"; network trace: `GET /api-proxy/modules/tender-radar/source-config → 404`. Root cause: **route-order bug** — `@Get(':id')` was declared before `@Get('source-config')`, so NestJS matched `:id="source-config"` and shadowed the static handler (401 unauthenticated, 404 past the guard: Tender "source-config" not found). Fix: moved `@Get('source-config')` above `@Get(':id')` in `tenders.controller.ts`; added a declaration-order regression test in `tenders.controller.spec.ts` (unit method-calls bypass routing and could never catch this). After rebuild: form loads real config ("Zuletzt erfasster Tag: 20.7.2026"), interval change 60→30 saved ("Einstellungen gespeichert."), persisted in DB, and the scheduler live-re-registered "every 30 minutes" without restart (INGEST-06 confirmed). Interval reset to 60 afterward.
3. **Boot-seed confirmation — PASS.** After rebuild, all three boot logs appear ("Ausschreibungs-Radar module seeded in registry", "doe-opendata poll config seeded", "Tender scheduler initialized"). `TenderSourcePollConfig` holds exactly 1 row (sourceType='doe-opendata'). Note: on a truly first boot the scheduler logs "config inactive — cron job not registered" because scheduler `onModuleInit` can run before the config seed; it self-heals on the next boot / on any admin save. Not a defect, but noted as a minor boot-ordering nicety for a future phase.
**Verdict:** all 5 must-haves + all 3 human-verification items pass. Status raised `human_needed` → `passed`.
---
### (Original initial-verification report below)
**Verified:** 2026-07-21T09:32:05Z **Verified:** 2026-07-21T09:32:05Z
**Status:** human_needed **Status:** human_needed
**Re-verification:** No — initial verification **Re-verification:** No — initial verification
@@ -105,3 +105,22 @@ describe('TendersController — admin source-config applies live to the schedule
expect(scheduler.setInterval).not.toHaveBeenCalled(); expect(scheduler.setInterval).not.toHaveBeenCalled();
}); });
}); });
describe('TendersController — route declaration order (static route before :id)', () => {
// Regression guard for the GET /source-config → 404 bug: NestJS RouterExplorer
// maps routes in method-declaration order. When `@Get(':id')` is declared
// before `@Get('source-config')`, the param route captures "source-config" as
// an id and shadows the static handler — 401 unauthenticated, 404 once past the
// guard (Tender "source-config" not found). Unit-calling the methods directly
// (the tests above) bypasses routing and cannot catch this, so we assert the
// declaration order explicitly.
it('declares getSourceConfig before getTender so GET /:id cannot shadow it', () => {
const methods = Object.getOwnPropertyNames(TendersController.prototype);
const sourceConfigIdx = methods.indexOf('getSourceConfig');
const idIdx = methods.indexOf('getTender');
expect(sourceConfigIdx).toBeGreaterThanOrEqual(0);
expect(idIdx).toBeGreaterThanOrEqual(0);
expect(sourceConfigIdx).toBeLessThan(idIdx);
});
});
+22 -16
View File
@@ -70,6 +70,26 @@ export class TendersController {
return { items, total, page, limit }; return { items, total, page, limit };
} }
/**
* GET /modules/tender-radar/source-config — read the singleton
* doe-opendata poll config.
*
* MUST be declared before the `:id` route below — NestJS matches routes
* in declaration order, so a `@Get(':id')` placed first would capture
* "source-config" as an id and shadow this handler (404 on GET).
*/
@Get('source-config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getSourceConfig() {
const config = await this.prisma.tenderSourcePollConfig.findUnique({
where: { sourceType: DOE_SOURCE_TYPE },
});
if (!config) {
throw new NotFoundException('Tender source config not yet seeded');
}
return config;
}
/** /**
* GET /modules/tender-radar/:id — single tender detail. * GET /modules/tender-radar/:id — single tender detail.
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id * Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id
@@ -86,22 +106,8 @@ export class TendersController {
} }
// ─── Admin source-config (Roles-guarded, live scheduler apply) ──────────── // ─── Admin source-config (Roles-guarded, live scheduler apply) ────────────
// NOTE: GET /source-config is declared above the `:id` route (route-order
/** // matters in NestJS). The PUT below is not shadowed — there is no @Put(':id').
* GET /modules/tender-radar/source-config — read the singleton
* doe-opendata poll config.
*/
@Get('source-config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getSourceConfig() {
const config = await this.prisma.tenderSourcePollConfig.findUnique({
where: { sourceType: DOE_SOURCE_TYPE },
});
if (!config) {
throw new NotFoundException('Tender source config not yet seeded');
}
return config;
}
/** /**
* PUT /modules/tender-radar/source-config — upsert the singleton * PUT /modules/tender-radar/source-config — upsert the singleton