From 49622ba0225d8151f497a176e0bb0f14e0b34327 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 15:50:23 +0200 Subject: [PATCH] feat(11-01): wire listTenders through the query builder + add GET /coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/tenders/tenders.controller.spec.ts | 87 ++++++++++++++++++- apps/api/src/tenders/tenders.controller.ts | 56 ++++++++++-- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 4b89eea..a50f933 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -19,7 +19,12 @@ import { TendersController } from './tenders.controller'; function makeFakePrisma() { const tenders = new Map(); - tenders.set('t1', { id: 't1', title: 'Beispielausschreibung', status: 'active' }); + tenders.set('t1', { + id: 't1', + title: 'Beispielausschreibung', + status: 'active', + sourcePortal: 'doe-opendata', + }); const configs = new Map(); configs.set('doe-opendata', { id: 'cfg1', @@ -34,6 +39,9 @@ function makeFakePrisma() { 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), @@ -123,4 +131,81 @@ describe('TendersController — route declaration order (static route before :id 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 }], + }); + }); }); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 8f50b26..6f094ca 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -14,6 +14,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { SourceConfigDto } from './dto/source-config.dto'; import { TenderQueryDto } from './dto/tender-query.dto'; import { TenderSchedulerService } from './tender-scheduler.service'; +import { buildOrderBy, buildTenderWhere } from './tender-query.builder'; const DOE_SOURCE_TYPE = 'doe-opendata'; @@ -44,27 +45,35 @@ export class TendersController { // ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ──────────────────── /** - * GET /modules/tender-radar — paginated global tender catalog. - * Gated by @UseModule('tender-radar'): only tenants with the module - * active can read. Deliberately NOT filtered by the tenant's id — the - * catalog is platform-global (D-03). + * GET /modules/tender-radar — paginated, filtered + sorted global + * tender catalog. Gated by @UseModule('tender-radar'): only tenants + * with the module active can read. Deliberately NOT filtered by the + * tenant's id — the catalog is platform-global (D-03). + * + * Filter/sort composition is delegated to tender-query.builder.ts + * (buildTenderWhere/buildOrderBy) — kept out of the controller so the + * where/orderBy logic is independently unit-testable (T-11-01/03). + * Pagination bounds (limit @Max(100), page @Min(1)) are unchanged + * (T-10-15, Don't Hand-Roll). */ @Get() @UseModule('tender-radar') async listTenders(@Query() query: TenderQueryDto) { const page = query.page ?? 1; const limit = query.limit ?? 20; - const status = query.status ?? 'active'; const skip = (page - 1) * limit; + const where = buildTenderWhere(query); + const orderBy = buildOrderBy(query.sort); + const [items, total] = await Promise.all([ this.prisma.tender.findMany({ - where: { status }, - orderBy: { publishedAt: 'desc' }, + where, + orderBy, skip, take: limit, }), - this.prisma.tender.count({ where: { status } }), + this.prisma.tender.count({ where }), ]); return { items, total, page, limit }; @@ -90,6 +99,37 @@ export class TendersController { return config; } + /** + * GET /modules/tender-radar/coverage — distribution of active tenders + * by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a + * thin/single-source result list (currently only 'doe-opendata', + * Oberschwelle-lastig) is not misread as a defect. + * + * MUST be declared before `@Get(':id')` below — same route-order + * pitfall as `source-config` above (Pitfall 5, Phase-10 regression). + * Read-surface, gated by @UseModule (not an admin-only route). + */ + @Get('coverage') + @UseModule('tender-radar') + async getCoverage() { + const [bySource, total] = await Promise.all([ + this.prisma.tender.groupBy({ + by: ['sourcePortal'], + _count: true, + where: { status: 'active' }, + }), + this.prisma.tender.count({ where: { status: 'active' } }), + ]); + + return { + total, + sources: bySource.map((s) => ({ + sourcePortal: s.sourcePortal, + count: s._count, + })), + }; + } + /** * GET /modules/tender-radar/:id — single tender detail. * Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id