Files
tessera-ctl/apps/api/src/tenders/tender-query.builder.spec.ts
T
schalli 48e12523f3 feat(14-03): add email-config admin routes + D-13 read-side visibility filter + EmailAlertConfigForm
buildTenderWhere gains an optional ownerTenantId param: a resolved
requesting tenant sees global tenders (null) plus its own private ones
(OR[global, mine]); an unresolved requester fails CLOSED to global-only —
never an accidental cross-tenant leak.

TendersController: listTenders/getTender resolve the requesting tenant
leniently from the auth context (resolveRequestingTenantId, never throws)
and apply the D-13 filter; getTender 404s (not a distinct "forbidden") when
a tender's non-null ownerTenantId doesn't match the requester, so no
cross-tenant detail leak. New GET/PUT /modules/tender-radar/email-config
routes (Roles ADMIN/SUPER_ADMIN, tenantId from auth context, never the
body) delegate to TenderEmailConfigService — declared before @Get(':id')
per the project's NestJS route-order convention.

Web: EmailAlertConfig type + fetchEmailConfig/saveEmailConfig client
functions; EmailAlertConfigForm mirrors the DKV InboxConfigForm (password
blank on load, only sent when typed — T-07-12), added as a new
"E-Mail-Alerts" section on the existing tender-radar settings page.
Hardcoded German strings — i18n is Plan 14-05.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:53:17 +02:00

297 lines
9.8 KiB
TypeScript

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 && JSON.stringify(c).includes('estimatedValue'),
) 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');
});
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<string, unknown>) ||
'bundesland' in (clause as Record<string, unknown>) ||
'region' in (clause as Record<string, unknown>)),
);
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<string, unknown>),
);
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<string, unknown>),
);
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<Record<string, unknown>>) ?? [];
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' });
});
});