From 426a1ea3b8ee132f104e2951afcc5ffb46d6b6b1 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 15:47:40 +0200 Subject: [PATCH] test(11-01): add failing tender-query.builder spec (RED) Extends TenderQueryDto with validated filter/sort params (q, openOnly, deadlineFrom/To, valueMin/Max, includeNullValue, sort) and adds the RED-first spec for the not-yet-implemented tender-query.builder.ts: NULL-graceful value filter (D-05), openOnly deadline default (D-04), explicit deadline range, and sort whitelist (UI-01). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/tenders/dto/tender-query.dto.ts | 86 +++++++++- .../src/tenders/tender-query.builder.spec.ts | 155 ++++++++++++++++++ 2 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/tenders/tender-query.builder.spec.ts diff --git a/apps/api/src/tenders/dto/tender-query.dto.ts b/apps/api/src/tenders/dto/tender-query.dto.ts index c594f90..ce33d43 100644 --- a/apps/api/src/tenders/dto/tender-query.dto.ts +++ b/apps/api/src/tenders/dto/tender-query.dto.ts @@ -1,5 +1,16 @@ import { Type } from 'class-transformer'; -import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { + IsBoolean, + IsDate, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; /** * Query DTO for paginating + filtering the global tender catalog. @@ -7,10 +18,14 @@ import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; * Security: * - T-10-15: pagination bounds (limit @Max(100)) mitigate oversized * result-set DoS — same convention as DkvHistoryQueryDto. + * - T-11-01: `sort` is validated against a fixed whitelist (@IsIn) — the + * actual Prisma orderBy key mapping happens in tender-query.builder.ts's + * SORT_MAP, never from a raw user-supplied field name. * - * Used for: GET /modules/tender-radar?page=1&limit=20&status=active + * Used for: GET /modules/tender-radar?page=1&limit=20&status=active&q=... * - * No region/CPV filters here — rich filtering is Phase 11 (FILTER-*). + * Region/PLZ/Bundesland/CPV/favOnly filters are added in later Phase-11 + * plans (11-02/03/05) — deliberately NOT added here. */ export class TenderQueryDto { /** @@ -41,4 +56,69 @@ export class TenderQueryDto { @IsOptional() @IsIn(['active', 'expired']) status?: string; + + /** + * Freitext-Suche über title + buyerName (FILTER-01, D-01). + * MaxLength bounds an otherwise-unbounded `contains` scan input. + */ + @IsOptional() + @IsString() + @MaxLength(200) + q?: string; + + /** + * "Nur noch offene" Default-Ansicht (FILTER-04, D-04). Default-Semantik + * (true when omitted) is applied in tender-query.builder.ts, not here — + * the DTO only validates the shape of an explicitly-supplied value. + */ + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + openOnly?: boolean; + + /** + * Abgabefrist-Datumsbereich (FILTER-04). Combinable with openOnly. + */ + @IsOptional() + @Type(() => Date) + @IsDate() + deadlineFrom?: Date; + + @IsOptional() + @Type(() => Date) + @IsDate() + deadlineTo?: Date; + + /** + * Geschätzter Auftragswert min/max (FILTER-05, D-05). + */ + @IsOptional() + @Type(() => Number) + @IsNumber() + valueMin?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + valueMax?: number; + + /** + * Whether NULL-value rows stay visible while a value filter is active. + * Default-Semantik (true when omitted) applied in the builder — D-05 + * critical requirement: NULL-Wert-Zeilen dürfen NIE stillschweigend + * verschwinden. + */ + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + includeNullValue?: boolean; + + /** + * Sort-Whitelist (UI-01, D-06, T-11-01): only these three keys are + * accepted; the DTO-level @IsIn rejects anything else before the + * request reaches the builder's SORT_MAP. + */ + @IsOptional() + @IsIn(['deadline', 'value', 'published']) + sort?: string; } diff --git a/apps/api/src/tenders/tender-query.builder.spec.ts b/apps/api/src/tenders/tender-query.builder.spec.ts new file mode 100644 index 0000000..729bbbd --- /dev/null +++ b/apps/api/src/tenders/tender-query.builder.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { buildOrderBy, buildTenderWhere } from './tender-query.builder'; +import type { TenderQueryDto } from './dto/tender-query.dto'; + +/** + * tender-query.builder.spec — RED-first (TDD) proof for FILTER-01/04/05, + * UI-01 (D-01, D-04, D-05, D-06). + * + * Core test (Pitfall 3 / D-05): an active value filter must NEVER + * eliminate estimatedValue=null rows — 91.6% of the live Tender data has + * no estimatedValue, so a naive range filter would collapse the list. + */ + +function dto(overrides: Partial = {}): TenderQueryDto { + return overrides as TenderQueryDto; +} + +describe('buildTenderWhere', () => { + it('empty DTO: defaults to status=active and openOnly (deadline in future OR null)', () => { + const where = buildTenderWhere(dto()); + + expect(where.status).toBe('active'); + expect(where.AND).toEqual( + expect.arrayContaining([ + { + OR: [{ deadlineAt: { gte: expect.any(Date) } }, { deadlineAt: null }], + }, + ]), + ); + }); + + it('openOnly=false omits the deadline-open constraint entirely', () => { + const where = buildTenderWhere(dto({ openOnly: false })); + + const and = (where.AND as unknown[]) ?? []; + const hasOpenClause = and.some( + (clause) => + typeof clause === 'object' && + clause !== null && + 'OR' in (clause as Record) && + JSON.stringify(clause).includes('deadlineAt'), + ); + expect(hasOpenClause).toBe(false); + }); + + it('deadlineFrom/deadlineTo set: adds an explicit deadlineAt range, combinable with openOnly', () => { + const from = new Date('2026-08-01T00:00:00.000Z'); + const to = new Date('2026-08-31T23:59:59.000Z'); + const where = buildTenderWhere(dto({ deadlineFrom: from, deadlineTo: to })); + + expect(where.AND).toEqual( + expect.arrayContaining([{ deadlineAt: { gte: from, lte: to } }]), + ); + // openOnly default (true) still applied alongside the explicit range. + expect(where.AND).toEqual( + expect.arrayContaining([ + { + OR: [{ deadlineAt: { gte: expect.any(Date) } }, { deadlineAt: null }], + }, + ]), + ); + }); + + it('deadlineFrom only: range uses gte without lte', () => { + const from = new Date('2026-08-01T00:00:00.000Z'); + const where = buildTenderWhere(dto({ deadlineFrom: from })); + + expect(where.AND).toEqual( + expect.arrayContaining([{ deadlineAt: { gte: from } }]), + ); + }); + + it('q set: adds case-insensitive OR over title + buyerName', () => { + const where = buildTenderWhere(dto({ q: 'Bau' })); + + expect(where.AND).toEqual( + expect.arrayContaining([ + { + OR: [ + { title: { contains: 'Bau', mode: 'insensitive' } }, + { buyerName: { contains: 'Bau', mode: 'insensitive' } }, + ], + }, + ]), + ); + }); + + it('value filter with default includeNullValue: OR(range, null) — NULL rows survive', () => { + const where = buildTenderWhere(dto({ valueMin: 1000, valueMax: 5000 })); + + expect(where.AND).toEqual( + expect.arrayContaining([ + { + OR: [ + { estimatedValue: { gte: 1000, lte: 5000 } }, + { estimatedValue: null }, + ], + }, + ]), + ); + }); + + it('value filter with includeNullValue=false: pure range, no null branch', () => { + const where = buildTenderWhere( + dto({ valueMin: 1000, valueMax: 5000, includeNullValue: false }), + ); + + expect(where.AND).toEqual( + expect.arrayContaining([{ estimatedValue: { gte: 1000, lte: 5000 } }]), + ); + const and = (where.AND as unknown[]) ?? []; + const hasOrNullBranch = and.some( + (clause) => + typeof clause === 'object' && + clause !== null && + JSON.stringify(clause).includes('"estimatedValue":null'), + ); + expect(hasOrNullBranch).toBe(false); + }); + + it('valueMin only: range has gte but no lte key', () => { + const where = buildTenderWhere(dto({ valueMin: 1000 })); + + const and = (where.AND as Array>) ?? []; + const valueClause = and.find((c) => 'OR' in c) as + | { OR: Array> } + | undefined; + const rangeBranch = valueClause?.OR.find((b) => 'estimatedValue' in b && b.estimatedValue !== null); + expect(rangeBranch).toEqual({ estimatedValue: { gte: 1000 } }); + }); + + it('explicit status overrides the active default', () => { + const where = buildTenderWhere(dto({ status: 'expired' })); + expect(where.status).toBe('expired'); + }); +}); + +describe('buildOrderBy', () => { + it('sort=deadline maps to { deadlineAt: asc }', () => { + expect(buildOrderBy('deadline')).toEqual({ deadlineAt: 'asc' }); + }); + + it('sort=value maps to { estimatedValue: desc }', () => { + expect(buildOrderBy('value')).toEqual({ estimatedValue: 'desc' }); + }); + + it('sort=published maps to { publishedAt: desc }', () => { + expect(buildOrderBy('published')).toEqual({ publishedAt: 'desc' }); + }); + + it('unknown/missing sort key defaults to { publishedAt: desc }', () => { + expect(buildOrderBy(undefined)).toEqual({ publishedAt: 'desc' }); + expect(buildOrderBy('not-a-real-key')).toEqual({ publishedAt: 'desc' }); + }); +});