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 && JSON.stringify(c).includes('estimatedValue'), ) 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'); }); it('plz set: adds a plz-startsWith constraint (FILTER-02)', () => { const where = buildTenderWhere(dto({ plz: '10' })); expect(where.AND).toEqual( expect.arrayContaining([{ plz: { startsWith: '10' } }]), ); }); it('bundesland set: adds an exact bundesland-equality constraint against the backfilled column (FILTER-02, Pitfall 1)', () => { const where = buildTenderWhere(dto({ bundesland: 'Bayern' })); expect(where.AND).toEqual(expect.arrayContaining([{ bundesland: 'Bayern' }])); }); it('region set: adds a region-startsWith constraint, independent of bundesland (FILTER-02)', () => { const where = buildTenderWhere(dto({ region: 'DE2' })); expect(where.AND).toEqual( expect.arrayContaining([{ region: { startsWith: 'DE2' } }]), ); }); it('plz/bundesland/region are omitted from AND when unset', () => { const where = buildTenderWhere(dto()); const and = (where.AND as unknown[]) ?? []; const hasGeoClause = and.some( (clause) => typeof clause === 'object' && clause !== null && ('plz' in (clause as Record) || 'bundesland' in (clause as Record) || 'region' in (clause as Record)), ); expect(hasGeoClause).toBe(false); }); it('cpv set: adds a cpvDivisions hasSome constraint (FILTER-03, Pitfall 2)', () => { const where = buildTenderWhere(dto({ cpv: ['45'] })); expect(where.AND).toEqual( expect.arrayContaining([{ cpvDivisions: { hasSome: ['45'] } }]), ); }); it('cpv with multiple divisions: hasSome carries all selected divisions', () => { const where = buildTenderWhere(dto({ cpv: ['45', '71'] })); expect(where.AND).toEqual( expect.arrayContaining([{ cpvDivisions: { hasSome: ['45', '71'] } }]), ); }); it('cpv omitted or empty is never added to AND', () => { const whereUnset = buildTenderWhere(dto()); const whereEmpty = buildTenderWhere(dto({ cpv: [] })); for (const where of [whereUnset, whereEmpty]) { const and = (where.AND as unknown[]) ?? []; const hasCpvClause = and.some( (clause) => typeof clause === 'object' && clause !== null && 'cpvDivisions' in (clause as Record), ); expect(hasCpvClause).toBe(false); } }); }); describe('buildTenderWhere — favOnly (UI-04, D-10, T-11-10/11)', () => { it('favOnly=true with favIds supplied: adds an id-in constraint containing exactly those ids', () => { const where = buildTenderWhere(dto({ favOnly: true }), ['t1', 't2']); expect(where.AND).toEqual( expect.arrayContaining([{ id: { in: ['t1', 't2'] } }]), ); }); it('favOnly=true with empty favIds: yields a guaranteed-empty match, never "all tenders"', () => { const where = buildTenderWhere(dto({ favOnly: true }), []); expect(where.AND).toEqual( expect.arrayContaining([{ id: { in: ['__none__'] } }]), ); }); it('favOnly=true with favIds omitted entirely: also yields a guaranteed-empty match', () => { const where = buildTenderWhere(dto({ favOnly: true })); expect(where.AND).toEqual( expect.arrayContaining([{ id: { in: ['__none__'] } }]), ); }); it('favOnly unset: never adds an id-in constraint, regardless of favIds', () => { const where = buildTenderWhere(dto(), ['t1', 't2']); const and = (where.AND as unknown[]) ?? []; const hasFavClause = and.some( (clause) => typeof clause === 'object' && clause !== null && 'id' in (clause as Record), ); expect(hasFavClause).toBe(false); }); it('favOnly=true with more favIds than MAX_FAV_IDS: the in-list is capped (T-11-11 DoS)', () => { const manyIds = Array.from({ length: 600 }, (_, i) => `t${i}`); const where = buildTenderWhere(dto({ favOnly: true }), manyIds); const and = (where.AND as Array>) ?? []; const favClause = and.find((c) => 'id' in c) as | { id: { in: string[] } } | undefined; expect(favClause?.id.in.length).toBeLessThanOrEqual(500); }); }); describe('buildTenderWhere — D-13 ownerTenantId visibility (Phase 14, Plan 03)', () => { it('a resolved ownerTenantId adds an OR[global, mine] clause', () => { const where = buildTenderWhere(dto(), undefined, 'tenant-a'); expect(where.AND).toEqual( expect.arrayContaining([ { OR: [{ ownerTenantId: null }, { ownerTenantId: 'tenant-a' }] }, ]), ); }); it('an unresolved ownerTenantId (no auth context) fails closed to global-only tenders', () => { const where = buildTenderWhere(dto()); expect(where.AND).toEqual( expect.arrayContaining([{ ownerTenantId: null }]), ); }); }); 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' }); }); });