test(books): add exhaustive filter validator and query-builder coverage

This commit is contained in:
neon
2026-04-01 12:52:34 -06:00
parent 1fbc499ffc
commit 777bedaa90
5 changed files with 588 additions and 0 deletions
@@ -87,6 +87,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
audioMetadata: null,
formatPriority: [],
comicMetadata: null,
collections: [],
...overrides,
}
}
@@ -51,6 +51,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
audioMetadata: null,
formatPriority: [],
comicMetadata: null,
collections: [],
...overrides,
}
}
@@ -83,6 +83,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
audioMetadata: null,
formatPriority: [],
comicMetadata: null,
collections: [],
...overrides,
}
}
@@ -27,6 +27,8 @@ vi.mock('drizzle-orm', () => {
import { BadRequestException } from '@nestjs/common';
import { ilike, sql } from 'drizzle-orm';
import { FIELD_OPERATORS, type RuleField, type RuleOperator } from '@projectx/types';
import { BookQueryBuilder } from './book-query-builder.service';
function makeBuilder() {
@@ -47,6 +49,72 @@ function wrapRule(rule: Record<string, unknown>) {
};
}
/**
* Extracts the SQL fragment produced by the innermost rule from a single-rule
* AND group built with accessibleLibraryIds:[1] and no implicitLibraryId.
*
* Shape: and(libraryScope, and(ruleSql)) → clauses[1].clauses[0] = ruleSql
*/
function getRuleSql(where: Record<string, unknown>): Record<string, unknown> {
const outer = where as { clauses: { clauses: unknown[] }[] };
return outer.clauses[1].clauses[0] as Record<string, unknown>;
}
const BASE_CTX = { accessibleLibraryIds: [1] as number[] };
const USER_CTX = { accessibleLibraryIds: [1] as number[], userId: 10 };
/**
* Returns a minimal valid { value, valueTo } pair for a given operator+field
* combination that satisfies the query builder's runtime assertions (e.g. numeric
* operators need a finite number; date operators need a parseable date string).
*
* The exhaustive `never` default ensures TypeScript raises a compile error when a
* new operator is added to RuleOperator but not handled here.
*/
function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unknown; valueTo?: unknown } {
const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'rating', 'metadataScore'];
const isNumericField = numericFields.includes(field);
switch (operator) {
case 'isEmpty':
case 'isNotEmpty':
case 'isMissing':
case 'isPresent':
case 'isUnread':
case 'isInProgress':
case 'isFinished':
return {};
case 'contains':
case 'notContains':
case 'startsWith':
case 'endsWith':
return { value: 'test' };
case 'eq':
case 'notEq':
return { value: isNumericField ? 10 : 'test' };
case 'gt':
case 'gte':
case 'lt':
case 'lte':
return { value: 10 };
case 'between':
return field === 'addedAt' ? { value: '2023-01-01', valueTo: '2023-12-31' } : { value: 10, valueTo: 20 };
case 'before':
case 'after':
return { value: '2023-01-01' };
case 'withinLast':
return { value: 7 };
case 'includesAny':
case 'includesAll':
case 'excludesAll':
return { value: ['test'] };
default: {
const _exhaustive: never = operator;
return _exhaustive;
}
}
}
describe('BookQueryBuilder', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -235,3 +303,447 @@ describe('BookQueryBuilder', () => {
expect(where.clauses[1].clauses[0]).toMatchObject({ type: 'sql', text: '1 = 0' });
});
});
describe('field coverage smoke tests', () => {
it.each(Object.entries(FIELD_OPERATORS) as [RuleField, RuleOperator[]][])(
'field %s: every valid operator resolves without throwing',
(field, operators) => {
const { builder } = makeBuilder();
for (const operator of operators) {
const { value, valueTo } = buildValueFor(operator, field);
const rule: Record<string, unknown> = { type: 'rule', field, operator };
if (value !== undefined) rule.value = value;
if (valueTo !== undefined) rule.valueTo = valueTo;
expect(() => builder.buildWhere(wrapRule(rule) as never, USER_CTX), `field '${field}' operator '${operator}' should not throw`).not.toThrow();
}
},
);
});
describe('textRuleToSql (via title)', () => {
it.each([
['contains', 'Dune', { type: 'ilike', pattern: '%Dune%' }],
['notContains', 'Dune', { type: 'not' }],
['startsWith', 'Dune', { type: 'ilike', pattern: 'Dune%' }],
['endsWith', 'Dune', { type: 'ilike', pattern: '%Dune' }],
['eq', 'Dune', { type: 'ilike', pattern: 'Dune' }],
['notEq', 'Dune', { type: 'not' }],
['isEmpty', undefined, { type: 'isNull' }],
['isNotEmpty', undefined, { type: 'isNotNull' }],
] as const)('operator %s produces the correct SQL shape', (operator, value, expected) => {
const { builder } = makeBuilder();
const rule: Record<string, unknown> = { type: 'rule', field: 'title', operator };
if (value !== undefined) rule.value = value;
const where = builder.buildWhere(wrapRule(rule) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject(expected);
});
it('notContains wraps an ilike in not()', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'title', operator: 'notContains', value: 'Dune' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'not', value: { type: 'ilike', pattern: '%Dune%' } });
});
it('notEq wraps an exact ilike in not()', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'title', operator: 'notEq', value: 'Dune' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'not', value: { type: 'ilike', pattern: 'Dune' } });
});
});
describe('textSetRuleToSql (via publisher with array values)', () => {
it('includesAny produces inArray on the column directly', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'publisher', operator: 'includesAny', value: ['Penguin', 'TOR'] }) as never,
BASE_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
});
it('excludesAll with values produces or(isNull, not(inArray))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'publisher', operator: 'excludesAll', value: ['Penguin'] }) as never,
BASE_CTX,
) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'or' });
expect(clause.clauses[0]).toMatchObject({ type: 'isNull' });
expect(clause.clauses[1]).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('excludesAll with empty array produces always-true branch', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'publisher', operator: 'excludesAll', value: [] }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
});
});
describe('numericRuleToSql (via pageCount and publishedYear)', () => {
it.each([
['gt', { type: 'gt' }],
['gte', { type: 'gte' }],
['lt', { type: 'lt' }],
['lte', { type: 'lte' }],
['isEmpty', { type: 'isNull' }],
['isNotEmpty', { type: 'isNotNull' }],
] as const)('pageCount %s produces %o', (operator, expected) => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'pageCount', operator, value: 100 }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject(expected);
});
it('between produces and(gte, lte)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'pageCount', operator: 'between', value: 100, valueTo: 500 }) as never,
BASE_CTX,
) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'and' });
expect(clause.clauses[0]).toMatchObject({ type: 'gte' });
expect(clause.clauses[1]).toMatchObject({ type: 'lte' });
});
it('publishedYear eq produces eq(col, value)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'publishedYear', operator: 'eq', value: 2001 }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'eq' });
});
it('publishedYear notEq produces ne(col, value)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'publishedYear', operator: 'notEq', value: 2001 }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'ne' });
});
});
describe('authorRuleToSql', () => {
it('includesAny with values produces inArray(books.id, subquery)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'author', operator: 'includesAny', value: ['Tolkien'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
expect(getRuleSql(where).right).toMatchObject({ type: 'subquery' });
});
it('excludesAll with values produces not(inArray(books.id, subquery))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'author', operator: 'excludesAll', value: ['Tolkien'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('excludesAll with empty array produces always-true branch', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'author', operator: 'excludesAll', value: [] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
});
it('isEmpty produces not(inArray(books.id, subquery with no where clause))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'author', operator: 'isEmpty' }) as never, USER_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('isNotEmpty produces inArray(books.id, subquery with no where clause)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'author', operator: 'isNotEmpty' }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
});
});
describe('genreRuleToSql', () => {
it('includesAny produces inArray with eq-based subquery (not ilike like author)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'genre', operator: 'includesAny', value: ['Fiction'] }) as never,
USER_CTX,
) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'inArray' });
expect(clause.right.whereClause).toMatchObject({ type: 'or' });
expect(clause.right.whereClause.clauses[0]).toMatchObject({ type: 'eq' });
});
it('includesAll produces one subquery per value', () => {
const { builder, db } = makeBuilder();
builder.buildWhere(wrapRule({ type: 'rule', field: 'genre', operator: 'includesAll', value: ['Fiction', 'Sci-Fi'] }) as never, USER_CTX);
expect(db.select).toHaveBeenCalledTimes(2);
});
it('excludesAll with values produces not(inArray(books.id, subquery))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'genre', operator: 'excludesAll', value: ['Fiction'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('excludesAll with empty array produces always-true branch', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'genre', operator: 'excludesAll', value: [] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
});
it('isEmpty produces not(inArray(books.id, subquery))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'genre', operator: 'isEmpty' }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('isNotEmpty produces inArray(books.id, subquery)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'genre', operator: 'isNotEmpty' }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
});
});
describe('tagRuleToSql', () => {
it('includesAny produces inArray(books.id, subquery)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'tag', operator: 'includesAny', value: ['favourite'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
});
it('includesAll produces one subquery per value', () => {
const { builder, db } = makeBuilder();
builder.buildWhere(wrapRule({ type: 'rule', field: 'tag', operator: 'includesAll', value: ['a', 'b'] }) as never, USER_CTX);
expect(db.select).toHaveBeenCalledTimes(2);
});
it('excludesAll with values produces not(inArray)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'tag', operator: 'excludesAll', value: ['favourite'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('excludesAll with empty array produces always-true branch', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'tag', operator: 'excludesAll', value: [] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
});
it('isEmpty produces not(inArray)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'tag', operator: 'isEmpty' }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('isNotEmpty produces inArray', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'tag', operator: 'isNotEmpty' }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
});
});
describe('collectionRuleToSql', () => {
it('includesAny produces inArray(books.id, user-scoped subquery)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'collection', operator: 'includesAny', value: ['Reading'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
});
it('excludesAll with values produces not(inArray)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'collection', operator: 'excludesAll', value: ['Reading'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('excludesAll with empty array produces always-true branch', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'collection', operator: 'excludesAll', value: [] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
});
it('isEmpty produces not(inArray)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'collection', operator: 'isEmpty' }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('isNotEmpty produces inArray', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'collection', operator: 'isNotEmpty' }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
});
});
describe('formatRuleToSql', () => {
it('includesAny produces inArray(books.id, subquery with inArray on format column)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'format', operator: 'includesAny', value: ['PDF', 'EPUB'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'inArray' });
});
it('includesAny with empty array produces always-false branch', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'format', operator: 'includesAny', value: [] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 0' });
});
it('excludesAll with values produces not(inArray)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'format', operator: 'excludesAll', value: ['PDF'] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('excludesAll with empty array produces always-true branch', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'format', operator: 'excludesAll', value: [] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
});
});
describe('dateRuleToSql (addedAt)', () => {
it('before produces lt(books.addedAt, parsedDate)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'before', value: '2023-01-01' }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'lt' });
expect(getRuleSql(where).right).toBeInstanceOf(Date);
});
it('after produces gt(books.addedAt, parsedDate)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'after', value: '2023-01-01' }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'gt' });
expect(getRuleSql(where).right).toBeInstanceOf(Date);
});
it('between produces and(gte, lte) with parsed dates', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'addedAt', operator: 'between', value: '2023-01-01', valueTo: '2023-12-31' }) as never,
BASE_CTX,
) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'and' });
expect(clause.clauses[0]).toMatchObject({ type: 'gte' });
expect(clause.clauses[1]).toMatchObject({ type: 'lte' });
});
it('withinLast produces a sql interval expression', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'withinLast', value: 30 }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql' });
});
it('withinLast with zero days is valid (produces sql expression)', () => {
const { builder } = makeBuilder();
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'addedAt', operator: 'withinLast', value: 0 }) as never, BASE_CTX)).not.toThrow();
});
it('between with invalid second date throws', () => {
const { builder } = makeBuilder();
expect(() =>
builder.buildWhere(
wrapRule({ type: 'rule', field: 'addedAt', operator: 'between', value: '2023-01-01', valueTo: 'not-a-date' }) as never,
BASE_CTX,
),
).toThrow(BadRequestException);
});
});
describe('statusRuleToSql (fileAvailability)', () => {
it('isMissing produces eq(books.status, "missing")', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'fileAvailability', operator: 'isMissing' }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'eq', right: 'missing' });
});
it('isPresent produces eq(books.status, "present")', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'fileAvailability', operator: 'isPresent' }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'eq', right: 'present' });
});
});
describe('readProgressRuleToSql', () => {
it('isUnread produces not(inArray(books.id, subquery))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'readProgress', operator: 'isUnread' }) as never, USER_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'not', value: { type: 'inArray' } });
});
it('isInProgress produces inArray(books.id, subquery with percentage between 0 and 100)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'readProgress', operator: 'isInProgress' }) as never, USER_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'inArray' });
expect(clause.right).toMatchObject({ type: 'subquery' });
expect(clause.right.whereClause).toMatchObject({ type: 'and' });
expect(clause.right.whereClause.clauses).toHaveLength(3);
});
it('isFinished produces inArray(books.id, subquery with percentage >= 100)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'readProgress', operator: 'isFinished' }) as never, USER_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'inArray' });
expect(clause.right).toMatchObject({ type: 'subquery' });
expect(clause.right.whereClause).toMatchObject({ type: 'and' });
expect(clause.right.whereClause.clauses).toHaveLength(2);
expect(clause.right.whereClause.clauses[1]).toMatchObject({ type: 'gte' });
});
});
describe('isbnRuleToSql', () => {
it('isEmpty produces and(isNull(isbn13), isNull(isbn10))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'isbn', operator: 'isEmpty' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'and' });
expect(clause.clauses[0]).toMatchObject({ type: 'isNull' });
expect(clause.clauses[1]).toMatchObject({ type: 'isNull' });
});
it('isNotEmpty produces or(isNotNull(isbn13), isNotNull(isbn10))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'isbn', operator: 'isNotEmpty' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'or' });
expect(clause.clauses[0]).toMatchObject({ type: 'isNotNull' });
expect(clause.clauses[1]).toMatchObject({ type: 'isNotNull' });
});
it('eq produces or(eq(isbn13, val), eq(isbn10, val))', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'isbn', operator: 'eq', value: '9780141187761' }) as never, BASE_CTX) as any;
const clause = getRuleSql(where);
expect(clause).toMatchObject({ type: 'or' });
expect(clause.clauses[0]).toMatchObject({ type: 'eq', right: '9780141187761' });
expect(clause.clauses[1]).toMatchObject({ type: 'eq', right: '9780141187761' });
});
it('eq with missing value throws BadRequestException', () => {
const { builder } = makeBuilder();
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'isbn', operator: 'eq' }) as never, BASE_CTX)).toThrow(BadRequestException);
});
});
@@ -1,7 +1,56 @@
import { BadRequestException } from '@nestjs/common';
import { FIELD_OPERATORS, RULE_OPERATORS, type RuleField, type RuleOperator } from '@projectx/types';
import { validateGroupRule, groupRuleSchema } from './group-rule.validator';
/**
* Returns a minimal { value, valueTo } pair that satisfies the Zod schema for a
* given operator. The validator only checks operator-field compatibility and
* value type (string | number | string[] | number[]), not field-level semantics,
* so any structurally valid value is sufficient here.
*
* The exhaustive `never` default ensures TypeScript raises a compile error when a
* new operator is added to RuleOperator but not handled in this helper.
*/
function validRuleValue(operator: RuleOperator): { value?: unknown; valueTo?: unknown } {
switch (operator) {
case 'isEmpty':
case 'isNotEmpty':
case 'isMissing':
case 'isPresent':
case 'isUnread':
case 'isInProgress':
case 'isFinished':
return {};
case 'contains':
case 'notContains':
case 'startsWith':
case 'endsWith':
case 'eq':
case 'notEq':
case 'before':
case 'after':
return { value: 'test' };
case 'gt':
case 'gte':
case 'lt':
case 'lte':
case 'withinLast':
return { value: 1 };
case 'between':
return { value: 1, valueTo: 2 };
case 'includesAny':
case 'includesAll':
case 'excludesAll':
return { value: ['test'] };
default: {
const _exhaustive: never = operator;
return _exhaustive;
}
}
}
describe('validateGroupRule', () => {
it('returns null for null input', () => {
expect(validateGroupRule(null)).toBeNull();
@@ -191,3 +240,27 @@ describe('groupRuleSchema depth enforcement', () => {
expect(result.success).toBe(false);
});
});
describe('field × operator exhaustive validation', () => {
it.each(Object.entries(FIELD_OPERATORS) as [RuleField, RuleOperator[]][])('accepts all valid operators for field: %s', (field, operators) => {
for (const operator of operators) {
const { value, valueTo } = validRuleValue(operator);
const rule: Record<string, unknown> = { type: 'rule', field, operator };
if (value !== undefined) rule.value = value;
if (valueTo !== undefined) rule.valueTo = valueTo;
expect(
() => validateGroupRule({ type: 'group', join: 'AND', rules: [rule] }),
`field '${field}' should accept operator '${operator}'`,
).not.toThrow();
}
});
it.each(Object.entries(FIELD_OPERATORS) as [RuleField, RuleOperator[]][])('rejects a disallowed operator for field: %s', (field, operators) => {
const disallowedOp = RULE_OPERATORS.find((op) => !operators.includes(op));
if (!disallowedOp) return;
const rule = { type: 'rule', field, operator: disallowedOp, value: 'test' };
expect(() => validateGroupRule({ type: 'group', join: 'AND', rules: [rule] })).toThrow(BadRequestException);
});
});