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:
@@ -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