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>
This commit is contained in:
@@ -19,7 +19,12 @@ import { TendersController } from './tenders.controller';
|
|||||||
|
|
||||||
function makeFakePrisma() {
|
function makeFakePrisma() {
|
||||||
const tenders = new Map<string, any>();
|
const tenders = new Map<string, any>();
|
||||||
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<string, any>();
|
const configs = new Map<string, any>();
|
||||||
configs.set('doe-opendata', {
|
configs.set('doe-opendata', {
|
||||||
id: 'cfg1',
|
id: 'cfg1',
|
||||||
@@ -34,6 +39,9 @@ function makeFakePrisma() {
|
|||||||
findMany: vi.fn(async (_args?: any) => Array.from(tenders.values())),
|
findMany: vi.fn(async (_args?: any) => Array.from(tenders.values())),
|
||||||
count: vi.fn(async (_args?: any) => tenders.size),
|
count: vi.fn(async (_args?: any) => tenders.size),
|
||||||
findUnique: vi.fn(async ({ where }: any) => tenders.get(where.id) ?? null),
|
findUnique: vi.fn(async ({ where }: any) => tenders.get(where.id) ?? null),
|
||||||
|
groupBy: vi.fn(async (_args?: any) => [
|
||||||
|
{ sourcePortal: 'doe-opendata', _count: tenders.size },
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
tenderSourcePollConfig: {
|
tenderSourcePollConfig: {
|
||||||
findUnique: vi.fn(async ({ where }: any) => configs.get(where.sourceType) ?? null),
|
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(idIdx).toBeGreaterThanOrEqual(0);
|
||||||
expect(sourceConfigIdx).toBeLessThan(idIdx);
|
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 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { SourceConfigDto } from './dto/source-config.dto';
|
import { SourceConfigDto } from './dto/source-config.dto';
|
||||||
import { TenderQueryDto } from './dto/tender-query.dto';
|
import { TenderQueryDto } from './dto/tender-query.dto';
|
||||||
import { TenderSchedulerService } from './tender-scheduler.service';
|
import { TenderSchedulerService } from './tender-scheduler.service';
|
||||||
|
import { buildOrderBy, buildTenderWhere } from './tender-query.builder';
|
||||||
|
|
||||||
const DOE_SOURCE_TYPE = 'doe-opendata';
|
const DOE_SOURCE_TYPE = 'doe-opendata';
|
||||||
|
|
||||||
@@ -44,27 +45,35 @@ export class TendersController {
|
|||||||
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
|
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /modules/tender-radar — paginated global tender catalog.
|
* GET /modules/tender-radar — paginated, filtered + sorted global
|
||||||
* Gated by @UseModule('tender-radar'): only tenants with the module
|
* tender catalog. Gated by @UseModule('tender-radar'): only tenants
|
||||||
* active can read. Deliberately NOT filtered by the tenant's id — the
|
* with the module active can read. Deliberately NOT filtered by the
|
||||||
* catalog is platform-global (D-03).
|
* 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()
|
@Get()
|
||||||
@UseModule('tender-radar')
|
@UseModule('tender-radar')
|
||||||
async listTenders(@Query() query: TenderQueryDto) {
|
async listTenders(@Query() query: TenderQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
const status = query.status ?? 'active';
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = buildTenderWhere(query);
|
||||||
|
const orderBy = buildOrderBy(query.sort);
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.tender.findMany({
|
this.prisma.tender.findMany({
|
||||||
where: { status },
|
where,
|
||||||
orderBy: { publishedAt: 'desc' },
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
this.prisma.tender.count({ where: { status } }),
|
this.prisma.tender.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { items, total, page, limit };
|
return { items, total, page, limit };
|
||||||
@@ -90,6 +99,37 @@ export class TendersController {
|
|||||||
return config;
|
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.
|
* GET /modules/tender-radar/:id — single tender detail.
|
||||||
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id
|
* Gated by @UseModule('tender-radar'); NOT scoped by the tenant's id
|
||||||
|
|||||||
Reference in New Issue
Block a user