Files
tessera-ctl/apps/api/src/tenders/tenders.controller.spec.ts
T
schalli 49622ba022 feat(11-01): wire listTenders through the query builder + add GET /coverage
listTenders now delegates where/orderBy composition to
tender-query.builder.ts (buildTenderWhere/buildOrderBy) instead of the
fixed status/publishedAt clause; pagination bounds unchanged (T-10-15).

New GET /modules/tender-radar/coverage handler returns active-tender
counts grouped by sourcePortal (D-12, UI-05 coverage banner data
source). Declared before @Get(':id') — same static-route-before-:id
convention as source-config (Pitfall 5, Phase-10 regression guard).

Extends tenders.controller.spec.ts: sort whitelist pass-through,
unknown-sort fallback, pagination skip/take, coverage response shape,
and a declaration-order regression test for getCoverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:50:23 +02:00

212 lines
8.4 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',
sourcePortal: 'doe-opendata',
});
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),
groupBy: vi.fn(async (_args?: any) => [
{ sourcePortal: 'doe-opendata', _count: tenders.size },
]),
},
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);
});
it('declares getCoverage before getTender so GET /:id cannot shadow it (Pitfall 5)', () => {
const methods = Object.getOwnPropertyNames(TendersController.prototype);
const coverageIdx = methods.indexOf('getCoverage');
const idIdx = methods.indexOf('getTender');
expect(coverageIdx).toBeGreaterThanOrEqual(0);
expect(idIdx).toBeGreaterThanOrEqual(0);
expect(coverageIdx).toBeLessThan(idIdx);
});
});
describe('TendersController — listTenders uses the query builder (sort whitelist + pagination bounds)', () => {
it('passes buildTenderWhere/buildOrderBy output through to prisma.tender.findMany', 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({ sort: 'deadline', q: 'Bau' } as any);
const callArgs = prisma.tender.findMany.mock.calls[0][0];
expect(callArgs.orderBy).toEqual({ deadlineAt: 'asc' });
expect(callArgs.where.AND).toEqual(
expect.arrayContaining([
{
OR: [
{ title: { contains: 'Bau', mode: 'insensitive' } },
{ buyerName: { contains: 'Bau', mode: 'insensitive' } },
],
},
]),
);
});
it('an unknown sort key falls back to the publishedAt-desc default via buildOrderBy', 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({ sort: 'not-whitelisted' } as any);
const callArgs = prisma.tender.findMany.mock.calls[0][0];
expect(callArgs.orderBy).toEqual({ publishedAt: 'desc' });
});
it('respects page/limit for skip/take (pagination bounds unchanged, T-10-15)', 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({ page: 3, limit: 10 } as any);
const callArgs = prisma.tender.findMany.mock.calls[0][0];
expect(callArgs.skip).toBe(20);
expect(callArgs.take).toBe(10);
});
});
describe('TendersController — GET /coverage', () => {
it('returns distinct sourcePortal distribution and total for active tenders', 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.getCoverage();
expect(prisma.tender.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['sourcePortal'],
where: { status: 'active' },
}),
);
expect(result).toEqual({
total: 1,
sources: [{ sourcePortal: 'doe-opendata', count: 1 }],
});
});
});