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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,16 @@
|
|||||||
import { Type } from 'class-transformer';
|
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.
|
* Query DTO for paginating + filtering the global tender catalog.
|
||||||
@@ -7,10 +18,14 @@ import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
|||||||
* Security:
|
* Security:
|
||||||
* - T-10-15: pagination bounds (limit @Max(100)) mitigate oversized
|
* - T-10-15: pagination bounds (limit @Max(100)) mitigate oversized
|
||||||
* result-set DoS — same convention as DkvHistoryQueryDto.
|
* 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 {
|
export class TenderQueryDto {
|
||||||
/**
|
/**
|
||||||
@@ -41,4 +56,69 @@ export class TenderQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['active', 'expired'])
|
@IsIn(['active', 'expired'])
|
||||||
status?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = {}): 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<string, unknown>) &&
|
||||||
|
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<Record<string, unknown>>) ?? [];
|
||||||
|
const valueClause = and.find((c) => 'OR' in c) as
|
||||||
|
| { OR: Array<Record<string, unknown>> }
|
||||||
|
| 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user