48d1246043
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>
127 lines
5.3 KiB
TypeScript
127 lines
5.3 KiB
TypeScript
import { NotFoundException } from '@nestjs/common';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { TendersController } from './tenders.controller';
|
|
|
|
/**
|
|
* TendersController.spec — automated proof for INGEST-06 and the
|
|
* tenant-gated-not-scoped invariant (RESEARCH.md V4, T-10-14):
|
|
*
|
|
* - The global read path (`GET /`) never adds the tenant's id to the
|
|
* prisma `where` clause — the catalog is platform-wide, not row-scoped.
|
|
* - Saving the admin source-config drives the scheduler's setInterval()
|
|
* (single argument, no tenant id) / stopJob() exactly as the DKV analog
|
|
* does, minus the tenant argument.
|
|
*
|
|
* Uses the same hand-rolled prisma-shaped fake convention as
|
|
* tender-ingestion.service.spec.ts / tender-scheduler.service.spec.ts
|
|
* (in-memory fakes, no live DB connection).
|
|
*/
|
|
|
|
function makeFakePrisma() {
|
|
const tenders = new Map<string, any>();
|
|
tenders.set('t1', { id: 't1', title: 'Beispielausschreibung', status: 'active' });
|
|
const configs = new Map<string, any>();
|
|
configs.set('doe-opendata', {
|
|
id: 'cfg1',
|
|
sourceType: 'doe-opendata',
|
|
pollIntervalMin: 60,
|
|
isActive: true,
|
|
lastIngestedDay: null,
|
|
});
|
|
|
|
return {
|
|
tender: {
|
|
findMany: vi.fn(async (_args?: any) => Array.from(tenders.values())),
|
|
count: vi.fn(async (_args?: any) => tenders.size),
|
|
findUnique: vi.fn(async ({ where }: any) => tenders.get(where.id) ?? null),
|
|
},
|
|
tenderSourcePollConfig: {
|
|
findUnique: vi.fn(async ({ where }: any) => configs.get(where.sourceType) ?? null),
|
|
upsert: vi.fn(async ({ where, update, create }: any) => {
|
|
const existing = configs.get(where.sourceType);
|
|
const record = existing ? { ...existing, ...update } : { ...create };
|
|
configs.set(where.sourceType, record);
|
|
return record;
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('TendersController — global read (not tenant-scoped)', () => {
|
|
it('GET / calls prisma.tender.findMany with a where clause that has no tenantId key', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(prisma as any, scheduler);
|
|
|
|
await controller.listTenders({});
|
|
|
|
expect(prisma.tender.findMany).toHaveBeenCalledTimes(1);
|
|
const callArgs = prisma.tender.findMany.mock.calls[0][0];
|
|
expect(callArgs.where).not.toHaveProperty('tenantId');
|
|
});
|
|
|
|
it('GET /:id returns the tender when found and never scopes by tenant', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(prisma as any, scheduler);
|
|
|
|
const result = await controller.getTender('t1');
|
|
|
|
expect(result.id).toBe('t1');
|
|
const callArgs = prisma.tender.findUnique.mock.calls[0][0];
|
|
expect(callArgs.where).toEqual({ id: 't1' });
|
|
});
|
|
|
|
it('GET /:id throws NotFoundException for a missing id', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(prisma as any, scheduler);
|
|
|
|
await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('TendersController — admin source-config applies live to the scheduler (INGEST-06)', () => {
|
|
it('PUT /source-config with isActive=true + pollIntervalMin=30 calls scheduler.setInterval(30) with a single argument', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(prisma as any, scheduler);
|
|
|
|
await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 });
|
|
|
|
expect(scheduler.setInterval).toHaveBeenCalledTimes(1);
|
|
expect(scheduler.setInterval).toHaveBeenCalledWith(30);
|
|
expect(scheduler.stopJob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PUT /source-config with isActive=false calls scheduler.stopJob()', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
|
const controller = new TendersController(prisma as any, scheduler);
|
|
|
|
await controller.saveSourceConfig({ isActive: false });
|
|
|
|
expect(scheduler.stopJob).toHaveBeenCalledTimes(1);
|
|
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);
|
|
});
|
|
});
|