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(); tenders.set('t1', { id: 't1', title: 'Beispielausschreibung', status: 'active', sourcePortal: 'doe-opendata', }); const configs = new Map(); 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 }], }); }); });