diff --git a/src/common/app-platform.ts b/src/common/app-platform.ts new file mode 100644 index 0000000..3e2f3e7 --- /dev/null +++ b/src/common/app-platform.ts @@ -0,0 +1,4 @@ +import {z} from 'zod'; + +export const AppPlatform = z.enum(['ios', 'android']); +export type AppPlatform = z.infer; diff --git a/src/common/language-and-text.ts b/src/common/language-and-text.ts new file mode 100644 index 0000000..95e675b --- /dev/null +++ b/src/common/language-and-text.ts @@ -0,0 +1,14 @@ +import {z} from 'zod'; + +export const LanguageAndTextSchema = z.union([ + z.object({ + lang: z.string(), + value: z.string(), + }), + z.object({ + language: z.string().optional(), + value: z.string().optional(), + }), +]); + +export type LanguageAndTextType = z.infer; diff --git a/src/global-messages/index.ts b/src/global-messages/index.ts new file mode 100644 index 0000000..bf7daf2 --- /dev/null +++ b/src/global-messages/index.ts @@ -0,0 +1 @@ +export {createGlobalMessageSchema, GenericGlobalMessageType} from './types'; diff --git a/src/global-messages/types.ts b/src/global-messages/types.ts new file mode 100644 index 0000000..8239c00 --- /dev/null +++ b/src/global-messages/types.ts @@ -0,0 +1,43 @@ +import {z} from 'zod'; +import {LanguageAndTextSchema} from '../common/language-and-text'; +import {Rule} from '../rules/types'; +import {AppPlatform} from '../common/app-platform'; + +/** + * Create a global message schema with a consumer specific context enum. + * + * @example + * export enum GlobalMessageContextEnum { + * webOverview = 'web-overview', + * } + * const GlobalMessageContextSchema = z.enum(GlobalMessageContextEnum); + * export const GlobalMessageSchema = createGlobalMessageSchema( + * GlobalMessageContextSchema, + * ); + */ +export function createGlobalMessageSchema( + contextEnum: ContextEnum, +) { + return z.object({ + id: z.string(), + active: z.boolean(), + title: z.array(LanguageAndTextSchema).optional(), + body: z.array(LanguageAndTextSchema), + link: z.array(LanguageAndTextSchema).optional(), + linkText: z.array(LanguageAndTextSchema).optional(), + type: z.enum(['error', 'valid', 'info', 'warning']), + subtle: z.boolean().optional(), + context: z.array(contextEnum), + isDismissable: z.boolean().optional(), + appPlatforms: z.array(AppPlatform).optional(), + appVersionMin: z.string().optional(), + appVersionMax: z.string().optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + rules: z.array(Rule).optional(), + }); +} + +export type GenericGlobalMessageType = z.infer< + ReturnType> +>; diff --git a/src/index.ts b/src/index.ts index 3f8756c..3a3cb5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ export * from './fare-contract'; export * from './offers/ticket-offer'; +export * from './rules'; +export * from './global-messages'; +export * from './common/app-platform'; export {ErrorResponse, HttpError} from './error-response'; export {BookingAvailabilityType} from './offers/booking'; export {formatNumberToString} from './utils'; diff --git a/src/rules/__tests__/rule-grouping.test.ts b/src/rules/__tests__/rule-grouping.test.ts new file mode 100644 index 0000000..d0cd250 --- /dev/null +++ b/src/rules/__tests__/rule-grouping.test.ts @@ -0,0 +1,83 @@ +import {checkRules} from '../check-rules'; +import {Rule, RuleOperator, RuleVariables} from '../types'; + +const createRule = ( + variable: string, + operator: RuleOperator, + value: Rule['value'], + groupId?: string, +): Rule => ({variable, operator, value, groupId}); + +describe('checkRules without groups', () => { + it('returns true when there are no rules', () => { + const localVariables: RuleVariables = {}; + expect(checkRules([], localVariables)).toEqual(true); + }); + + it('returns true when all ungrouped rules pass', () => { + const localVariables: RuleVariables = {name: 'foo', age: 10}; + const rules = [ + createRule('name', RuleOperator.equalTo, 'foo'), + createRule('age', RuleOperator.greaterThan, 5), + ]; + expect(checkRules(rules, localVariables)).toEqual(true); + }); + + it('returns false when one ungrouped rule fails', () => { + const localVariables: RuleVariables = {name: 'foo', age: 10}; + const rules = [ + createRule('name', RuleOperator.equalTo, 'foo'), + createRule('age', RuleOperator.lessThan, 5), + ]; + expect(checkRules(rules, localVariables)).toEqual(false); + }); + + it('returns false when all ungrouped rules fail', () => { + const localVariables: RuleVariables = {name: 'foo'}; + const rules = [createRule('name', RuleOperator.equalTo, 'not-foo')]; + expect(checkRules(rules, localVariables)).toEqual(false); + }); +}); + +describe('checkRules with a single group', () => { + it('returns true when one rule in the group passes', () => { + const localVariables: RuleVariables = {name: 'foo'}; + const rules = [ + createRule('name', RuleOperator.equalTo, 'foo', 'group1'), + createRule('name', RuleOperator.equalTo, 'bar', 'group1'), + ]; + expect(checkRules(rules, localVariables)).toEqual(true); + }); + + it('returns false when no rule in the group passes', () => { + const localVariables: RuleVariables = {name: 'foo'}; + const rules = [ + createRule('name', RuleOperator.equalTo, 'bar', 'group1'), + createRule('name', RuleOperator.equalTo, 'baz', 'group1'), + ]; + expect(checkRules(rules, localVariables)).toEqual(false); + }); +}); + +describe('checkRules with multiple groups', () => { + it('returns true when each group has at least one passing rule', () => { + const localVariables: RuleVariables = {name: 'foo', age: 10}; + const rules = [ + createRule('name', RuleOperator.equalTo, 'foo', 'group1'), + createRule('name', RuleOperator.equalTo, 'bar', 'group1'), + createRule('age', RuleOperator.lessThan, 5, 'group2'), + createRule('age', RuleOperator.greaterThan, 5, 'group2'), + ]; + expect(checkRules(rules, localVariables)).toEqual(true); + }); + + it('returns false when one group has no passing rule', () => { + const localVariables: RuleVariables = {name: 'foo', age: 10}; + const rules = [ + createRule('name', RuleOperator.equalTo, 'foo', 'group1'), + createRule('age', RuleOperator.lessThan, 5, 'group2'), + createRule('age', RuleOperator.equalTo, 999, 'group2'), + ]; + expect(checkRules(rules, localVariables)).toEqual(false); + }); +}); diff --git a/src/rules/__tests__/rule-operators.test.ts b/src/rules/__tests__/rule-operators.test.ts new file mode 100644 index 0000000..e47669c --- /dev/null +++ b/src/rules/__tests__/rule-operators.test.ts @@ -0,0 +1,194 @@ +import {checkRule} from '../check-rules'; +import {Rule, RuleOperator, RuleVariables} from '../types'; + +const createRule = ( + variable: string, + operator: RuleOperator, + value: Rule['value'], +): Rule => ({variable, operator, value}); + +describe('RuleOperator.equalTo', () => { + it('returns true when string values are equal', () => { + const localVariables: RuleVariables = {name: 'foo'}; + const rule = createRule('name', RuleOperator.equalTo, 'foo'); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns true when number values are equal', () => { + const localVariables: RuleVariables = {age: 5}; + const rule = createRule('age', RuleOperator.equalTo, 5); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns false when values are not equal', () => { + const localVariables: RuleVariables = {name: 'foo'}; + const rule = createRule('name', RuleOperator.equalTo, 'not-foo'); + expect(checkRule(rule, localVariables)).toEqual(false); + }); + + it('returns false when number values are not equal', () => { + const localVariables: RuleVariables = {age: 5}; + const rule = createRule('age', RuleOperator.equalTo, 10); + expect(checkRule(rule, localVariables)).toEqual(false); + }); +}); + +describe('RuleOperator.notEqualTo', () => { + it('returns true when values are not equal', () => { + const localVariables: RuleVariables = {name: 'foo'}; + const rule = createRule('name', RuleOperator.notEqualTo, 'not-foo'); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns false when values are equal', () => { + const localVariables: RuleVariables = {age: 5}; + const rule = createRule('age', RuleOperator.notEqualTo, 5); + expect(checkRule(rule, localVariables)).toEqual(false); + }); +}); + +describe('RuleOperator.greaterThan', () => { + it('returns true when local number is greater than rule number', () => { + const localVariables: RuleVariables = {age: 10}; + const rule = createRule('age', RuleOperator.greaterThan, 5); + expect(checkRule(rule, localVariables)).toEqual(true); + }); +}); + +describe('RuleOperator.lessThan', () => { + it('returns true when local number is less than rule number', () => { + const localVariables: RuleVariables = {age: 5}; + const rule = createRule('age', RuleOperator.lessThan, 10); + expect(checkRule(rule, localVariables)).toEqual(true); + }); +}); + +describe('RuleOperator.greaterThanOrEqualTo', () => { + it('returns true when local number is greater than rule number', () => { + const localVariables: RuleVariables = {age: 10}; + const rule = createRule('age', RuleOperator.greaterThanOrEqualTo, 5); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns true when local number equals rule number', () => { + const localVariables: RuleVariables = {age: 5}; + const rule = createRule('age', RuleOperator.greaterThanOrEqualTo, 5); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns false when local number is less than rule number', () => { + const localVariables: RuleVariables = {age: 3}; + const rule = createRule('age', RuleOperator.greaterThanOrEqualTo, 5); + expect(checkRule(rule, localVariables)).toEqual(false); + }); + + it('returns false when values are not comparable types', () => { + const localVariables: RuleVariables = {isActive: true}; + const rule = createRule( + 'isActive', + RuleOperator.greaterThanOrEqualTo, + true, + ); + expect(checkRule(rule, localVariables)).toEqual(false); + }); +}); + +describe('RuleOperator.lessThanOrEqualTo', () => { + it('returns true when local number is less than rule number', () => { + const localVariables: RuleVariables = {age: 3}; + const rule = createRule('age', RuleOperator.lessThanOrEqualTo, 5); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns true when local number equals rule number', () => { + const localVariables: RuleVariables = {age: 5}; + const rule = createRule('age', RuleOperator.lessThanOrEqualTo, 5); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns false when local number is greater than rule number', () => { + const localVariables: RuleVariables = {age: 10}; + const rule = createRule('age', RuleOperator.lessThanOrEqualTo, 5); + expect(checkRule(rule, localVariables)).toEqual(false); + }); + + it('returns false when values are not comparable types', () => { + const localVariables: RuleVariables = {isActive: true}; + const rule = createRule('isActive', RuleOperator.lessThanOrEqualTo, true); + expect(checkRule(rule, localVariables)).toEqual(false); + }); +}); + +describe('RuleOperator.contains', () => { + it('returns true when local array contains the rule value', () => { + const localVariables: RuleVariables = {tags: ['a', 'b', 'c']}; + const rule = createRule('tags', RuleOperator.contains, 'b'); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns false when local array does not contain the rule value', () => { + const localVariables: RuleVariables = {tags: ['a', 'b', 'c']}; + const rule = createRule('tags', RuleOperator.contains, 'd'); + expect(checkRule(rule, localVariables)).toEqual(false); + }); + + it('returns false when local value is not an array', () => { + const localVariables: RuleVariables = {tags: 'a'}; + const rule = createRule('tags', RuleOperator.contains, 'a'); + expect(checkRule(rule, localVariables)).toEqual(false); + }); +}); + +describe('RuleOperator.notContains', () => { + it('returns true when local array does not contain the rule value', () => { + const localVariables: RuleVariables = {tags: ['a', 'b', 'c']}; + const rule = createRule('tags', RuleOperator.notContains, 'd'); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns false when local array contains the rule value', () => { + const localVariables: RuleVariables = {tags: ['a', 'b', 'c']}; + const rule = createRule('tags', RuleOperator.notContains, 'b'); + expect(checkRule(rule, localVariables)).toEqual(false); + }); + + it('returns false when local value is not an array', () => { + const localVariables: RuleVariables = {tags: 'a'}; + const rule = createRule('tags', RuleOperator.notContains, 'd'); + expect(checkRule(rule, localVariables)).toEqual(false); + }); +}); + +describe('RuleOperator.onlyContains', () => { + it('returns true when every element in the local array equals the rule value', () => { + const localVariables: RuleVariables = {tags: ['a', 'a', 'a']}; + const rule = createRule('tags', RuleOperator.onlyContains, 'a'); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns true when the local array is empty', () => { + const localVariables: RuleVariables = {tags: []}; + const rule = createRule('tags', RuleOperator.onlyContains, 'a'); + expect(checkRule(rule, localVariables)).toEqual(true); + }); + + it('returns false when some element in the local array does not equal the rule value', () => { + const localVariables: RuleVariables = {tags: ['a', 'b', 'a']}; + const rule = createRule('tags', RuleOperator.onlyContains, 'a'); + expect(checkRule(rule, localVariables)).toEqual(false); + }); + + it('returns false when local value is not an array', () => { + const localVariables: RuleVariables = {tags: 'a'}; + const rule = createRule('tags', RuleOperator.onlyContains, 'a'); + expect(checkRule(rule, localVariables)).toEqual(false); + }); +}); + +describe('checkRule variable resolution', () => { + it('returns false when the variable is not present in localVariables', () => { + const localVariables: RuleVariables = {name: 'foo'}; + const rule = createRule('missing', RuleOperator.equalTo, 'foo'); + expect(checkRule(rule, localVariables)).toEqual(false); + }); +}); diff --git a/src/rules/check-rules.ts b/src/rules/check-rules.ts new file mode 100644 index 0000000..fd835de --- /dev/null +++ b/src/rules/check-rules.ts @@ -0,0 +1,100 @@ +import {Rule, RuleOperator, RuleVariables} from './types'; + +export const checkRules = ( + rules: Rule[], + localVariables: RuleVariables, +): boolean => { + // Verify that all ungrouped rules pass + const ungroupedRules = rules.filter((rule) => rule.groupId === undefined); + const allOfUngrouped = ungroupedRules.every((rule) => + checkRule(rule, localVariables), + ); + if (!allOfUngrouped) return false; + + // Verify that groups pass one of their rules + const uniqueGroups = new Set(); + rules.forEach((rule) => { + if (rule.groupId) uniqueGroups.add(rule.groupId); + }); + for (const groupId of uniqueGroups) { + const groupRules = rules.filter((rule) => rule.groupId === groupId); + const oneOfRuleInGroup = groupRules.find((rule) => + checkRule(rule, localVariables), + ); + if (!oneOfRuleInGroup) return false; + } + return true; +}; + +export const checkRule = ( + globalMessageRule: Rule, + localVariables: RuleVariables, +): boolean => { + const { + operator, + value: ruleValue, + variable: variableName, + } = globalMessageRule; + if (!(variableName in localVariables)) return false; + const localValue = localVariables[variableName]; + switch (operator) { + case RuleOperator.equalTo: + if ( + ['string', 'number', 'boolean'].includes(typeof localValue) || + localValue === null + ) + return localValue === ruleValue; + return false; + case RuleOperator.notEqualTo: + if ( + ['string', 'number', 'boolean'].includes(typeof localValue) || + localValue === null + ) + return localValue !== ruleValue; + return false; + case RuleOperator.greaterThan: + if ( + ['string', 'number'].includes(typeof localValue) && + ['string', 'number'].includes(typeof ruleValue) + ) + return (localValue as string | number) > (ruleValue as string | number); + return false; + case RuleOperator.lessThan: + if ( + ['string', 'number'].includes(typeof localValue) && + ['string', 'number'].includes(typeof ruleValue) + ) + return (localValue as string | number) < (ruleValue as string | number); + return false; + case RuleOperator.greaterThanOrEqualTo: + if ( + ['string', 'number'].includes(typeof localValue) && + ['string', 'number'].includes(typeof ruleValue) + ) + return ( + (localValue as string | number) >= (ruleValue as string | number) + ); + return false; + case RuleOperator.lessThanOrEqualTo: + if ( + ['string', 'number'].includes(typeof localValue) && + ['string', 'number'].includes(typeof ruleValue) + ) + return ( + (localValue as string | number) <= (ruleValue as string | number) + ); + return false; + case RuleOperator.contains: + if (Array.isArray(localValue)) return localValue.includes(ruleValue); + return false; + case RuleOperator.notContains: + if (Array.isArray(localValue)) return !localValue.includes(ruleValue); + return false; + case RuleOperator.onlyContains: + if (Array.isArray(localValue)) + return localValue.every((v) => v === ruleValue); + return false; + default: + return false; + } +}; diff --git a/src/rules/index.ts b/src/rules/index.ts new file mode 100644 index 0000000..74a4328 --- /dev/null +++ b/src/rules/index.ts @@ -0,0 +1,2 @@ +export {Rule, RuleOperator, RuleVariables} from './types'; +export {checkRules} from './check-rules'; diff --git a/src/rules/types.ts b/src/rules/types.ts new file mode 100644 index 0000000..fe87e03 --- /dev/null +++ b/src/rules/types.ts @@ -0,0 +1,28 @@ +import {z} from 'zod'; + +const RuleValue = z.union([z.string(), z.number(), z.boolean(), z.null()]); +type RuleValue = z.infer; + +export type RuleVariables = { + [key: string]: RuleValue | RuleValue[]; +}; + +export enum RuleOperator { + equalTo = 'equalTo', + notEqualTo = 'notEqualTo', + greaterThan = 'greaterThan', + lessThan = 'lessThan', + greaterThanOrEqualTo = 'greaterThanOrEqualTo', + lessThanOrEqualTo = 'lessThanOrEqualTo', + contains = 'contains', + notContains = 'notContains', + onlyContains = 'onlyContains', +} + +export const Rule = z.object({ + variable: z.string().describe('key of RuleVariables'), // if passing down ruleVariables to the zod parsing, this can be enforced runtime with refine + operator: z.nativeEnum(RuleOperator), + value: RuleValue, + groupId: z.string().optional(), +}); +export type Rule = z.infer; diff --git a/tsconfig.json b/tsconfig.json index 135776d..ce8bde3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "rootDir": "src", "outDir": "lib", - "declaration": true + "declaration": true, + "types": ["jest"] }, "include": ["src"], "exclude": ["node_modules"]