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:
+7
-7
@@ -4,17 +4,17 @@ milestone: v1.1
|
||||
milestone_name: Ausschreibungs-Radar
|
||||
current_phase: 10
|
||||
current_phase_name: ausschreibungs-radar-foundation-d-e-ingestion
|
||||
status: verifying
|
||||
stopped_at: Completed 10-06-PLAN.md
|
||||
last_updated: "2026-07-21T09:27:21.391Z"
|
||||
status: verified
|
||||
stopped_at: Phase 10 verified (live UAT passed; route-order bug fixed)
|
||||
last_updated: "2026-07-21T12:44:00Z"
|
||||
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:
|
||||
total_phases: 14
|
||||
completed_phases: 9
|
||||
completed_phases: 10
|
||||
total_plans: 46
|
||||
completed_plans: 45
|
||||
percent: 64
|
||||
completed_plans: 46
|
||||
percent: 71
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
+25
-3
@@ -1,11 +1,11 @@
|
||||
---
|
||||
phase: 10-ausschreibungs-radar-foundation-d-e-ingestion
|
||||
verified: 2026-07-21T09:32:05Z
|
||||
status: human_needed
|
||||
verified: 2026-07-21T12:44:00Z
|
||||
status: passed
|
||||
score: 5/5 must-haves verified
|
||||
behavior_unverified: 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:
|
||||
- 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."
|
||||
@@ -21,6 +21,28 @@ human_verification:
|
||||
# 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.
|
||||
**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
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
@@ -105,3 +105,22 @@ describe('TendersController — admin source-config applies live to the schedule
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,26 @@ export class TendersController {
|
||||
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.
|
||||
* 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) ────────────
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
// 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').
|
||||
|
||||
/**
|
||||
* PUT /modules/tender-radar/source-config — upsert the singleton
|
||||
|
||||
Reference in New Issue
Block a user