mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
b50b8da56c
Register book-metadata-provider services by address, with no code change per provider; Settings lists ready-made ones to install. Ranking prefers a record of the book's own medium. LubimyCzytac moves out to its own provider and Aladin, whose API closes, is retired; their ids stay. Adds migrations 0129 and 0130. See docs/METADATA_PROVIDERS.md. Ref #1405
612 lines
22 KiB
TypeScript
612 lines
22 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
||
|
||
import {
|
||
COMMUNITY_RATING_PROVIDER_KEYS,
|
||
CUSTOM_FIELD_TYPE_OPERATORS,
|
||
FIELD_OPERATORS,
|
||
RULE_OPERATORS,
|
||
customRuleField,
|
||
type CustomMetadataFieldType,
|
||
type RuleField,
|
||
type RuleOperator,
|
||
} from '@bookorbit/types';
|
||
|
||
import { validateGroupRule, groupRuleSchema } from './group-rule.validator';
|
||
|
||
/**
|
||
* Returns a minimal { value, valueTo } pair that satisfies the Zod schema for a
|
||
* given field/operator pair. The validator checks operator-field compatibility,
|
||
* value type (string | number | string[] | number[]), and - for date fields with
|
||
* before/after/between - that the value(s) parse to a real date, so date fields
|
||
* need real date strings here while everything else can use structurally valid stubs.
|
||
*
|
||
* The exhaustive `never` default ensures TypeScript raises a compile error when a
|
||
* new operator is added to RuleOperator but not handled in this helper.
|
||
*/
|
||
const DATE_FIELDS: RuleField[] = ['addedAt', 'startedAt', 'finishedAt', 'publishedDate'];
|
||
|
||
function validRuleValue(operator: RuleOperator, field: RuleField): { value?: unknown; valueTo?: unknown } {
|
||
switch (operator) {
|
||
case 'isEmpty':
|
||
case 'isNotEmpty':
|
||
case 'isMissing':
|
||
case 'isPresent':
|
||
case 'isUnread':
|
||
case 'isInProgress':
|
||
case 'isFinished':
|
||
case 'isLocked':
|
||
case 'isUnlocked':
|
||
case 'isUpNext':
|
||
case 'isTrue':
|
||
case 'isFalse':
|
||
return {};
|
||
case 'before':
|
||
case 'after':
|
||
return { value: '2024-01-01' };
|
||
case 'contains':
|
||
case 'notContains':
|
||
case 'startsWith':
|
||
case 'endsWith':
|
||
case 'eq':
|
||
case 'notEq':
|
||
return { value: 'test' };
|
||
case 'gt':
|
||
case 'gte':
|
||
case 'lt':
|
||
case 'lte':
|
||
case 'withinLast':
|
||
case 'moreThanAgo':
|
||
return { value: 1 };
|
||
case 'between':
|
||
return DATE_FIELDS.includes(field) ? { value: '2024-01-01', valueTo: '2024-06-01' } : { value: 1, valueTo: 2 };
|
||
case 'includesAny':
|
||
case 'includesAll':
|
||
case 'excludesAll':
|
||
return { value: ['test'] };
|
||
default: {
|
||
const _exhaustive: never = operator;
|
||
return _exhaustive;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The value a client sends for a custom field of the given type. */
|
||
function validCustomRuleValue(operator: RuleOperator, type: CustomMetadataFieldType): { value?: unknown; valueTo?: unknown } {
|
||
if (operator === 'isEmpty' || operator === 'isNotEmpty' || type === 'boolean') return {};
|
||
switch (type) {
|
||
case 'number':
|
||
return operator === 'between' ? { value: 1, valueTo: 5 } : { value: 1 };
|
||
case 'date':
|
||
if (operator === 'between') return { value: '2024-01-01', valueTo: '2024-06-01' };
|
||
return operator === 'withinLast' || operator === 'moreThanAgo' ? { value: 7 } : { value: '2024-01-01' };
|
||
case 'text':
|
||
case 'url':
|
||
return { value: 'test' };
|
||
}
|
||
}
|
||
|
||
describe('validateGroupRule', () => {
|
||
it('returns null for null input', () => {
|
||
expect(validateGroupRule(null)).toBeNull();
|
||
});
|
||
|
||
it('returns null for undefined input', () => {
|
||
expect(validateGroupRule(undefined)).toBeNull();
|
||
});
|
||
|
||
it('accepts a valid simple group rule', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }],
|
||
};
|
||
|
||
const result = validateGroupRule(rule);
|
||
expect(result).toEqual(rule);
|
||
});
|
||
|
||
it('accepts valid OR join', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'OR',
|
||
rules: [{ type: 'rule', field: 'author', operator: 'includesAny', value: ['Frank Herbert'] }],
|
||
};
|
||
expect(validateGroupRule(rule)).toEqual(rule);
|
||
});
|
||
|
||
it('throws BadRequestException for invalid field', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'nonexistentField', operator: 'contains', value: 'x' }],
|
||
};
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('throws BadRequestException when operator is not valid for the field', () => {
|
||
// 'author' does not support 'contains'
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'author', operator: 'contains', value: 'Frank' }],
|
||
};
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('accepts nested groups up to max depth 5', () => {
|
||
const deepRule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [
|
||
{
|
||
type: 'group',
|
||
join: 'OR',
|
||
rules: [
|
||
{
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [
|
||
{
|
||
type: 'group',
|
||
join: 'OR',
|
||
rules: [
|
||
{
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'title', operator: 'eq', value: 'deep' }],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
expect(() => validateGroupRule(deepRule)).not.toThrow();
|
||
});
|
||
|
||
it('throws BadRequestException for empty rules array', () => {
|
||
const rule = { type: 'group', join: 'AND', rules: [] };
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('throws BadRequestException for missing join field', () => {
|
||
const rule = { type: 'group', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'x' }] };
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('accepts numeric value for numeric fields', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'publishedYear', operator: 'gt', value: 2000 }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('accepts between operator with valueTo', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'publishedYear', operator: 'between', value: 2000, valueTo: 2020 }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('accepts a community rating rule with an explicit provider', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4.5, provider: COMMUNITY_RATING_PROVIDER_KEYS[0] }],
|
||
};
|
||
|
||
expect(validateGroupRule(rule)).toEqual(rule);
|
||
});
|
||
|
||
it('accepts a community rating rule with any provider', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4.5, provider: 'any' }],
|
||
};
|
||
|
||
expect(validateGroupRule(rule)).toEqual(rule);
|
||
});
|
||
|
||
it('accepts a community rating count rule with an explicit provider', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: COMMUNITY_RATING_PROVIDER_KEYS[0] }],
|
||
};
|
||
|
||
expect(validateGroupRule(rule)).toEqual(rule);
|
||
});
|
||
|
||
it('accepts a community rating rule without provider for backwards compatibility', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4.5 }],
|
||
};
|
||
|
||
expect(validateGroupRule(rule)).toEqual(rule);
|
||
});
|
||
|
||
it('rejects provider on non-community-rating rules', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'rating', operator: 'gte', value: 4, provider: 'amazon' }],
|
||
};
|
||
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('rejects a rating provider that cannot be a provider key', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4.5, provider: 'Not A Provider' }],
|
||
};
|
||
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it("accepts an external provider's ratings on rating and count rules", () => {
|
||
for (const field of ['communityRating', 'communityRatingCount']) {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'gte', value: 4, provider: 'fantlab' }] };
|
||
expect(() => validateGroupRule(rule)).not.toThrow();
|
||
}
|
||
});
|
||
|
||
it('accepts array values for includesAny operators', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'genre', operator: 'includesAny', value: ['Fiction', 'Sci-Fi'] }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('throws BadRequestException for non-object input', () => {
|
||
expect(() => validateGroupRule('invalid')).toThrow(BadRequestException);
|
||
expect(() => validateGroupRule(42)).toThrow(BadRequestException);
|
||
expect(() => validateGroupRule([])).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('accepts isEmpty operator with no value', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'title', operator: 'isEmpty' }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('throws BadRequestException for fileAvailability with invalid operator', () => {
|
||
// fileAvailability only supports isMissing and isPresent
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'fileAvailability', operator: 'contains', value: 'x' }],
|
||
};
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('accepts readProgress with isUnread operator', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'readProgress', operator: 'isUnread' }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('throws BadRequestException for array with more than 20 items', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'author', operator: 'includesAny', value: Array(21).fill('Author') }],
|
||
};
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('accepts cover with isMissing operator', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'cover', operator: 'isMissing' }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('accepts cover with isPresent operator', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'cover', operator: 'isPresent' }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('throws BadRequestException for cover with invalid operator', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'cover', operator: 'contains', value: 'x' }],
|
||
};
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
});
|
||
|
||
describe('date field value validation', () => {
|
||
const DATE_FIELDS_UNDER_TEST: RuleField[] = ['addedAt', 'startedAt', 'finishedAt', 'publishedDate'];
|
||
|
||
it.each(DATE_FIELDS_UNDER_TEST)("rejects a 2-digit year for '%s' before/after/between (issue #787 regression)", (field) => {
|
||
const before = { type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'before', value: '21-12-31' }] };
|
||
const after = { type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'after', value: '21-12-31' }] };
|
||
const between = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field, operator: 'between', value: '2023-01-01', valueTo: '21-12-31' }],
|
||
};
|
||
|
||
expect(() => validateGroupRule(before)).toThrow(BadRequestException);
|
||
expect(() => validateGroupRule(after)).toThrow(BadRequestException);
|
||
expect(() => validateGroupRule(between)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it.each(DATE_FIELDS_UNDER_TEST)("rejects a non-date string for '%s' with before", (field) => {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'before', value: 'not-a-date' }] };
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it.each(DATE_FIELDS_UNDER_TEST)("rejects a missing value for '%s' with after", (field) => {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'after' }] };
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it.each(DATE_FIELDS_UNDER_TEST)("rejects a valid value with a malformed valueTo for '%s' between", (field) => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field, operator: 'between', value: '2023-01-01', valueTo: 'nope' }],
|
||
};
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it.each(DATE_FIELDS_UNDER_TEST)("accepts a well-formed YYYY-MM-DD value for '%s' before/after/between", (field) => {
|
||
const before = { type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'before', value: '2024-01-01' }] };
|
||
const after = { type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'after', value: '2024-01-01' }] };
|
||
const between = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field, operator: 'between', value: '2023-01-01', valueTo: '2024-01-01' }],
|
||
};
|
||
|
||
expect(() => validateGroupRule(before)).not.toThrow();
|
||
expect(() => validateGroupRule(after)).not.toThrow();
|
||
expect(() => validateGroupRule(between)).not.toThrow();
|
||
});
|
||
|
||
it.each(DATE_FIELDS_UNDER_TEST)("accepts a zero-padded year below 1000 for '%s'", (field) => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field, operator: 'before', value: '0021-12-31' }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it.each(DATE_FIELDS_UNDER_TEST)("accepts a valid ISO timestamp for '%s'", (field) => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field, operator: 'before', value: '2026-01-01T02:30:00.000Z' }],
|
||
};
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('rejects a calendar-invalid date like 2024-02-30 even though the regex shape matches', () => {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'finishedAt', operator: 'before', value: '2024-02-30' }] };
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('rejects an ISO timestamp whose calendar date is invalid', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'finishedAt', operator: 'before', value: '2024-02-30T00:00:00.000Z' }],
|
||
};
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('rejects an ISO timestamp whose time is invalid', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'finishedAt', operator: 'before', value: '2024-02-29T24:00:00.000Z' }],
|
||
};
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('does not require date-shaped values for withinLast (a day count, not a date)', () => {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'finishedAt', operator: 'withinLast', value: 30 }] };
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('accepts a non-negative day count for moreThanAgo', () => {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value: 30 }] };
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it.each([-1, 'not-a-number', undefined])('rejects an invalid moreThanAgo day count: %s', (value) => {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'finishedAt', operator: 'moreThanAgo', value }] };
|
||
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('validates moreThanAgo day counts for custom date fields', () => {
|
||
const valid = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: customRuleField(7), operator: 'moreThanAgo', value: 14 }] };
|
||
const invalid = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: customRuleField(7), operator: 'moreThanAgo', value: -1 }] };
|
||
|
||
expect(validateGroupRule(valid)).toBeDefined();
|
||
expect(() => validateGroupRule(invalid)).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('does not apply date validation to non-date fields sharing the between operator', () => {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'pageCount', operator: 'between', value: 100, valueTo: 300 }] };
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
|
||
it('accepts a finite epoch value supported by the query builder', () => {
|
||
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'addedAt', operator: 'before', value: Date.UTC(2024, 0, 1) }] };
|
||
expect(validateGroupRule(rule)).toBeDefined();
|
||
});
|
||
});
|
||
|
||
describe('groupRuleSchema depth enforcement', () => {
|
||
it('groups at depth 5 are valid', () => {
|
||
const schema = groupRuleSchema(5);
|
||
const result = schema.safeParse({
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'x' }],
|
||
});
|
||
expect(result.success).toBe(true);
|
||
});
|
||
|
||
it('at maxDepth 1, child groups are rejected', () => {
|
||
const schema = groupRuleSchema(1);
|
||
const result = schema.safeParse({
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [
|
||
{
|
||
type: 'group',
|
||
join: 'OR',
|
||
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'x' }],
|
||
},
|
||
],
|
||
});
|
||
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, field);
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe('custom metadata field rules', () => {
|
||
function validate(rule: Record<string, unknown>) {
|
||
return validateGroupRule({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: customRuleField(7), ...rule }] });
|
||
}
|
||
|
||
it.each(Object.entries(CUSTOM_FIELD_TYPE_OPERATORS) as [CustomMetadataFieldType, RuleOperator[]][])(
|
||
'accepts every operator a custom %s field offers',
|
||
(type, operators) => {
|
||
for (const operator of operators) {
|
||
const { value, valueTo } = validCustomRuleValue(operator, type);
|
||
const rule: Record<string, unknown> = { operator };
|
||
if (value !== undefined) rule.value = value;
|
||
if (valueTo !== undefined) rule.valueTo = valueTo;
|
||
|
||
expect(() => validate(rule), `custom ${type} field should accept operator '${operator}'`).not.toThrow();
|
||
}
|
||
},
|
||
);
|
||
|
||
it('round-trips a custom field rule unchanged', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [{ type: 'rule', field: 'custom:12', operator: 'contains', value: 'signed' }],
|
||
};
|
||
|
||
expect(validateGroupRule(rule)).toEqual(rule);
|
||
});
|
||
|
||
it('accepts custom field rules nested inside groups alongside built-in rules', () => {
|
||
const rule = {
|
||
type: 'group',
|
||
join: 'AND',
|
||
rules: [
|
||
{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' },
|
||
{ type: 'group', join: 'OR', rules: [{ type: 'rule', field: 'custom:3', operator: 'isTrue' }] },
|
||
],
|
||
};
|
||
|
||
expect(validateGroupRule(rule)).toEqual(rule);
|
||
});
|
||
|
||
it.each([
|
||
['custom:0', 'a zero id'],
|
||
['custom:', 'no id'],
|
||
['custom:abc', 'a non-numeric id'],
|
||
['custom:1234567890', 'an id wider than the field id column'],
|
||
['custom:1; DROP TABLE books', 'trailing SQL'],
|
||
['custom:1.5', 'a fractional id'],
|
||
['custom: 1', 'a leading space'],
|
||
])('rejects %s (%s)', (field) => {
|
||
expect(() => validateGroupRule({ type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'isNotEmpty' }] })).toThrow(
|
||
BadRequestException,
|
||
);
|
||
});
|
||
|
||
it('rejects operators no custom field type offers', () => {
|
||
expect(() => validate({ operator: 'includesAny', value: ['a'] })).toThrow(BadRequestException);
|
||
expect(() => validate({ operator: 'isUpNext' })).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('rejects list values, which would not resolve to a single typed column', () => {
|
||
expect(() => validate({ operator: 'eq', value: ['a', 'b'] })).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('rejects numeric comparisons whose value is not a number', () => {
|
||
for (const operator of ['gt', 'gte', 'lt', 'lte']) {
|
||
expect(() => validate({ operator, value: '12' }), `operator '${operator}' should require a number`).toThrow(BadRequestException);
|
||
}
|
||
});
|
||
|
||
it('rejects date comparisons whose value is not a date', () => {
|
||
expect(() => validate({ operator: 'before', value: 'yesterday' })).toThrow(BadRequestException);
|
||
expect(() => validate({ operator: 'after', value: '2024-13-45' })).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('accepts a between range whose bounds suit the same column', () => {
|
||
expect(() => validate({ operator: 'between', value: 1, valueTo: 5 })).not.toThrow();
|
||
expect(() => validate({ operator: 'between', value: '2024-01-01', valueTo: '2024-06-01' })).not.toThrow();
|
||
});
|
||
|
||
it('rejects a between range whose bounds would read different columns', () => {
|
||
expect(() => validate({ operator: 'between', value: 1, valueTo: '2024-06-01' })).toThrow(BadRequestException);
|
||
expect(() => validate({ operator: 'between', value: '2024-01-01', valueTo: 'not a date' })).toThrow(BadRequestException);
|
||
expect(() => validate({ operator: 'between', value: '2024-01-01' })).toThrow(BadRequestException);
|
||
});
|
||
|
||
it('rejects a provider on a custom field rule', () => {
|
||
expect(() => validate({ operator: 'contains', value: 'signed', provider: 'any' })).toThrow(BadRequestException);
|
||
});
|
||
});
|