From 97748818375e59923ec7776a78acd85ef58ad5dc Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:50:15 -0700 Subject: [PATCH 01/15] Init server-side search and filter. --- src/api/ruleData/dto/pagination.dto.ts | 29 ++++++++ src/api/ruleData/ruleData.controller.ts | 10 ++- src/api/ruleData/ruleData.service.ts | 73 ++++++++++++++++++-- src/api/scenarioData/scenarioData.service.ts | 14 +++- 4 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 src/api/ruleData/dto/pagination.dto.ts diff --git a/src/api/ruleData/dto/pagination.dto.ts b/src/api/ruleData/dto/pagination.dto.ts new file mode 100644 index 0000000..a3d0773 --- /dev/null +++ b/src/api/ruleData/dto/pagination.dto.ts @@ -0,0 +1,29 @@ +import { IsOptional, IsString, IsObject } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PaginationDto { + @IsOptional() + @IsString() + page?: number; + + @IsOptional() + @IsString() + pageSize?: number; + + @IsOptional() + @IsString() + sortField?: string; + + @IsOptional() + @IsString() + sortOrder?: 'ascend' | 'descend'; + + @IsOptional() + @IsObject() + @Type(() => Object) + filters?: Record; + + @IsOptional() + @IsString() + searchTerm?: string; +} diff --git a/src/api/ruleData/ruleData.controller.ts b/src/api/ruleData/ruleData.controller.ts index 402fe6b..1149605 100644 --- a/src/api/ruleData/ruleData.controller.ts +++ b/src/api/ruleData/ruleData.controller.ts @@ -1,16 +1,20 @@ -import { Controller, Get, Param, Post, Body, Put, Delete, HttpException, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Param, Post, Body, Put, Delete, HttpException, HttpStatus, Query } from '@nestjs/common'; import { RuleDataService } from './ruleData.service'; import { RuleData } from './ruleData.schema'; import { RuleDraft } from './ruleDraft.schema'; +import { PaginationDto } from './dto/pagination.dto'; @Controller('api/ruleData') export class RuleDataController { constructor(private readonly ruleDataService: RuleDataService) {} @Get('/list') - async getAllRulesData(): Promise { + async getAllRulesData( + @Query() query: PaginationDto, + ): Promise<{ data: RuleData[]; total: number; categories: Array }> { try { - return await this.ruleDataService.getAllRuleData(); + const { data, total, categories } = await this.ruleDataService.getAllRuleData(query); + return { data, total, categories }; } catch (error) { throw new HttpException('Error getting list of rule data', HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index 2229ad5..2ed8d6f 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -7,6 +7,7 @@ import { DocumentsService } from '../documents/documents.service'; import { RuleData, RuleDataDocument } from './ruleData.schema'; import { RuleDraft, RuleDraftDocument } from './ruleDraft.schema'; import { deriveNameFromFilepath } from '../../utils/helpers'; +import { PaginationDto } from './dto/pagination.dto'; @Injectable() export class RuleDataService { @@ -18,15 +19,73 @@ export class RuleDataService { async onModuleInit() { console.info('Syncing existing rules with any updates to the rules repository'); - const existingRules = await this.getAllRuleData(); - this.updateInReviewStatus(existingRules); - this.addUnsyncedFiles(existingRules); + const params: PaginationDto = { page: 1, pageSize: 5000 }; + const existingRules = await this.getAllRuleData(params); + const { data: existingRuleData } = existingRules; + this.updateInReviewStatus(existingRuleData); + this.addUnsyncedFiles(existingRuleData); } - - async getAllRuleData(): Promise { + async getAllRuleData(params: PaginationDto): Promise<{ data: RuleData[]; total: number; categories: Array }> { try { - const ruleDataList = await this.ruleDataModel.find().exec(); - return ruleDataList; + const { page, pageSize, sortField, sortOrder, filters, searchTerm } = params || {}; + const queryConditions: any[] = []; + + // search + if (searchTerm) { + queryConditions.push({ + $or: [{ title: { $regex: searchTerm, $options: 'i' } }, { filepath: { $regex: searchTerm, $options: 'i' } }], + }); + } + + // filters + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value) { + if (key === 'filepath') { + if (Array.isArray(value) && value.length > 0) { + queryConditions.push({ + $or: value.map((filter: string) => ({ + filepath: { $regex: filter, $options: 'i' }, + })), + }); + } + } else if (Array.isArray(value)) { + queryConditions.push({ [key]: { $in: value } }); + } else { + queryConditions.push({ [key]: value }); + } + } + }); + } + + // Construct the final query using $and + const query = queryConditions.length > 0 ? { $and: queryConditions } : {}; + + // Prepare sort options + const sortOptions: any = {}; + if (sortField && sortOrder) { + sortOptions[sortField] = sortOrder === 'ascend' ? 1 : -1; + } + + const filePaths = await this.ruleDataModel.find({}, 'filepath -_id').exec(); + const total = filePaths.length; + const filePathsArray = filePaths.map((filePath) => filePath.filepath); + const splitFilePaths = filePathsArray.map((filepath) => { + const parts = filepath.split('/'); + return parts.slice(0, -1); + }); + const categories = [...new Set(splitFilePaths.flat())]; + + // Execute the query with pagination and sorting + const data = await this.ruleDataModel + .find(query) + .sort(sortOptions) + .skip((page - 1) * pageSize) + .limit(pageSize) + .lean() + .exec(); + + return { data, total, categories }; } catch (error) { throw new Error(`Error getting all rule data: ${error.message}`); } diff --git a/src/api/scenarioData/scenarioData.service.ts b/src/api/scenarioData/scenarioData.service.ts index dcb3639..a7af917 100644 --- a/src/api/scenarioData/scenarioData.service.ts +++ b/src/api/scenarioData/scenarioData.service.ts @@ -355,7 +355,19 @@ export class ScenarioDataService { return arrayCombinations; } - return validationCriteria.split(',').map((val: string) => val.trim()); + if (validationType === '[=text]') { + return validationCriteria.split(',').map((val: string) => val.trim()); + } + // TODO: Future update to include regex generation + const generateRandomText = ( + count: number, + charPool = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + ) => Array.from({ length: count }, () => charPool.charAt(Math.floor(Math.random() * charPool.length))); + const textArray = Array.from({ length: complexityGeneration }, () => + generateRandomText(complexityGeneration).join(''), + ); + + return textArray; case 'true-false': const firstValue = Math.random() < 0.5; From 2b1e0e5aa3c1fe5f1c83992c68295278855cc741 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:22:40 -0700 Subject: [PATCH 02/15] Add server-side search to rule data. --- src/api/ruleData/dto/pagination.dto.ts | 7 ++ src/api/ruleData/ruleData.controller.spec.ts | 5 +- src/api/ruleData/ruleData.controller.ts | 6 +- src/api/ruleData/ruleData.service.spec.ts | 102 ++++++++++++++++-- src/api/ruleData/ruleData.service.ts | 43 +++++--- .../scenarioData/scenarioData.service.spec.ts | 12 +-- 6 files changed, 142 insertions(+), 33 deletions(-) diff --git a/src/api/ruleData/dto/pagination.dto.ts b/src/api/ruleData/dto/pagination.dto.ts index a3d0773..45f71d9 100644 --- a/src/api/ruleData/dto/pagination.dto.ts +++ b/src/api/ruleData/dto/pagination.dto.ts @@ -27,3 +27,10 @@ export class PaginationDto { @IsString() searchTerm?: string; } + +export class CategoryObject { + @IsObject() + @Type(() => Object) + text: string; + value: string; +} diff --git a/src/api/ruleData/ruleData.controller.spec.ts b/src/api/ruleData/ruleData.controller.spec.ts index 4da34ac..eaeb0d4 100644 --- a/src/api/ruleData/ruleData.controller.spec.ts +++ b/src/api/ruleData/ruleData.controller.spec.ts @@ -19,10 +19,11 @@ describe('RuleDataController', () => { }); it('should return all rules data', async () => { - const result: RuleData[] = [mockRuleData]; + const ruleDataResult: RuleData[] = [mockRuleData]; + const result = { data: ruleDataResult, total: 1, categories: [] }; jest.spyOn(service, 'getAllRuleData').mockImplementation(() => Promise.resolve(result)); - expect(await controller.getAllRulesData()).toBe(result); + expect(await controller.getAllRulesData()).toStrictEqual(result); }); it('should return a rule draft for a given ruleId', async () => { diff --git a/src/api/ruleData/ruleData.controller.ts b/src/api/ruleData/ruleData.controller.ts index 1149605..43894c6 100644 --- a/src/api/ruleData/ruleData.controller.ts +++ b/src/api/ruleData/ruleData.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, Param, Post, Body, Put, Delete, HttpException, HttpSta import { RuleDataService } from './ruleData.service'; import { RuleData } from './ruleData.schema'; import { RuleDraft } from './ruleDraft.schema'; -import { PaginationDto } from './dto/pagination.dto'; +import { CategoryObject, PaginationDto } from './dto/pagination.dto'; @Controller('api/ruleData') export class RuleDataController { @@ -10,8 +10,8 @@ export class RuleDataController { @Get('/list') async getAllRulesData( - @Query() query: PaginationDto, - ): Promise<{ data: RuleData[]; total: number; categories: Array }> { + @Query() query?: PaginationDto, + ): Promise<{ data: RuleData[]; total: number; categories: Array }> { try { const { data, total, categories } = await this.ruleDataService.getAllRuleData(query); return { data, total, categories }; diff --git a/src/api/ruleData/ruleData.service.spec.ts b/src/api/ruleData/ruleData.service.spec.ts index 2bfd2f1..c0a9a1a 100644 --- a/src/api/ruleData/ruleData.service.spec.ts +++ b/src/api/ruleData/ruleData.service.spec.ts @@ -17,10 +17,16 @@ const mockRuleDraft = { content: { nodes: [], edges: [] } }; class MockRuleDataModel { constructor(private data: RuleData) {} save = jest.fn().mockResolvedValue(this.data); - static find = jest.fn(); + static find = jest.fn().mockReturnThis(); static findById = jest.fn().mockReturnThis(); - static findOne = jest.fn(); - static findOneAndDelete = jest.fn(); + static findOne = jest.fn().mockReturnThis(); + static findOneAndDelete = jest.fn().mockReturnThis(); + static countDocuments = jest.fn().mockResolvedValue(1); + static sort = jest.fn().mockReturnThis(); + static skip = jest.fn().mockReturnThis(); + static limit = jest.fn().mockReturnThis(); + static lean = jest.fn().mockReturnThis(); + static exec = jest.fn().mockResolvedValue([mockRuleData]); } export const mockServiceProviders = [ @@ -58,10 +64,74 @@ describe('RuleDataService', () => { expect(service).toBeDefined(); }); - it('should get all rule data', async () => { - const ruleDataList: RuleData[] = [mockRuleData]; - MockRuleDataModel.find = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(ruleDataList) }); - expect(await service.getAllRuleData()).toEqual(ruleDataList); + describe('getAllRuleData', () => { + it('should get all rule data with default pagination', async () => { + const result = await service.getAllRuleData(); + expect(result).toEqual({ + data: [mockRuleData], + total: 1, + categories: expect.any(Array), + }); + expect(MockRuleDataModel.find).toHaveBeenCalledWith({}); + expect(MockRuleDataModel.sort).toHaveBeenCalledWith({}); + expect(MockRuleDataModel.skip).toHaveBeenCalledWith(0); + expect(MockRuleDataModel.limit).toHaveBeenCalledWith(5000); + }); + + it('should handle search term', async () => { + await service.getAllRuleData({ page: 1, pageSize: 10, searchTerm: 'test' }); + expect(MockRuleDataModel.find).toHaveBeenCalledWith({ + $and: [ + { + $or: [{ title: { $regex: 'test', $options: 'i' } }, { filepath: { $regex: 'test', $options: 'i' } }], + }, + ], + }); + }); + + it('should handle filters', async () => { + await service.getAllRuleData({ + page: 1, + pageSize: 10, + filters: { filepath: ['category'] }, + }); + expect(MockRuleDataModel.find).toHaveBeenCalledWith({ + $and: [ + { + $or: [ + { + filepath: { + $regex: new RegExp('(^category/|/category/)', 'i'), + }, + }, + ], + }, + ], + }); + }); + + it('should handle sorting', async () => { + await service.getAllRuleData({ + page: 1, + pageSize: 10, + sortField: 'title', + sortOrder: 'descend', + }); + expect(MockRuleDataModel.sort).toHaveBeenCalledWith({ title: -1 }); + }); + + it('should handle pagination', async () => { + await service.getAllRuleData({ page: 2, pageSize: 20 }); + expect(MockRuleDataModel.skip).toHaveBeenCalledWith(20); + expect(MockRuleDataModel.limit).toHaveBeenCalledWith(20); + }); + + it('should handle errors', async () => { + MockRuleDataModel.find.mockImplementationOnce(() => { + throw new Error('Test error'); + }); + await expect(service.getAllRuleData()).rejects.toThrow('Error getting all rule data: Test error'); + }); }); it('should return a rule draft for a given ruleId', async () => { @@ -154,4 +224,22 @@ describe('RuleDataService', () => { expect(service.createRuleData).toHaveBeenCalledTimes(1); expect(service.updateRuleData).toHaveBeenCalledTimes(1); }); + describe('onModuleInit', () => { + it('should initialize categories, update review status, and add unsynced files', async () => { + const mockGetAllRuleData = jest.spyOn(service, 'getAllRuleData').mockResolvedValue({ + data: [mockRuleData], + total: 1, + categories: [], + }); + const mockUpdateInReviewStatus = jest.spyOn(service, 'updateInReviewStatus').mockResolvedValue(); + const mockAddUnsyncedFiles = jest.spyOn(service, 'addUnsyncedFiles').mockResolvedValue(); + + await service.onModuleInit(); + + expect(mockGetAllRuleData).toHaveBeenCalled(); + expect(mockUpdateInReviewStatus).toHaveBeenCalledWith([mockRuleData]); + expect(mockAddUnsyncedFiles).toHaveBeenCalledWith([mockRuleData]); + expect(service['categories']).toEqual([]); + }); + }); }); diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index 2ed8d6f..eb64c8d 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -7,25 +7,38 @@ import { DocumentsService } from '../documents/documents.service'; import { RuleData, RuleDataDocument } from './ruleData.schema'; import { RuleDraft, RuleDraftDocument } from './ruleDraft.schema'; import { deriveNameFromFilepath } from '../../utils/helpers'; -import { PaginationDto } from './dto/pagination.dto'; +import { CategoryObject, PaginationDto } from './dto/pagination.dto'; @Injectable() export class RuleDataService { + private categories: Array = []; constructor( @InjectModel(RuleData.name) private ruleDataModel: Model, @InjectModel(RuleDraft.name) private ruleDraftModel: Model, private documentsService: DocumentsService, ) {} + private updateCategories(ruleData: RuleData[]) { + const filePathsArray = ruleData.map((filePath) => filePath.filepath); + const splitFilePaths = filePathsArray.map((filepath) => { + const parts = filepath.split('/'); + return parts.slice(0, -1); + }); + const categorySet = [...new Set(splitFilePaths.flat())]; + this.categories = categorySet.map((category: string) => ({ text: category, value: category })); + } + async onModuleInit() { console.info('Syncing existing rules with any updates to the rules repository'); - const params: PaginationDto = { page: 1, pageSize: 5000 }; - const existingRules = await this.getAllRuleData(params); + const existingRules = await this.getAllRuleData(); const { data: existingRuleData } = existingRules; + this.updateCategories(existingRuleData); this.updateInReviewStatus(existingRuleData); this.addUnsyncedFiles(existingRuleData); } - async getAllRuleData(params: PaginationDto): Promise<{ data: RuleData[]; total: number; categories: Array }> { + async getAllRuleData( + params: PaginationDto = { page: 1, pageSize: 5000 }, + ): Promise<{ data: RuleData[]; total: number; categories: Array }> { try { const { page, pageSize, sortField, sortOrder, filters, searchTerm } = params || {}; const queryConditions: any[] = []; @@ -45,7 +58,9 @@ export class RuleDataService { if (Array.isArray(value) && value.length > 0) { queryConditions.push({ $or: value.map((filter: string) => ({ - filepath: { $regex: filter, $options: 'i' }, + filepath: { + $regex: new RegExp(`(^${filter}/|/${filter}/)`, 'i'), + }, })), }); } @@ -67,15 +82,8 @@ export class RuleDataService { sortOptions[sortField] = sortOrder === 'ascend' ? 1 : -1; } - const filePaths = await this.ruleDataModel.find({}, 'filepath -_id').exec(); - const total = filePaths.length; - const filePathsArray = filePaths.map((filePath) => filePath.filepath); - const splitFilePaths = filePathsArray.map((filepath) => { - const parts = filepath.split('/'); - return parts.slice(0, -1); - }); - const categories = [...new Set(splitFilePaths.flat())]; - + // Get total count of documents matching the query + const total = await this.ruleDataModel.countDocuments(query); // Execute the query with pagination and sorting const data = await this.ruleDataModel .find(query) @@ -85,7 +93,7 @@ export class RuleDataService { .lean() .exec(); - return { data, total, categories }; + return { data, total, categories: this.categories }; } catch (error) { throw new Error(`Error getting all rule data: ${error.message}`); } @@ -134,6 +142,8 @@ export class RuleDataService { ruleData = await this._addOrUpdateDraft(ruleData); const newRuleData = new this.ruleDataModel(ruleData); const response = await newRuleData.save(); + const existingRules = await this.getAllRuleData(); + this.updateCategories(existingRules.data); return response; } catch (error) { console.error(error.message); @@ -165,6 +175,9 @@ export class RuleDataService { if (!deletedRuleData) { throw new Error('Rule data not found'); } + const existingRules = await this.getAllRuleData(); + this.updateCategories(existingRules.data); + return deletedRuleData; } catch (error) { throw new Error(`Failed to delete rule data: ${error.message}`); diff --git a/src/api/scenarioData/scenarioData.service.spec.ts b/src/api/scenarioData/scenarioData.service.spec.ts index b384b19..0ca0ffb 100644 --- a/src/api/scenarioData/scenarioData.service.spec.ts +++ b/src/api/scenarioData/scenarioData.service.spec.ts @@ -699,7 +699,7 @@ describe('ScenarioDataService', () => { it('should generate possible values for number-input with range', () => { const input = { type: 'number-input', validationType: '[num]', validationCriteria: '1,10' }; const result = service.generatePossibleValues(input); - expect(result.length).toBe(10); + expect(result.length).toBeLessThanOrEqual(10); result.forEach((value) => { expect(value).toBeGreaterThanOrEqual(1); expect(value).toBeLessThanOrEqual(10); @@ -709,7 +709,7 @@ describe('ScenarioDataService', () => { it('should handle date inputs and generate valid dates', () => { const input = { type: 'date', validationCriteria: '2020-01-01,2022-01-01', validationType: '(date)' }; const result = service.generatePossibleValues(input); - expect(result.length).toBe(10); + expect(result.length).toBeLessThanOrEqual(10); result.forEach((value) => { const date = new Date(value).getTime(); expect(date).toBeGreaterThan(new Date('2020-01-01').getTime()); @@ -720,7 +720,7 @@ describe('ScenarioDataService', () => { it('should generate possible values for date input based on a range', () => { const input = { type: 'date', validationType: '[date]', validationCriteria: '2022-01-01,2023-01-01' }; const result = service.generatePossibleValues(input); - expect(result.length).toBe(10); + expect(result.length).toBeLessThanOrEqual(10); result.forEach((value) => { const date = new Date(value); expect(date).toBeInstanceOf(Date); @@ -737,7 +737,7 @@ describe('ScenarioDataService', () => { }); it('should handle text-input with multiple values', () => { - const input = { type: 'text-input', validationCriteria: 'option1,option2,option3' }; + const input = { type: 'text-input', validationType: '[=text]', validationCriteria: 'option1,option2,option3' }; const result = service.generatePossibleValues(input); expect(result).toEqual(['option1', 'option2', 'option3']); }); @@ -773,7 +773,7 @@ describe('ScenarioDataService', () => { const mockProduct = [['1927-10-18', true]]; (complexCartesianProduct as jest.Mock).mockReturnValue(mockProduct); const result = service.generatePossibleValues(input); - expect(result.length).toBe(10); + expect(result.length).toBeLessThanOrEqual(10); result.forEach((array) => { expect(array[0]).toHaveProperty('dateOfBirth'); }); @@ -908,7 +908,7 @@ describe('ScenarioDataService', () => { [[{ subfield1: 5, subfield2: true }]], ]); const result = service.generateCombinations(data, undefined, 2); - expect(result.length).toBe(2); + expect(result.length).toBeLessThanOrEqual(2); result.forEach((item) => { expect(item).toHaveProperty('nested'); expect(Array.isArray(item.nested)).toBe(true); From 7ec5f62e5bd72773e375b462de48042e2874d3f8 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:59:13 -0700 Subject: [PATCH 03/15] Add alphabetical sort to categories. --- src/api/ruleData/ruleData.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index eb64c8d..e0e8348 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -24,7 +24,7 @@ export class RuleDataService { const parts = filepath.split('/'); return parts.slice(0, -1); }); - const categorySet = [...new Set(splitFilePaths.flat())]; + const categorySet = [...new Set(splitFilePaths.flat())].sort((a, b) => a.localeCompare(b)); this.categories = categorySet.map((category: string) => ({ text: category, value: category })); } From 24bfb0e971e5a42c61f996ee34bbb4db0b39f917 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:40:11 -0700 Subject: [PATCH 04/15] Draft decision validation error checks. --- src/api/decisions/decisions.controller.ts | 7 +- src/api/decisions/decisions.service.spec.ts | 6 +- src/api/decisions/decisions.service.ts | 13 +- .../validations/validationError.service.ts | 19 ++ .../validations/validations.service.spec.ts | 284 ++++++++++++++++ .../validations/validations.service.ts | 311 ++++++++++++++++++ 6 files changed, 636 insertions(+), 4 deletions(-) create mode 100644 src/api/decisions/validations/validationError.service.ts create mode 100644 src/api/decisions/validations/validations.service.spec.ts create mode 100644 src/api/decisions/validations/validations.service.ts diff --git a/src/api/decisions/decisions.controller.ts b/src/api/decisions/decisions.controller.ts index 398b96b..930aae2 100644 --- a/src/api/decisions/decisions.controller.ts +++ b/src/api/decisions/decisions.controller.ts @@ -1,6 +1,7 @@ import { Controller, Post, Query, Body, HttpException, HttpStatus } from '@nestjs/common'; import { DecisionsService } from './decisions.service'; import { EvaluateDecisionDto, EvaluateDecisionWithContentDto } from './dto/evaluate-decision.dto'; +import { ValidationError } from './validations/validationError.service'; @Controller('api/decisions') export class DecisionsController { @@ -11,7 +12,11 @@ export class DecisionsController { try { return await this.decisionsService.runDecisionByContent(ruleContent, context, { trace }); } catch (error) { - throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + if (error instanceof ValidationError) { + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + } else { + throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } } } diff --git a/src/api/decisions/decisions.service.spec.ts b/src/api/decisions/decisions.service.spec.ts index d67250f..6b76022 100644 --- a/src/api/decisions/decisions.service.spec.ts +++ b/src/api/decisions/decisions.service.spec.ts @@ -2,6 +2,7 @@ import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { ZenEngine, ZenDecision, ZenEvaluateOptions } from '@gorules/zen-engine'; import { DecisionsService } from './decisions.service'; +import { ValidationService } from './validations/validations.service'; import { readFileSafely } from '../../utils/readFile'; jest.mock('../../utils/readFile', () => ({ @@ -11,6 +12,7 @@ jest.mock('../../utils/readFile', () => ({ describe('DecisionsService', () => { let service: DecisionsService; + let validationService: ValidationService; let mockEngine: Partial; let mockDecision: Partial; @@ -23,11 +25,13 @@ describe('DecisionsService', () => { }; const module: TestingModule = await Test.createTestingModule({ - providers: [ConfigService, DecisionsService, { provide: ZenEngine, useValue: mockEngine }], + providers: [ConfigService, DecisionsService, ValidationService, { provide: ZenEngine, useValue: mockEngine }], }).compile(); service = module.get(DecisionsService); + validationService = module.get(ValidationService); service.engine = mockEngine as ZenEngine; + jest.spyOn(validationService, 'validateInputs').mockImplementation(() => {}); }); describe('runDecision', () => { diff --git a/src/api/decisions/decisions.service.ts b/src/api/decisions/decisions.service.ts index ce3ea7f..1894465 100644 --- a/src/api/decisions/decisions.service.ts +++ b/src/api/decisions/decisions.service.ts @@ -3,6 +3,8 @@ import { ZenEngine, ZenEvaluateOptions } from '@gorules/zen-engine'; import { ConfigService } from '@nestjs/config'; import { RuleContent } from '../ruleMapping/ruleMapping.interface'; import { readFileSafely, FileNotFoundError } from '../../utils/readFile'; +import { ValidationService } from './validations/validations.service'; +import { ValidationError } from './validations/validationError.service'; @Injectable() export class DecisionsService { @@ -16,12 +18,19 @@ export class DecisionsService { } async runDecisionByContent(ruleContent: RuleContent, context: object, options: ZenEvaluateOptions) { + const validator = new ValidationService(); + const ruleInputs = ruleContent?.nodes?.filter((node) => node.type === 'inputNode')[0]?.content; try { + validator.validateInputs(ruleInputs, context); const decision = this.engine.createDecision(ruleContent); return await decision.evaluate(context, options); } catch (error) { - console.error(error.message); - throw new Error(`Failed to run decision: ${error.message}`); + if (error instanceof ValidationError) { + throw new ValidationError(`Invalid input: ${error.message}`); + } else { + console.error(error.message); + throw new Error(`Failed to run decision: ${error.message}`); + } } } diff --git a/src/api/decisions/validations/validationError.service.ts b/src/api/decisions/validations/validationError.service.ts new file mode 100644 index 0000000..183057e --- /dev/null +++ b/src/api/decisions/validations/validationError.service.ts @@ -0,0 +1,19 @@ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + Object.setPrototypeOf(this, ValidationError.prototype); + } + + getErrorCode(): string { + return 'VALIDATION_ERROR'; + } + + toJSON(): object { + return { + name: this.name, + message: this.message, + code: this.getErrorCode(), + }; + } +} diff --git a/src/api/decisions/validations/validations.service.spec.ts b/src/api/decisions/validations/validations.service.spec.ts new file mode 100644 index 0000000..41913a1 --- /dev/null +++ b/src/api/decisions/validations/validations.service.spec.ts @@ -0,0 +1,284 @@ +import { ValidationService } from './validations.service'; +import { ValidationError } from '../validations/validationError.service'; + +describe('ValidationService', () => { + let validator: ValidationService; + + beforeEach(() => { + validator = new ValidationService(); + }); + + describe('validateInputs', () => { + it('should validate valid inputs', () => { + const ruleContent = { + fields: [ + { field: 'age', type: 'number-input' }, + { field: 'name', type: 'text-input' }, + ], + }; + const context = { age: 25, name: 'John Doe' }; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + }); + + describe('validateField', () => { + it('should validate number input', () => { + const field = { field: 'age', type: 'number-input' }; + const context = { age: 25 }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + + it('should validate date input', () => { + const field = { field: 'birthDate', type: 'date' }; + const context = { birthDate: '2000-01-01' }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + + it('should validate text input', () => { + const field = { field: 'name', type: 'text-input' }; + const context = { name: 'John Doe' }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + + it('should validate boolean input', () => { + const field = { field: 'isActive', type: 'true-false' }; + const context = { isActive: true }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + + it('should throw ValidationError for invalid input type', () => { + const field = { field: 'age', type: 'number-input' }; + const context = { age: 'twenty-five' }; + expect(() => validator['validateField'](field, context)).toThrow(ValidationError); + }); + }); + + describe('validateNumberCriteria', () => { + it('should validate equal to criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '==', validationCriteria: '25' }; + expect(() => validator['validateNumberCriteria'](field, 25)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 26)).toThrow(ValidationError); + }); + + it('should validate not equal to criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '!=', validationCriteria: '25' }; + expect(() => validator['validateNumberCriteria'](field, 26)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 25)).toThrow(ValidationError); + }); + + it('should validate greater than or equal to criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '>=', validationCriteria: '18' }; + expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 17)).toThrow(ValidationError); + }); + + it('should validate greater than criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '>', validationCriteria: '18' }; + expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 17)).toThrow(ValidationError); + }); + + it('should validate less than or equal to criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '<=', validationCriteria: '18' }; + expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 19)).toThrow(ValidationError); + }); + + it('should validate less than criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '<', validationCriteria: '18' }; + expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 18)).toThrow(ValidationError); + }); + + it('should validate between exclusive criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '(num)', validationCriteria: '[16, 20]' }; + expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 16)).toThrow(ValidationError); + expect(() => validator['validateNumberCriteria'](field, 20)).toThrow(ValidationError); + expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); + }); + + it('should validate between inclusive criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '[num]', validationCriteria: '[16, 20]' }; + expect(() => validator['validateNumberCriteria'](field, 16)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 15)).toThrow(ValidationError); + expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); + }); + + it('should validate multiple criteria', () => { + const field = { + field: 'age', + type: 'number-input', + validationType: '[=num]', + validationCriteria: '[18, 20]', + }; + expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); + }); + + it('should validate multiple criteria with multiple values', () => { + const field = { + field: 'age', + type: 'number-input', + validationType: '[=nums]', + validationCriteria: '[18, 20]', + }; + expect(() => validator['validateNumberCriteria'](field, [18])).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, [18, 20])).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, [19, 20])).toThrow(ValidationError); + }); + }); + + describe('validateDateCriteria', () => { + it('should validate equal to criteria', () => { + const field = { field: 'date', type: 'date', validationType: '==', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); + }); + + it('should validate not equal to criteria', () => { + const field = { field: 'date', type: 'date', validationType: '!=', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); + }); + + it('should validate greater than criteria', () => { + const field = { field: 'date', type: 'date', validationType: '>', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); + }); + + it('should validate greater than or equal to criteria', () => { + const field = { field: 'date', type: 'date', validationType: '>=', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2022-01-01')).toThrow(ValidationError); + }); + + it('should validate less than criteria', () => { + const field = { field: 'date', type: 'date', validationType: '<', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2022-12-31')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); + }); + + it('should validate less than or equal to criteria', () => { + const field = { field: 'date', type: 'date', validationType: '<=', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); + }); + + it('should validate between exclusive criteria', () => { + const field = { + field: 'date', + type: 'date', + validationType: '(date)', + validationCriteria: '[2023-01-01, 2024-01-03]', + }; + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2024-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-06-05')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2024-01-04')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); + }); + + it('should validate between inclusive criteria', () => { + const field = { + field: 'date', + type: 'date', + validationType: '[date]', + validationCriteria: '[2023-01-01, 2023-01-03]', + }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-03')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-04')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); + }); + + it('should validate multiple criteria with single dates', () => { + const field = { + field: 'date', + type: 'date', + validationType: '[=date]', + validationCriteria: '[2023-01-01, 2023-01-03]', + }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-03')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2023-01-04')).toThrow(ValidationError); + }); + + it('should validate multiple criteria with multiple dates', () => { + const field = { + field: 'date', + type: 'date', + validationType: '[=dates]', + validationCriteria: '[2023-01-01, 2023-01-03]', + }; + expect(() => validator['validateDateCriteria'](field, ['2023-01-01'])).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-03'])).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-02'])).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-04'])).toThrow(ValidationError); + }); + }); + + describe('validateTextCriteria', () => { + it('should validate equal to criteria', () => { + const field = { field: 'status', type: 'text-input', validationType: '==', validationCriteria: 'active' }; + expect(() => validator['validateTextCriteria'](field, 'active')).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, 'inactive')).toThrow(ValidationError); + }); + + it('should validate not equal to criteria', () => { + const field = { field: 'status', type: 'text-input', validationType: '!=', validationCriteria: 'active' }; + expect(() => validator['validateTextCriteria'](field, 'inactive')).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, 'active')).toThrow(ValidationError); + }); + + it('should validate regex criteria', () => { + const field = { + field: 'email', + type: 'text-input', + validationType: 'regex', + validationCriteria: '^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$', + }; + expect(() => validator['validateTextCriteria'](field, 'test@example.com')).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, 'invalid-email')).toThrow(ValidationError); + }); + + it('should validate multiple criteria with single text', () => { + const field = { + field: 'status', + type: 'text-input', + validationType: '[=text]', + validationCriteria: 'active,testing', + }; + expect(() => validator['validateTextCriteria'](field, 'active')).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, 'inactive')).toThrow(ValidationError); + }); + + it('should validate multiple criteria with multiple texts', () => { + const field = { + field: 'status', + type: 'text-input', + validationType: '[=texts]', + validationCriteria: 'active,inactive', + }; + expect(() => validator['validateTextCriteria'](field, ['active'])).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, ['active', 'inactive'])).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, ['active', 'inactive', 'testing'])).toThrow( + ValidationError, + ); + }); + }); +}); diff --git a/src/api/decisions/validations/validations.service.ts b/src/api/decisions/validations/validations.service.ts new file mode 100644 index 0000000..3ed67e7 --- /dev/null +++ b/src/api/decisions/validations/validations.service.ts @@ -0,0 +1,311 @@ +import { Injectable } from '@nestjs/common'; +import { ValidationError } from '../validations/validationError.service'; + +@Injectable() +export class ValidationService { + validateInputs(ruleContent: any, context: Record): void { + if (typeof ruleContent !== 'object' || !Array.isArray(ruleContent.fields)) { + return; + } + + for (const field of ruleContent.fields) { + this.validateField(field, context); + } + } + + private validateField(field: any, context: Record): void { + if (!field.field || typeof field.field !== 'string') { + throw new ValidationError(`Invalid field definition: ${JSON.stringify(field)}`); + } + + if (!(field.field in context)) { + return; + } + + const input = context[field.field]; + + if (input === null || input === undefined) { + return; + } + + this.validateType(field, input); + this.validateCriteria(field, input); + + if (Array.isArray(field.childFields)) { + for (const childField of field.childFields) { + this.validateField(childField, input); + } + } + } + + private validateType(field: any, input: any): void { + const { dataType, type, validationType } = field; + const actualType = typeof input; + + switch (type || dataType) { + case 'number-input': + if (typeof input !== 'number' && validationType !== '[=nums]') { + throw new ValidationError(`Input ${field.field} should be a number, but got ${actualType}`); + } else if (validationType === '[=nums]' && !Array.isArray(input)) { + throw new ValidationError(`Input ${field.field} should be an array of numbers, but got ${actualType}`); + } + break; + case 'date': + if ((typeof input !== 'string' && validationType !== '[=dates]') || isNaN(Date.parse(input))) { + throw new ValidationError(`Input ${field.field} should be a valid date string, but got ${input}`); + } else if (validationType === '[=dates]' && !Array.isArray(input)) { + throw new ValidationError(`Input ${field.field} should be an array of date strings, but got ${input}`); + } + break; + case 'text-input': + if (typeof input !== 'string' && validationType !== '[=texts]') { + throw new ValidationError(`Input ${field.field} should be a string, but got ${actualType}`); + } else if (validationType === '[=texts]' && !Array.isArray(input)) { + throw new ValidationError(`Input ${field.field} should be an array of strings, but got ${actualType}`); + } + break; + case 'true-false': + if (typeof input !== 'boolean') { + throw new ValidationError(`Input ${field.field} should be a boolean, but got ${actualType}`); + } + break; + } + } + + private validateCriteria(field: any, input: any): void { + const { dataType, type } = field; + + switch (type || dataType) { + case 'number-input': + this.validateNumberCriteria(field, input); + break; + case 'date': + this.validateDateCriteria(field, input); + break; + case 'text-input': + this.validateTextCriteria(field, input); + break; + } + } + + private validateNumberCriteria(field: any, input: number | number[]): void { + const { validationCriteria, validationType } = field; + const numberValues = validationCriteria + ? validationCriteria + .replace(/[\[\]]/g, '') + .split(',') + .map((val: string) => parseFloat(val.trim())) + : []; + + const validationValue = parseFloat(validationCriteria) || 0; + const [minValue, maxValue] = + numberValues.length > 1 ? [numberValues[0], numberValues[numberValues.length - 1]] : [0, 10]; + + const numberInput = typeof input === 'number' ? parseFloat(input.toFixed(2)) : null; + + switch (validationType) { + case '==': + if (numberInput !== parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must equal ${validationValue}`); + } + break; + case '!=': + if (numberInput === parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must not equal ${validationValue}`); + } + break; + case '>=': + if (numberInput < parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must be greater than or equal to ${validationValue}`); + } + break; + case '<=': + if (numberInput > parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must be less than or equal to ${validationValue}`); + } + break; + case '>': + if (numberInput <= parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must be greater than ${validationValue}`); + } + break; + case '<': + if (numberInput >= parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must be less than ${validationValue}`); + } + break; + case '(num)': + if (numberInput <= minValue || numberInput >= maxValue) { + throw new ValidationError(`Input ${field.field} must be between ${minValue} and ${maxValue} (exclusive)`); + } + break; + case '[num]': + if (numberInput < minValue || numberInput > maxValue) { + throw new ValidationError(`Input ${field.field} must be between ${minValue} and ${maxValue} (inclusive)`); + } + break; + case '[=num]': + if (!numberValues.includes(numberInput)) { + throw new ValidationError(`Input ${field.field} must be one of: ${numberValues.join(', ')}`); + } + break; + case '[=nums]': + if (!Array.isArray(input) || !input.every((inp: number) => numberValues.includes(parseFloat(inp.toFixed(2))))) { + throw new ValidationError( + `Input ${field.field} must be an array with values from: ${numberValues.join(', ')}`, + ); + } + break; + } + } + + private validateDateCriteria(field: any, input: string | string[]): void { + const { validationCriteria, validationType } = field; + const dateValues = validationCriteria + ? validationCriteria + .replace(/[\[\]]/g, '') + .split(',') + .map((val: string) => new Date(val.trim()).getTime()) + : []; + + const dateValidationValue = new Date(validationCriteria).getTime() || new Date().getTime(); + const [minDate, maxDate] = + dateValues.length > 1 + ? [dateValues[0], dateValues[dateValues.length - 1]] + : [new Date().getTime(), new Date().setFullYear(new Date().getFullYear() + 1)]; + + const dateInput = typeof input === 'string' ? new Date(input).getTime() : null; + + switch (validationType) { + case '==': + if (dateInput !== dateValidationValue) { + throw new ValidationError(`Input ${field.field} must equal ${new Date(dateValidationValue).toISOString()}`); + } + break; + case '!=': + if (dateInput === dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must not equal ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '>=': + if (dateInput < dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must be on or after ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '<=': + if (dateInput > dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must be on or before ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '>': + if (dateInput <= dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must be after ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '<': + if (dateInput >= dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must be before ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '(date)': + if (dateInput <= minDate || dateInput >= maxDate) { + throw new ValidationError( + `Input ${field.field} must be between ${new Date(minDate).toISOString()} and ${new Date(maxDate).toISOString()} (exclusive)`, + ); + } + break; + case '[date]': + if (dateInput < minDate || dateInput > maxDate) { + throw new ValidationError( + `Input ${field.field} must be between ${new Date(minDate).toISOString()} and ${new Date(maxDate).toISOString()} (inclusive)`, + ); + } + break; + case '[=date]': + if (!dateValues.includes(dateInput)) { + throw new ValidationError( + `Input ${field.field} must be one of: ${dateValues.map((d) => new Date(d).toISOString()).join(', ')}`, + ); + } + break; + case '[=dates]': + if (!Array.isArray(input) || !input.every((inp: string) => dateValues.includes(new Date(inp).getTime()))) { + throw new ValidationError( + `Input ${field.field} must be an array with dates from: ${dateValues.map((d) => new Date(d).toISOString()).join(', ')}`, + ); + } + break; + } + } + + private validateTextCriteria(field: any, input: string | string[]): void { + const { validationCriteria, validationType } = field; + + switch (validationType) { + case '==': + if (input !== validationCriteria) { + throw new ValidationError(`Input ${field.field} must equal "${validationCriteria}"`); + } + break; + case '!=': + if (input === validationCriteria) { + throw new ValidationError(`Input ${field.field} must not equal "${validationCriteria}"`); + } + break; + case 'regex': + if (typeof input === 'string') { + try { + const regex = new RegExp(validationCriteria); + if (!regex.test(input)) { + throw new ValidationError(`Input ${field.field} must match the pattern: ${validationCriteria}`); + } + } catch (e) { + throw new ValidationError(`Invalid regex pattern for ${field.field}: ${validationCriteria}`); + } + break; + } + case '[=text]': + if (typeof input === 'string') { + const validTexts = validationCriteria + .replace(/[\[\]]/g, '') + .split(',') + .map((val: string) => val.trim()); + if (!validTexts.includes(input.trim())) { + throw new ValidationError(`Input ${field.field} must be one of: ${validTexts.join(', ')}`); + } + break; + } + case '[=texts]': + const validTextArray = validationCriteria + .replace(/[\[\]]/g, '') + .split(',') + .map((val: string) => val.trim()); + const inputArray = Array.isArray(input) + ? input + : input + .replace(/[\[\]]/g, '') + .split(',') + .map((val) => val.trim()); + if (!inputArray.every((inp: string) => validTextArray.includes(inp))) { + throw new ValidationError( + `Input ${field.field} must be on or many of the values from: ${validTextArray.join(', ')}`, + ); + } + break; + } + } + + validateOutput(outputSchema: any, output: any): void { + this.validateField(outputSchema, { output }); + } +} From af09bf12acdb303ce3bdce790de14131790b3ca2 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:44:55 -0700 Subject: [PATCH 05/15] Revert "Draft decision validation error checks." This reverts commit 24bfb0e971e5a42c61f996ee34bbb4db0b39f917. --- src/api/decisions/decisions.controller.ts | 7 +- src/api/decisions/decisions.service.spec.ts | 6 +- src/api/decisions/decisions.service.ts | 13 +- .../validations/validationError.service.ts | 19 -- .../validations/validations.service.spec.ts | 284 ---------------- .../validations/validations.service.ts | 311 ------------------ 6 files changed, 4 insertions(+), 636 deletions(-) delete mode 100644 src/api/decisions/validations/validationError.service.ts delete mode 100644 src/api/decisions/validations/validations.service.spec.ts delete mode 100644 src/api/decisions/validations/validations.service.ts diff --git a/src/api/decisions/decisions.controller.ts b/src/api/decisions/decisions.controller.ts index 930aae2..398b96b 100644 --- a/src/api/decisions/decisions.controller.ts +++ b/src/api/decisions/decisions.controller.ts @@ -1,7 +1,6 @@ import { Controller, Post, Query, Body, HttpException, HttpStatus } from '@nestjs/common'; import { DecisionsService } from './decisions.service'; import { EvaluateDecisionDto, EvaluateDecisionWithContentDto } from './dto/evaluate-decision.dto'; -import { ValidationError } from './validations/validationError.service'; @Controller('api/decisions') export class DecisionsController { @@ -12,11 +11,7 @@ export class DecisionsController { try { return await this.decisionsService.runDecisionByContent(ruleContent, context, { trace }); } catch (error) { - if (error instanceof ValidationError) { - throw new HttpException(error.message, HttpStatus.BAD_REQUEST); - } else { - throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); - } + throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/src/api/decisions/decisions.service.spec.ts b/src/api/decisions/decisions.service.spec.ts index 6b76022..d67250f 100644 --- a/src/api/decisions/decisions.service.spec.ts +++ b/src/api/decisions/decisions.service.spec.ts @@ -2,7 +2,6 @@ import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { ZenEngine, ZenDecision, ZenEvaluateOptions } from '@gorules/zen-engine'; import { DecisionsService } from './decisions.service'; -import { ValidationService } from './validations/validations.service'; import { readFileSafely } from '../../utils/readFile'; jest.mock('../../utils/readFile', () => ({ @@ -12,7 +11,6 @@ jest.mock('../../utils/readFile', () => ({ describe('DecisionsService', () => { let service: DecisionsService; - let validationService: ValidationService; let mockEngine: Partial; let mockDecision: Partial; @@ -25,13 +23,11 @@ describe('DecisionsService', () => { }; const module: TestingModule = await Test.createTestingModule({ - providers: [ConfigService, DecisionsService, ValidationService, { provide: ZenEngine, useValue: mockEngine }], + providers: [ConfigService, DecisionsService, { provide: ZenEngine, useValue: mockEngine }], }).compile(); service = module.get(DecisionsService); - validationService = module.get(ValidationService); service.engine = mockEngine as ZenEngine; - jest.spyOn(validationService, 'validateInputs').mockImplementation(() => {}); }); describe('runDecision', () => { diff --git a/src/api/decisions/decisions.service.ts b/src/api/decisions/decisions.service.ts index 1894465..ce3ea7f 100644 --- a/src/api/decisions/decisions.service.ts +++ b/src/api/decisions/decisions.service.ts @@ -3,8 +3,6 @@ import { ZenEngine, ZenEvaluateOptions } from '@gorules/zen-engine'; import { ConfigService } from '@nestjs/config'; import { RuleContent } from '../ruleMapping/ruleMapping.interface'; import { readFileSafely, FileNotFoundError } from '../../utils/readFile'; -import { ValidationService } from './validations/validations.service'; -import { ValidationError } from './validations/validationError.service'; @Injectable() export class DecisionsService { @@ -18,19 +16,12 @@ export class DecisionsService { } async runDecisionByContent(ruleContent: RuleContent, context: object, options: ZenEvaluateOptions) { - const validator = new ValidationService(); - const ruleInputs = ruleContent?.nodes?.filter((node) => node.type === 'inputNode')[0]?.content; try { - validator.validateInputs(ruleInputs, context); const decision = this.engine.createDecision(ruleContent); return await decision.evaluate(context, options); } catch (error) { - if (error instanceof ValidationError) { - throw new ValidationError(`Invalid input: ${error.message}`); - } else { - console.error(error.message); - throw new Error(`Failed to run decision: ${error.message}`); - } + console.error(error.message); + throw new Error(`Failed to run decision: ${error.message}`); } } diff --git a/src/api/decisions/validations/validationError.service.ts b/src/api/decisions/validations/validationError.service.ts deleted file mode 100644 index 183057e..0000000 --- a/src/api/decisions/validations/validationError.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'ValidationError'; - Object.setPrototypeOf(this, ValidationError.prototype); - } - - getErrorCode(): string { - return 'VALIDATION_ERROR'; - } - - toJSON(): object { - return { - name: this.name, - message: this.message, - code: this.getErrorCode(), - }; - } -} diff --git a/src/api/decisions/validations/validations.service.spec.ts b/src/api/decisions/validations/validations.service.spec.ts deleted file mode 100644 index 41913a1..0000000 --- a/src/api/decisions/validations/validations.service.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { ValidationService } from './validations.service'; -import { ValidationError } from '../validations/validationError.service'; - -describe('ValidationService', () => { - let validator: ValidationService; - - beforeEach(() => { - validator = new ValidationService(); - }); - - describe('validateInputs', () => { - it('should validate valid inputs', () => { - const ruleContent = { - fields: [ - { field: 'age', type: 'number-input' }, - { field: 'name', type: 'text-input' }, - ], - }; - const context = { age: 25, name: 'John Doe' }; - expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); - }); - }); - - describe('validateField', () => { - it('should validate number input', () => { - const field = { field: 'age', type: 'number-input' }; - const context = { age: 25 }; - expect(() => validator['validateField'](field, context)).not.toThrow(); - }); - - it('should validate date input', () => { - const field = { field: 'birthDate', type: 'date' }; - const context = { birthDate: '2000-01-01' }; - expect(() => validator['validateField'](field, context)).not.toThrow(); - }); - - it('should validate text input', () => { - const field = { field: 'name', type: 'text-input' }; - const context = { name: 'John Doe' }; - expect(() => validator['validateField'](field, context)).not.toThrow(); - }); - - it('should validate boolean input', () => { - const field = { field: 'isActive', type: 'true-false' }; - const context = { isActive: true }; - expect(() => validator['validateField'](field, context)).not.toThrow(); - }); - - it('should throw ValidationError for invalid input type', () => { - const field = { field: 'age', type: 'number-input' }; - const context = { age: 'twenty-five' }; - expect(() => validator['validateField'](field, context)).toThrow(ValidationError); - }); - }); - - describe('validateNumberCriteria', () => { - it('should validate equal to criteria', () => { - const field = { field: 'age', type: 'number-input', validationType: '==', validationCriteria: '25' }; - expect(() => validator['validateNumberCriteria'](field, 25)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 26)).toThrow(ValidationError); - }); - - it('should validate not equal to criteria', () => { - const field = { field: 'age', type: 'number-input', validationType: '!=', validationCriteria: '25' }; - expect(() => validator['validateNumberCriteria'](field, 26)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 25)).toThrow(ValidationError); - }); - - it('should validate greater than or equal to criteria', () => { - const field = { field: 'age', type: 'number-input', validationType: '>=', validationCriteria: '18' }; - expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 17)).toThrow(ValidationError); - }); - - it('should validate greater than criteria', () => { - const field = { field: 'age', type: 'number-input', validationType: '>', validationCriteria: '18' }; - expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 17)).toThrow(ValidationError); - }); - - it('should validate less than or equal to criteria', () => { - const field = { field: 'age', type: 'number-input', validationType: '<=', validationCriteria: '18' }; - expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 19)).toThrow(ValidationError); - }); - - it('should validate less than criteria', () => { - const field = { field: 'age', type: 'number-input', validationType: '<', validationCriteria: '18' }; - expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 18)).toThrow(ValidationError); - }); - - it('should validate between exclusive criteria', () => { - const field = { field: 'age', type: 'number-input', validationType: '(num)', validationCriteria: '[16, 20]' }; - expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 16)).toThrow(ValidationError); - expect(() => validator['validateNumberCriteria'](field, 20)).toThrow(ValidationError); - expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); - }); - - it('should validate between inclusive criteria', () => { - const field = { field: 'age', type: 'number-input', validationType: '[num]', validationCriteria: '[16, 20]' }; - expect(() => validator['validateNumberCriteria'](field, 16)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 15)).toThrow(ValidationError); - expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); - }); - - it('should validate multiple criteria', () => { - const field = { - field: 'age', - type: 'number-input', - validationType: '[=num]', - validationCriteria: '[18, 20]', - }; - expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); - }); - - it('should validate multiple criteria with multiple values', () => { - const field = { - field: 'age', - type: 'number-input', - validationType: '[=nums]', - validationCriteria: '[18, 20]', - }; - expect(() => validator['validateNumberCriteria'](field, [18])).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, [18, 20])).not.toThrow(); - expect(() => validator['validateNumberCriteria'](field, [19, 20])).toThrow(ValidationError); - }); - }); - - describe('validateDateCriteria', () => { - it('should validate equal to criteria', () => { - const field = { field: 'date', type: 'date', validationType: '==', validationCriteria: '2023-01-01' }; - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); - }); - - it('should validate not equal to criteria', () => { - const field = { field: 'date', type: 'date', validationType: '!=', validationCriteria: '2023-01-01' }; - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); - }); - - it('should validate greater than criteria', () => { - const field = { field: 'date', type: 'date', validationType: '>', validationCriteria: '2023-01-01' }; - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); - expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); - }); - - it('should validate greater than or equal to criteria', () => { - const field = { field: 'date', type: 'date', validationType: '>=', validationCriteria: '2023-01-01' }; - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2022-01-01')).toThrow(ValidationError); - }); - - it('should validate less than criteria', () => { - const field = { field: 'date', type: 'date', validationType: '<', validationCriteria: '2023-01-01' }; - expect(() => validator['validateDateCriteria'](field, '2022-12-31')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); - }); - - it('should validate less than or equal to criteria', () => { - const field = { field: 'date', type: 'date', validationType: '<=', validationCriteria: '2023-01-01' }; - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); - }); - - it('should validate between exclusive criteria', () => { - const field = { - field: 'date', - type: 'date', - validationType: '(date)', - validationCriteria: '[2023-01-01, 2024-01-03]', - }; - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2024-01-02')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-06-05')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2024-01-04')).toThrow(ValidationError); - expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); - }); - - it('should validate between inclusive criteria', () => { - const field = { - field: 'date', - type: 'date', - validationType: '[date]', - validationCriteria: '[2023-01-01, 2023-01-03]', - }; - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-03')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-04')).toThrow(ValidationError); - expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); - }); - - it('should validate multiple criteria with single dates', () => { - const field = { - field: 'date', - type: 'date', - validationType: '[=date]', - validationCriteria: '[2023-01-01, 2023-01-03]', - }; - expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-03')).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); - expect(() => validator['validateDateCriteria'](field, '2023-01-04')).toThrow(ValidationError); - }); - - it('should validate multiple criteria with multiple dates', () => { - const field = { - field: 'date', - type: 'date', - validationType: '[=dates]', - validationCriteria: '[2023-01-01, 2023-01-03]', - }; - expect(() => validator['validateDateCriteria'](field, ['2023-01-01'])).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-03'])).not.toThrow(); - expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-02'])).toThrow(ValidationError); - expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-04'])).toThrow(ValidationError); - }); - }); - - describe('validateTextCriteria', () => { - it('should validate equal to criteria', () => { - const field = { field: 'status', type: 'text-input', validationType: '==', validationCriteria: 'active' }; - expect(() => validator['validateTextCriteria'](field, 'active')).not.toThrow(); - expect(() => validator['validateTextCriteria'](field, 'inactive')).toThrow(ValidationError); - }); - - it('should validate not equal to criteria', () => { - const field = { field: 'status', type: 'text-input', validationType: '!=', validationCriteria: 'active' }; - expect(() => validator['validateTextCriteria'](field, 'inactive')).not.toThrow(); - expect(() => validator['validateTextCriteria'](field, 'active')).toThrow(ValidationError); - }); - - it('should validate regex criteria', () => { - const field = { - field: 'email', - type: 'text-input', - validationType: 'regex', - validationCriteria: '^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$', - }; - expect(() => validator['validateTextCriteria'](field, 'test@example.com')).not.toThrow(); - expect(() => validator['validateTextCriteria'](field, 'invalid-email')).toThrow(ValidationError); - }); - - it('should validate multiple criteria with single text', () => { - const field = { - field: 'status', - type: 'text-input', - validationType: '[=text]', - validationCriteria: 'active,testing', - }; - expect(() => validator['validateTextCriteria'](field, 'active')).not.toThrow(); - expect(() => validator['validateTextCriteria'](field, 'inactive')).toThrow(ValidationError); - }); - - it('should validate multiple criteria with multiple texts', () => { - const field = { - field: 'status', - type: 'text-input', - validationType: '[=texts]', - validationCriteria: 'active,inactive', - }; - expect(() => validator['validateTextCriteria'](field, ['active'])).not.toThrow(); - expect(() => validator['validateTextCriteria'](field, ['active', 'inactive'])).not.toThrow(); - expect(() => validator['validateTextCriteria'](field, ['active', 'inactive', 'testing'])).toThrow( - ValidationError, - ); - }); - }); -}); diff --git a/src/api/decisions/validations/validations.service.ts b/src/api/decisions/validations/validations.service.ts deleted file mode 100644 index 3ed67e7..0000000 --- a/src/api/decisions/validations/validations.service.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ValidationError } from '../validations/validationError.service'; - -@Injectable() -export class ValidationService { - validateInputs(ruleContent: any, context: Record): void { - if (typeof ruleContent !== 'object' || !Array.isArray(ruleContent.fields)) { - return; - } - - for (const field of ruleContent.fields) { - this.validateField(field, context); - } - } - - private validateField(field: any, context: Record): void { - if (!field.field || typeof field.field !== 'string') { - throw new ValidationError(`Invalid field definition: ${JSON.stringify(field)}`); - } - - if (!(field.field in context)) { - return; - } - - const input = context[field.field]; - - if (input === null || input === undefined) { - return; - } - - this.validateType(field, input); - this.validateCriteria(field, input); - - if (Array.isArray(field.childFields)) { - for (const childField of field.childFields) { - this.validateField(childField, input); - } - } - } - - private validateType(field: any, input: any): void { - const { dataType, type, validationType } = field; - const actualType = typeof input; - - switch (type || dataType) { - case 'number-input': - if (typeof input !== 'number' && validationType !== '[=nums]') { - throw new ValidationError(`Input ${field.field} should be a number, but got ${actualType}`); - } else if (validationType === '[=nums]' && !Array.isArray(input)) { - throw new ValidationError(`Input ${field.field} should be an array of numbers, but got ${actualType}`); - } - break; - case 'date': - if ((typeof input !== 'string' && validationType !== '[=dates]') || isNaN(Date.parse(input))) { - throw new ValidationError(`Input ${field.field} should be a valid date string, but got ${input}`); - } else if (validationType === '[=dates]' && !Array.isArray(input)) { - throw new ValidationError(`Input ${field.field} should be an array of date strings, but got ${input}`); - } - break; - case 'text-input': - if (typeof input !== 'string' && validationType !== '[=texts]') { - throw new ValidationError(`Input ${field.field} should be a string, but got ${actualType}`); - } else if (validationType === '[=texts]' && !Array.isArray(input)) { - throw new ValidationError(`Input ${field.field} should be an array of strings, but got ${actualType}`); - } - break; - case 'true-false': - if (typeof input !== 'boolean') { - throw new ValidationError(`Input ${field.field} should be a boolean, but got ${actualType}`); - } - break; - } - } - - private validateCriteria(field: any, input: any): void { - const { dataType, type } = field; - - switch (type || dataType) { - case 'number-input': - this.validateNumberCriteria(field, input); - break; - case 'date': - this.validateDateCriteria(field, input); - break; - case 'text-input': - this.validateTextCriteria(field, input); - break; - } - } - - private validateNumberCriteria(field: any, input: number | number[]): void { - const { validationCriteria, validationType } = field; - const numberValues = validationCriteria - ? validationCriteria - .replace(/[\[\]]/g, '') - .split(',') - .map((val: string) => parseFloat(val.trim())) - : []; - - const validationValue = parseFloat(validationCriteria) || 0; - const [minValue, maxValue] = - numberValues.length > 1 ? [numberValues[0], numberValues[numberValues.length - 1]] : [0, 10]; - - const numberInput = typeof input === 'number' ? parseFloat(input.toFixed(2)) : null; - - switch (validationType) { - case '==': - if (numberInput !== parseFloat(validationValue.toFixed(2))) { - throw new ValidationError(`Input ${field.field} must equal ${validationValue}`); - } - break; - case '!=': - if (numberInput === parseFloat(validationValue.toFixed(2))) { - throw new ValidationError(`Input ${field.field} must not equal ${validationValue}`); - } - break; - case '>=': - if (numberInput < parseFloat(validationValue.toFixed(2))) { - throw new ValidationError(`Input ${field.field} must be greater than or equal to ${validationValue}`); - } - break; - case '<=': - if (numberInput > parseFloat(validationValue.toFixed(2))) { - throw new ValidationError(`Input ${field.field} must be less than or equal to ${validationValue}`); - } - break; - case '>': - if (numberInput <= parseFloat(validationValue.toFixed(2))) { - throw new ValidationError(`Input ${field.field} must be greater than ${validationValue}`); - } - break; - case '<': - if (numberInput >= parseFloat(validationValue.toFixed(2))) { - throw new ValidationError(`Input ${field.field} must be less than ${validationValue}`); - } - break; - case '(num)': - if (numberInput <= minValue || numberInput >= maxValue) { - throw new ValidationError(`Input ${field.field} must be between ${minValue} and ${maxValue} (exclusive)`); - } - break; - case '[num]': - if (numberInput < minValue || numberInput > maxValue) { - throw new ValidationError(`Input ${field.field} must be between ${minValue} and ${maxValue} (inclusive)`); - } - break; - case '[=num]': - if (!numberValues.includes(numberInput)) { - throw new ValidationError(`Input ${field.field} must be one of: ${numberValues.join(', ')}`); - } - break; - case '[=nums]': - if (!Array.isArray(input) || !input.every((inp: number) => numberValues.includes(parseFloat(inp.toFixed(2))))) { - throw new ValidationError( - `Input ${field.field} must be an array with values from: ${numberValues.join(', ')}`, - ); - } - break; - } - } - - private validateDateCriteria(field: any, input: string | string[]): void { - const { validationCriteria, validationType } = field; - const dateValues = validationCriteria - ? validationCriteria - .replace(/[\[\]]/g, '') - .split(',') - .map((val: string) => new Date(val.trim()).getTime()) - : []; - - const dateValidationValue = new Date(validationCriteria).getTime() || new Date().getTime(); - const [minDate, maxDate] = - dateValues.length > 1 - ? [dateValues[0], dateValues[dateValues.length - 1]] - : [new Date().getTime(), new Date().setFullYear(new Date().getFullYear() + 1)]; - - const dateInput = typeof input === 'string' ? new Date(input).getTime() : null; - - switch (validationType) { - case '==': - if (dateInput !== dateValidationValue) { - throw new ValidationError(`Input ${field.field} must equal ${new Date(dateValidationValue).toISOString()}`); - } - break; - case '!=': - if (dateInput === dateValidationValue) { - throw new ValidationError( - `Input ${field.field} must not equal ${new Date(dateValidationValue).toISOString()}`, - ); - } - break; - case '>=': - if (dateInput < dateValidationValue) { - throw new ValidationError( - `Input ${field.field} must be on or after ${new Date(dateValidationValue).toISOString()}`, - ); - } - break; - case '<=': - if (dateInput > dateValidationValue) { - throw new ValidationError( - `Input ${field.field} must be on or before ${new Date(dateValidationValue).toISOString()}`, - ); - } - break; - case '>': - if (dateInput <= dateValidationValue) { - throw new ValidationError( - `Input ${field.field} must be after ${new Date(dateValidationValue).toISOString()}`, - ); - } - break; - case '<': - if (dateInput >= dateValidationValue) { - throw new ValidationError( - `Input ${field.field} must be before ${new Date(dateValidationValue).toISOString()}`, - ); - } - break; - case '(date)': - if (dateInput <= minDate || dateInput >= maxDate) { - throw new ValidationError( - `Input ${field.field} must be between ${new Date(minDate).toISOString()} and ${new Date(maxDate).toISOString()} (exclusive)`, - ); - } - break; - case '[date]': - if (dateInput < minDate || dateInput > maxDate) { - throw new ValidationError( - `Input ${field.field} must be between ${new Date(minDate).toISOString()} and ${new Date(maxDate).toISOString()} (inclusive)`, - ); - } - break; - case '[=date]': - if (!dateValues.includes(dateInput)) { - throw new ValidationError( - `Input ${field.field} must be one of: ${dateValues.map((d) => new Date(d).toISOString()).join(', ')}`, - ); - } - break; - case '[=dates]': - if (!Array.isArray(input) || !input.every((inp: string) => dateValues.includes(new Date(inp).getTime()))) { - throw new ValidationError( - `Input ${field.field} must be an array with dates from: ${dateValues.map((d) => new Date(d).toISOString()).join(', ')}`, - ); - } - break; - } - } - - private validateTextCriteria(field: any, input: string | string[]): void { - const { validationCriteria, validationType } = field; - - switch (validationType) { - case '==': - if (input !== validationCriteria) { - throw new ValidationError(`Input ${field.field} must equal "${validationCriteria}"`); - } - break; - case '!=': - if (input === validationCriteria) { - throw new ValidationError(`Input ${field.field} must not equal "${validationCriteria}"`); - } - break; - case 'regex': - if (typeof input === 'string') { - try { - const regex = new RegExp(validationCriteria); - if (!regex.test(input)) { - throw new ValidationError(`Input ${field.field} must match the pattern: ${validationCriteria}`); - } - } catch (e) { - throw new ValidationError(`Invalid regex pattern for ${field.field}: ${validationCriteria}`); - } - break; - } - case '[=text]': - if (typeof input === 'string') { - const validTexts = validationCriteria - .replace(/[\[\]]/g, '') - .split(',') - .map((val: string) => val.trim()); - if (!validTexts.includes(input.trim())) { - throw new ValidationError(`Input ${field.field} must be one of: ${validTexts.join(', ')}`); - } - break; - } - case '[=texts]': - const validTextArray = validationCriteria - .replace(/[\[\]]/g, '') - .split(',') - .map((val: string) => val.trim()); - const inputArray = Array.isArray(input) - ? input - : input - .replace(/[\[\]]/g, '') - .split(',') - .map((val) => val.trim()); - if (!inputArray.every((inp: string) => validTextArray.includes(inp))) { - throw new ValidationError( - `Input ${field.field} must be on or many of the values from: ${validTextArray.join(', ')}`, - ); - } - break; - } - } - - validateOutput(outputSchema: any, output: any): void { - this.validateField(outputSchema, { output }); - } -} From 1f2953d190a6ebfae537997e68312575d0d9da1d Mon Sep 17 00:00:00 2001 From: timwekkenbc Date: Wed, 16 Oct 2024 11:06:51 -0700 Subject: [PATCH 06/15] Added functionality to sync rules from the db with Klamm, Added functionality to check what rules have been updated in Github in order to know what to sync, Moved ruleData, ruleMapping, and klamm apis into their own modules, Added ability to get ruledata by filepath, Added klammSyncMetadata for storing the last time data was synced to klamm, Added Klamm types, Renamed klamm controller/service functions --- README.md | 2 + src/api/klamm/klamm.controller.spec.ts | 16 +- src/api/klamm/klamm.controller.ts | 9 +- src/api/klamm/klamm.d.ts | 18 + src/api/klamm/klamm.module.ts | 19 ++ src/api/klamm/klamm.service.spec.ts | 393 +++++++++++++++++++--- src/api/klamm/klamm.service.ts | 230 ++++++++++++- src/api/klamm/klammSyncMetadata.schema.ts | 15 + src/api/ruleData/ruleData.module.ts | 1 + src/api/ruleData/ruleData.service.spec.ts | 5 + src/api/ruleData/ruleData.service.ts | 40 ++- src/api/ruleMapping/ruleMapping.module.ts | 11 + src/app.module.ts | 18 +- 13 files changed, 701 insertions(+), 76 deletions(-) create mode 100644 src/api/klamm/klamm.d.ts create mode 100644 src/api/klamm/klamm.module.ts create mode 100644 src/api/klamm/klammSyncMetadata.schema.ts create mode 100644 src/api/ruleMapping/ruleMapping.module.ts diff --git a/README.md b/README.md index f25f0c8..e248224 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Before running your application locally, you'll need some environment variables. - MONGODB_URL: The URL for connecting to the MongoDB instance you created in the previous step. Set it to something like mongodb://localhost/nest. - FRONTEND_URI: The URI for the frontend application. Set it to http://localhost:8080. +- GITHUB_RULES_BRANCH: Specifies which branch Klamm is syncing with +- GITHUB_TOKEN: Optional github token to mitigate issues with rate limiting - GITHUB_APP_CLIENT_ID - GITHUB_APP_CLIENT_SECRET - KLAMM_API_URL: The base URL of your Klamm API diff --git a/src/api/klamm/klamm.controller.spec.ts b/src/api/klamm/klamm.controller.spec.ts index 09380c1..4d56aae 100644 --- a/src/api/klamm/klamm.controller.spec.ts +++ b/src/api/klamm/klamm.controller.spec.ts @@ -13,8 +13,8 @@ describe('KlammController', () => { { provide: KlammService, useValue: { - getBREFields: jest.fn(() => Promise.resolve('expected result')), - getBREFieldFromName: jest.fn((fieldName) => Promise.resolve(`result for ${fieldName}`)), // Mock implementation + getKlammBREFields: jest.fn(() => Promise.resolve('expected result')), + getKlammBREFieldFromName: jest.fn((fieldName) => Promise.resolve(`result for ${fieldName}`)), // Mock implementation }, }, ], @@ -25,14 +25,14 @@ describe('KlammController', () => { }); it('should call getBREFields and return expected result', async () => { - expect(await controller.getBREFields('test')).toBe('expected result'); - expect(service.getBREFields).toHaveBeenCalledTimes(1); + expect(await controller.getKlammBREFields('test')).toBe('expected result'); + expect(service.getKlammBREFields).toHaveBeenCalledTimes(1); }); - it('should call getBREFieldFromName with fieldName and return expected result', async () => { + it('should call getKlammFieldFromName with fieldName and return expected result', async () => { const fieldName = 'testField'; - expect(await controller.getBREFieldFromName(fieldName)).toBe(`result for ${fieldName}`); - expect(service.getBREFieldFromName).toHaveBeenCalledWith(fieldName); - expect(service.getBREFieldFromName).toHaveBeenCalledTimes(1); + expect(await controller.getKlammBREFieldFromName(fieldName)).toBe(`result for ${fieldName}`); + expect(service.getKlammBREFieldFromName).toHaveBeenCalledWith(fieldName); + expect(service.getKlammBREFieldFromName).toHaveBeenCalledTimes(1); }); }); diff --git a/src/api/klamm/klamm.controller.ts b/src/api/klamm/klamm.controller.ts index ad08ad8..d44652f 100644 --- a/src/api/klamm/klamm.controller.ts +++ b/src/api/klamm/klamm.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Query, Param } from '@nestjs/common'; +import { KlammField } from './klamm'; import { KlammService } from './klamm.service'; @Controller('api/klamm') @@ -6,12 +7,12 @@ export class KlammController { constructor(private readonly klammService: KlammService) {} @Get('/brefields') - async getBREFields(@Query('searchText') searchText: string) { - return await this.klammService.getBREFields(searchText); + async getKlammBREFields(@Query('searchText') searchText: string) { + return await this.klammService.getKlammBREFields(searchText); } @Get('/brefield/:fieldName') - async getBREFieldFromName(@Param('fieldName') fieldName: string) { - return await this.klammService.getBREFieldFromName(fieldName); + async getKlammBREFieldFromName(@Param('fieldName') fieldName: string): Promise { + return await this.klammService.getKlammBREFieldFromName(fieldName); } } diff --git a/src/api/klamm/klamm.d.ts b/src/api/klamm/klamm.d.ts new file mode 100644 index 0000000..7ddae37 --- /dev/null +++ b/src/api/klamm/klamm.d.ts @@ -0,0 +1,18 @@ +export interface KlammField { + id: number; + name: string; + label: string; + description: string; +} + +export interface KlammRulePayload { + name: string; + label: string; + rule_inputs: KlammField[]; + rule_outputs: KlammField[]; + child_rules: KlammRulePayload[]; +} + +export interface KlammRule extends KlammRulePayload { + id: number; +} diff --git a/src/api/klamm/klamm.module.ts b/src/api/klamm/klamm.module.ts new file mode 100644 index 0000000..59fb290 --- /dev/null +++ b/src/api/klamm/klamm.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { RuleDataModule } from '../ruleData/ruleData.module'; +import { RuleMappingModule } from '../ruleMapping/ruleMapping.module'; +import { KlammSyncMetadata, KlammSyncMetadataSchema } from './klammSyncMetadata.schema'; +import { DocumentsService } from '../documents/documents.service'; +import { KlammController } from './klamm.controller'; +import { KlammService } from './klamm.service'; + +@Module({ + imports: [ + RuleDataModule, + RuleMappingModule, + MongooseModule.forFeature([{ name: KlammSyncMetadata.name, schema: KlammSyncMetadataSchema }]), + ], + controllers: [KlammController], + providers: [KlammService, DocumentsService], +}) +export class KlammModule {} diff --git a/src/api/klamm/klamm.service.spec.ts b/src/api/klamm/klamm.service.spec.ts index 35acb88..5672b2c 100644 --- a/src/api/klamm/klamm.service.spec.ts +++ b/src/api/klamm/klamm.service.spec.ts @@ -1,64 +1,379 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { KlammService } from './klamm.service'; -import axios from 'axios'; -import { HttpException } from '@nestjs/common'; - -// Mock axios -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +import { Model } from 'mongoose'; +import { KlammService, GITHUB_RULES_REPO } from './klamm.service'; +import { RuleDataService } from '../ruleData/ruleData.service'; +import { RuleMappingService } from '../ruleMapping/ruleMapping.service'; +import { DocumentsService } from '../documents/documents.service'; +import { KlammSyncMetadataDocument } from './klammSyncMetadata.schema'; +import { RuleData } from '../ruleData/ruleData.schema'; +import { KlammRulePayload } from './klamm'; describe('KlammService', () => { let service: KlammService; + let ruleDataService: RuleDataService; + let ruleMappingService: RuleMappingService; + let documentsService: DocumentsService; + let klammSyncMetadata: Model; beforeEach(async () => { - // Set environment variables for testing - process.env.KLAMM_API_URL = 'https://test.api'; - process.env.KLAMM_API_AUTH_TOKEN = 'test-token'; + ruleDataService = { + getRuleDataByFilepath: jest.fn(), + } as unknown as RuleDataService; + ruleMappingService = { + inputOutputSchemaFile: jest.fn(), + } as unknown as RuleMappingService; + documentsService = { + getFileContent: jest.fn(), + } as unknown as DocumentsService; + klammSyncMetadata = { + findOneAndUpdate: jest.fn(), + findOne: jest.fn(), + } as unknown as Model; + + service = new KlammService(ruleDataService, ruleMappingService, documentsService, klammSyncMetadata); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize module correctly', async () => { + jest.spyOn(service as any, '_getUpdatedFilesFromGithub').mockResolvedValue(['file1.js']); + jest.spyOn(service as any, '_syncRules').mockResolvedValue(undefined); + jest.spyOn(service as any, '_updateLastSyncTimestamp').mockResolvedValue(undefined); + + await service.onModuleInit(); + + expect(service['_getUpdatedFilesFromGithub']).toHaveBeenCalled(); + expect(service['_syncRules']).toHaveBeenCalledWith(['file1.js']); + expect(service['_updateLastSyncTimestamp']).toHaveBeenCalled(); + }); + + it('should handle error in onModuleInit', async () => { + jest.spyOn(service as any, '_getUpdatedFilesFromGithub').mockRejectedValue(new Error('Error')); + + await service.onModuleInit(); + + expect(service['_getUpdatedFilesFromGithub']).toHaveBeenCalled(); + }); + + it('should sync rules correctly', async () => { + const updatedFiles = ['file1.js', 'file2.js']; + const mockRule = { name: 'rule1', filepath: 'file1.js' } as RuleData; + jest.spyOn(ruleDataService, 'getRuleDataByFilepath').mockResolvedValue(mockRule); + jest.spyOn(service as any, '_syncRuleWithKlamm').mockResolvedValue(undefined); + + await service['_syncRules'](updatedFiles); + + expect(ruleDataService.getRuleDataByFilepath).toHaveBeenCalledWith('file1.js'); + expect(service['_syncRuleWithKlamm']).toHaveBeenCalledWith(mockRule); + }); + + it('should handle error in _syncRules', async () => { + const updatedFiles = ['file1.js']; + jest.spyOn(ruleDataService, 'getRuleDataByFilepath').mockRejectedValue(new Error('Error')); + + await expect(service['_syncRules'](updatedFiles)).rejects.toThrow('Failed to sync rule from file file1.js'); + }); + + it('should handle empty updatedFiles array', async () => { + const updatedFiles: string[] = []; + jest.spyOn(service as any, '_syncRuleWithKlamm').mockResolvedValue(undefined); + + await service['_syncRules'](updatedFiles); + + expect(service['_syncRuleWithKlamm']).not.toHaveBeenCalled(); + }); + + it('should get updated files from GitHub correctly', async () => { + const mockFiles = ['file1.js', 'file2.js']; + const mockCommits = [{ url: 'commit1' }, { url: 'commit2' }]; + const mockCommitDetails = { files: [{ filename: 'rules/file1.js' }, { filename: 'rules/file2.js' }] }; + jest.spyOn(service as any, '_getLastSyncTimestamp').mockResolvedValue(1234567890); + jest + .spyOn(service.axiosGithubInstance, 'get') + .mockResolvedValueOnce({ data: mockCommits }) + .mockResolvedValueOnce({ data: mockCommitDetails }) + .mockResolvedValueOnce({ data: mockCommitDetails }); + + const result = await service['_getUpdatedFilesFromGithub'](); + + expect(service['_getLastSyncTimestamp']).toHaveBeenCalled(); + expect(service.axiosGithubInstance.get).toHaveBeenCalledWith( + `${GITHUB_RULES_REPO}/commits?since=${new Date(1234567890).toISOString().split('.')[0]}Z&sha=${process.env.GITHUB_RULES_BRANCH}`, + ); + expect(result).toEqual(mockFiles); + }); - mockedAxios.create.mockReturnThis(); + it('should handle error in _getUpdatedFilesFromGithub', async () => { + jest.spyOn(service as any, '_getLastSyncTimestamp').mockResolvedValue(1234567890); + jest.spyOn(service.axiosGithubInstance, 'get').mockRejectedValue(new Error('Error')); - service = new KlammService(); + await expect(service['_getUpdatedFilesFromGithub']()).rejects.toThrow('Error fetching updated files from GitHub'); }); - it('should return data on successful API call', async () => { - const responseData = ['field1', 'field2', 'field3']; - mockedAxios.get.mockResolvedValue({ data: responseData }); + it('should sync rule with Klamm correctly', async () => { + const mockRule = { name: 'rule1', filepath: 'rule1.json', title: 'Rule 1' } as RuleData; + const mockInputsOutputs = { inputs: [], outputs: [] }; + const mockChildRules = []; + jest.spyOn(service as any, '_getInputOutputFieldsData').mockResolvedValue(mockInputsOutputs); + jest.spyOn(service as any, '_getChildRules').mockResolvedValue(mockChildRules); + jest.spyOn(service as any, '_addOrUpdateRuleInKlamm').mockResolvedValue(undefined); + + await service['_syncRuleWithKlamm'](mockRule); + + expect(service['_getInputOutputFieldsData']).toHaveBeenCalledWith(mockRule); + expect(service['_getChildRules']).toHaveBeenCalledWith(mockRule); + expect(service['_addOrUpdateRuleInKlamm']).toHaveBeenCalledWith({ + name: mockRule.name, + label: mockRule.title, + rule_inputs: mockInputsOutputs.inputs, + rule_outputs: mockInputsOutputs.outputs, + child_rules: mockChildRules, + }); + }); + + it('should handle error in _syncRuleWithKlamm', async () => { + const mockRule = { name: 'rule1', filepath: 'rule1.json', title: 'Rule 1' } as RuleData; + jest.spyOn(service as any, '_getInputOutputFieldsData').mockRejectedValue(new Error('Error')); + + await expect(service['_syncRuleWithKlamm'](mockRule)).rejects.toThrow('Error syncing rule1'); + }); + + it('should get input/output fields data correctly', async () => { + const mockRule = { name: 'rule1', filepath: 'file1.js' } as RuleData; + const mockInputsOutputs = { inputs: [], resultOutputs: [] }; + jest.spyOn(ruleMappingService, 'inputOutputSchemaFile').mockResolvedValue(mockInputsOutputs); + jest.spyOn(service as any, '_getFieldsFromIds').mockResolvedValue([]); + + const result = await service['_getInputOutputFieldsData'](mockRule); + + expect(ruleMappingService.inputOutputSchemaFile).toHaveBeenCalledWith(mockRule.filepath); + expect(service['_getFieldsFromIds']).toHaveBeenCalledWith([]); + expect(result).toEqual({ inputs: [], outputs: [] }); + }); + + it('should handle error in _getInputOutputFieldsData', async () => { + const mockRule = { name: 'rule1', filepath: 'file1.js' } as RuleData; + jest.spyOn(ruleMappingService, 'inputOutputSchemaFile').mockRejectedValue(new Error('Error')); + + await expect(service['_getInputOutputFieldsData'](mockRule)).rejects.toThrow( + 'Error getting input/output fields for rule rule1', + ); + }); + + it('should get fields from IDs correctly', async () => { + const mockIds = [1, 2, 3]; + jest.spyOn(service as any, '_fetchFieldById').mockResolvedValue({}); + + const result = await service['_getFieldsFromIds'](mockIds); + + expect(service['_fetchFieldById']).toHaveBeenCalledTimes(mockIds.length); + expect(result).toEqual([{}, {}, {}]); + }); + + it('should handle error in _getFieldsFromIds', async () => { + const mockIds = [1, 2, 3]; + jest.spyOn(service as any, '_fetchFieldById').mockRejectedValue(new Error('Error')); + + await expect(service['_getFieldsFromIds'](mockIds)).rejects.toThrow('Error fetching fields by IDs: Error'); + }); + + it('should fetch field by ID correctly', async () => { + const mockId = 1; + jest.spyOn(service.axiosKlammInstance, 'get').mockResolvedValue({ data: {} }); + + const result = await service['_fetchFieldById'](mockId); + + expect(service.axiosKlammInstance.get).toHaveBeenCalledWith(`${process.env.KLAMM_API_URL}/api/brerules/${mockId}`); + expect(result).toEqual({}); + }); + + it('should handle error in _fetchFieldById', async () => { + const mockId = 1; + jest.spyOn(service.axiosKlammInstance, 'get').mockRejectedValue(new Error('Error')); + + await expect(service['_fetchFieldById'](mockId)).rejects.toThrow('Error fetching field with ID 1: Error'); + }); + + it('should get child rules correctly', async () => { + const mockRule = { name: 'rule1', filepath: 'file1.js' } as RuleData; + const mockFileContent = Buffer.from( + JSON.stringify({ nodes: [{ type: 'decisionNode', content: { key: 'key1' } }] }), + ); + jest.spyOn(documentsService, 'getFileContent').mockResolvedValue(mockFileContent); + jest.spyOn(service as any, '_getKlammRuleFromName').mockResolvedValue({}); + + const result = await service['_getChildRules'](mockRule); + + expect(documentsService.getFileContent).toHaveBeenCalledWith(mockRule.filepath); + expect(service['_getKlammRuleFromName']).toHaveBeenCalledWith('key1'); + expect(result).toEqual([{}]); + }); + + it('should handle error in _getChildRules', async () => { + const mockRule = { name: 'rule1', filepath: 'file1.js' } as RuleData; + jest.spyOn(documentsService, 'getFileContent').mockRejectedValue(new Error('Error')); + + await expect(service['_getChildRules'](mockRule)).rejects.toThrow('Error gettting child rules for rule1'); + }); + + it('should add or update rule in Klamm correctly', async () => { + const mockRulePayload = { name: 'rule1' } as KlammRulePayload; + jest.spyOn(service as any, '_getKlammRuleFromName').mockResolvedValue({ id: 1 }); + jest.spyOn(service as any, '_updateRuleInKlamm').mockResolvedValue(undefined); + + await service['_addOrUpdateRuleInKlamm'](mockRulePayload); + + expect(service['_getKlammRuleFromName']).toHaveBeenCalledWith(mockRulePayload.name); + expect(service['_updateRuleInKlamm']).toHaveBeenCalledWith(1, mockRulePayload); + }); + + it('should handle error in _addOrUpdateRuleInKlamm', async () => { + const mockRulePayload = { name: 'rule1' } as KlammRulePayload; + jest.spyOn(service as any, '_getKlammRuleFromName').mockRejectedValue(new Error('Error')); + + await expect(service['_addOrUpdateRuleInKlamm'](mockRulePayload)).rejects.toThrow('Error'); + }); + + it('should get Klamm rule from name correctly', async () => { + const mockRuleName = 'rule1'; + jest.spyOn(service.axiosKlammInstance, 'get').mockResolvedValue({ data: { data: [{}] } }); + + const result = await service['_getKlammRuleFromName'](mockRuleName); + + expect(service.axiosKlammInstance.get).toHaveBeenCalledWith(`${process.env.KLAMM_API_URL}/api/brerules`, { + params: { name: mockRuleName }, + }); + expect(result).toEqual({}); + }); + + it('should handle error in _getKlammRuleFromName', async () => { + const mockRuleName = 'rule1'; + jest.spyOn(service.axiosKlammInstance, 'get').mockRejectedValue(new Error('Error')); + + await expect(service['_getKlammRuleFromName'](mockRuleName)).rejects.toThrow('Error getting rule rule1 from Klamm'); + }); + + it('should add rule in Klamm correctly', async () => { + const mockRulePayload = { name: 'rule1' } as KlammRulePayload; + jest.spyOn(service.axiosKlammInstance, 'post').mockResolvedValue(undefined); + + await service['_addRuleInKlamm'](mockRulePayload); + + expect(service.axiosKlammInstance.post).toHaveBeenCalledWith( + `${process.env.KLAMM_API_URL}/api/brerules`, + mockRulePayload, + ); + }); + + it('should handle error in _addRuleInKlamm', async () => { + const mockRulePayload = { name: 'rule1' } as KlammRulePayload; + jest.spyOn(service.axiosKlammInstance, 'post').mockRejectedValue(new Error('Error')); + + await expect(service['_addRuleInKlamm'](mockRulePayload)).rejects.toThrow('Error adding rule to Klamm'); + }); + + it('should update rule in Klamm correctly', async () => { + const mockRulePayload = { name: 'rule1' } as KlammRulePayload; + const mockRuleId = 1; + jest.spyOn(service.axiosKlammInstance, 'put').mockResolvedValue(undefined); + + await service['_updateRuleInKlamm'](mockRuleId, mockRulePayload); + + expect(service.axiosKlammInstance.put).toHaveBeenCalledWith( + `${process.env.KLAMM_API_URL}/api/brerules/${mockRuleId}`, + mockRulePayload, + ); + }); + + it('should handle error in _updateRuleInKlamm', async () => { + const mockRulePayload = { name: 'rule1' } as KlammRulePayload; + const mockRuleId = 1; + jest.spyOn(service.axiosKlammInstance, 'put').mockRejectedValue(new Error('Error')); + + await expect(service['_updateRuleInKlamm'](mockRuleId, mockRulePayload)).rejects.toThrow( + 'Error updating rule 1 in Klamm', + ); + }); + + it('should update last sync timestamp correctly', async () => { + jest.spyOn(klammSyncMetadata, 'findOneAndUpdate').mockResolvedValue(undefined); + + await service['_updateLastSyncTimestamp'](); + + expect(klammSyncMetadata.findOneAndUpdate).toHaveBeenCalledWith( + { key: 'singleton' }, + { lastSyncTimestamp: expect.any(Number) }, + { upsert: true, new: true }, + ); + }); + + it('should handle error in _updateLastSyncTimestamp', async () => { + jest.spyOn(klammSyncMetadata, 'findOneAndUpdate').mockRejectedValue(new Error('Error')); + + await expect(service['_updateLastSyncTimestamp']()).rejects.toThrow('Failed to update last sync timestamp'); + }); + + it('should get last sync timestamp correctly', async () => { + const mockTimestamp = 1234567890; + jest.spyOn(klammSyncMetadata, 'findOne').mockResolvedValue({ lastSyncTimestamp: mockTimestamp }); + + const result = await service['_getLastSyncTimestamp'](); + + expect(klammSyncMetadata.findOne).toHaveBeenCalledWith({ key: 'singleton' }); + expect(result).toEqual(mockTimestamp); + }); + + it('should handle error in _getLastSyncTimestamp', async () => { + jest.spyOn(klammSyncMetadata, 'findOne').mockRejectedValue(new Error('Error')); + + await expect(service['_getLastSyncTimestamp']()).rejects.toThrow('Failed to get last sync timestamp'); + }); + + it('should get Klamm BRE fields correctly', async () => { const searchText = 'test'; - await expect(service.getBREFields(searchText)).resolves.toEqual(responseData); - expect(mockedAxios.get).toHaveBeenCalledWith(`${process.env.KLAMM_API_URL}/api/brefields?search=${searchText}`); + const mockData = ['field1', 'field2']; + jest.spyOn(service.axiosKlammInstance, 'get').mockResolvedValue({ data: mockData }); + + const result = await service.getKlammBREFields(searchText); + + expect(service.axiosKlammInstance.get).toHaveBeenCalledWith( + `${process.env.KLAMM_API_URL}/api/brefields?search=${encodeURIComponent(searchText.trim())}`, + ); + expect(result).toEqual(mockData); }); - it('should throw HttpException on API call failure', async () => { - mockedAxios.get.mockRejectedValue(new Error('Error fetching from Klamm')); + it('should handle error in getKlammBREFields', async () => { + const searchText = 'test'; + jest.spyOn(service.axiosKlammInstance, 'get').mockRejectedValue(new Error('Error')); - await expect(service.getBREFields('test')).rejects.toThrow(HttpException); - await expect(service.getBREFields('test')).rejects.toThrow('Error fetching from Klamm'); + await expect(service.getKlammBREFields(searchText)).rejects.toThrow('Error fetching from Klamm'); }); - it('should return the first field data on successful API call', async () => { - const fieldName = 'testField'; - const responseData = { data: [{ id: 1, name: fieldName }] }; - mockedAxios.get.mockResolvedValue({ data: responseData }); + it('should get Klamm BRE field from name correctly', async () => { + const fieldName = 'field1'; + const mockData = [{ id: 1, name: 'field1' }]; + jest.spyOn(service.axiosKlammInstance, 'get').mockResolvedValue({ data: { data: mockData } }); + + const result = await service.getKlammBREFieldFromName(fieldName); - await expect(service.getBREFieldFromName(fieldName)).resolves.toEqual(responseData.data[0]); - expect(mockedAxios.get).toHaveBeenCalledWith(`${process.env.KLAMM_API_URL}/api/brefields`, { - params: { name: fieldName }, + expect(service.axiosKlammInstance.get).toHaveBeenCalledWith(`${process.env.KLAMM_API_URL}/api/brefields`, { + params: { name: encodeURIComponent(fieldName.trim()) }, }); + expect(result).toEqual(mockData[0]); }); - it('should throw HttpException with BAD_REQUEST if no field exists', async () => { - const fieldName = 'nonexistentField'; - mockedAxios.get.mockResolvedValue({ data: { data: [] } }); + it('should handle error in getKlammBREFieldFromName', async () => { + const fieldName = 'field1'; + jest.spyOn(service.axiosKlammInstance, 'get').mockRejectedValue(new Error('Error')); - await expect(service.getBREFieldFromName(fieldName)).rejects.toThrow(HttpException); - await expect(service.getBREFieldFromName(fieldName)).rejects.toThrow('Field name does not exist'); + await expect(service.getKlammBREFieldFromName(fieldName)).rejects.toThrow('Error fetching from Klamm'); }); - it('should throw HttpException with INTERNAL_SERVER_ERROR on API call failure', async () => { - const fieldName = 'testField'; - mockedAxios.get.mockRejectedValue(new Error('Error fetching from Klamm')); + it('should throw InvalidFieldRequest error if field name does not exist', async () => { + const fieldName = 'field1'; + jest.spyOn(service.axiosKlammInstance, 'get').mockResolvedValue({ data: { data: [] } }); - await expect(service.getBREFieldFromName(fieldName)).rejects.toThrow(HttpException); - await expect(service.getBREFieldFromName(fieldName)).rejects.toThrow('Error fetching from Klamm'); + await expect(service.getKlammBREFieldFromName(fieldName)).rejects.toThrow('Field name does not exist'); }); }); diff --git a/src/api/klamm/klamm.service.ts b/src/api/klamm/klamm.service.ts index 6b87e9a..618939e 100644 --- a/src/api/klamm/klamm.service.ts +++ b/src/api/klamm/klamm.service.ts @@ -1,5 +1,16 @@ import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { Model } from 'mongoose'; +import { InjectModel } from '@nestjs/mongoose'; import axios, { AxiosInstance } from 'axios'; +import { KlammField, KlammRulePayload, KlammRule } from './klamm'; +import { deriveNameFromFilepath } from '../../utils/helpers'; +import { RuleDataService } from '../ruleData/ruleData.service'; +import { RuleMappingService } from '../ruleMapping/ruleMapping.service'; +import { RuleData } from '../ruleData/ruleData.schema'; +import { DocumentsService } from '../documents/documents.service'; +import { KlammSyncMetadata, KlammSyncMetadataDocument } from './klammSyncMetadata.schema'; + +export const GITHUB_RULES_REPO = 'https://api.github.com/repos/bcgov/brms-rules'; export class InvalidFieldRequest extends Error { constructor(message: string) { @@ -11,14 +22,38 @@ export class InvalidFieldRequest extends Error { @Injectable() export class KlammService { axiosKlammInstance: AxiosInstance; + axiosGithubInstance: AxiosInstance; - constructor() { + constructor( + private readonly ruleDataService: RuleDataService, + private readonly ruleMappingService: RuleMappingService, + private readonly documentsService: DocumentsService, + @InjectModel(KlammSyncMetadata.name) private klammSyncMetadata: Model, + ) { this.axiosKlammInstance = axios.create({ headers: { Authorization: `Bearer ${process.env.KLAMM_API_AUTH_TOKEN}`, 'Content-Type': 'application/json' }, }); + const headers: Record = {}; + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + this.axiosGithubInstance = axios.create({ headers }); + } + + async onModuleInit() { + try { + console.info('Syncing existing rules with Klamm...'); + const updatedFilesSinceLastDeploy = await this._getUpdatedFilesFromGithub(); + console.info('Files updated since last deploy:', updatedFilesSinceLastDeploy); + await this._syncRules(updatedFilesSinceLastDeploy); + console.info('Completed syncing existing rules with Klamm'); + await this._updateLastSyncTimestamp(); + } catch (error) { + console.error('Unable to sync latest updates to Klamm:', error.message); + } } - async getBREFields(searchText: string): Promise { + async getKlammBREFields(searchText: string): Promise { try { const sanitizedSearchText = encodeURIComponent(searchText.trim()); const { data } = await this.axiosKlammInstance.get( @@ -30,7 +65,7 @@ export class KlammService { } } - async getBREFieldFromName(fieldName: string): Promise { + async getKlammBREFieldFromName(fieldName: string): Promise { try { const sanitizedFieldName = encodeURIComponent(fieldName.trim()); const { data } = await this.axiosKlammInstance.get(`${process.env.KLAMM_API_URL}/api/brefields`, { @@ -48,4 +83,193 @@ export class KlammService { throw new HttpException('Error fetching from Klamm', HttpStatus.INTERNAL_SERVER_ERROR); } } + + private async _syncRules(updatedFiles: string[]): Promise { + for (const ruleFilepath of updatedFiles) { + try { + const rule = await this.ruleDataService.getRuleDataByFilepath(ruleFilepath); + if (rule) { + await this._syncRuleWithKlamm(rule); + console.info(`Rule ${rule.name} synced`); + } else { + console.warn(`No rule found for changed file: ${ruleFilepath}`); + } + } catch (error) { + console.error(`Failed to sync rule from file ${ruleFilepath}:`, error.message); + throw new Error(`Failed to sync rule from file ${ruleFilepath}`); + } + } + } + + async _getUpdatedFilesFromGithub(): Promise { + try { + // Get last updated time from from db + const timestamp = await this._getLastSyncTimestamp(); + const date = new Date(timestamp); + const formattedDate = date.toISOString().split('.')[0] + 'Z'; // Format required for github api + // Fetch commits since the specified timestamp + const commitsResponse = await this.axiosGithubInstance.get( + `${GITHUB_RULES_REPO}/commits?since=${formattedDate}&sha=${process.env.GITHUB_RULES_BRANCH}`, + ); + const commits = commitsResponse.data; + // Fetch details for each commit to get the list of changed files + const updatedFiles = new Set(); + for (const commit of commits) { + const commitDetailsResponse = await this.axiosGithubInstance.get(commit.url); + const commitDetails = commitDetailsResponse.data; + if (commitDetails.files) { + for (const file of commitDetails.files) { + if (file.filename.startsWith('rules/')) { + updatedFiles.add(file.filename.replace(/^rules\//, '')); + } + } + } + } + return Array.from(updatedFiles); + } catch (error) { + console.error('Error fetching updated files from GitHub:', error); + throw new Error('Error fetching updated files from GitHub'); + } + } + + async _syncRuleWithKlamm(rule: RuleData) { + try { + // Get the inputs and outputs for the rule + const { inputs, outputs } = await this._getInputOutputFieldsData(rule); + // Get child rules of the rule + const childRules = await this._getChildRules(rule); + // Configure payload to add or update + const payloadToAddOrUpdate = { + name: rule.name, + label: rule.title, + rule_inputs: inputs, + rule_outputs: outputs, + child_rules: childRules, + }; + // Add or update the rule in Klamm + await this._addOrUpdateRuleInKlamm(payloadToAddOrUpdate); + } catch (error) { + console.error(`Error syncing ${rule.name}`, error.message); + throw new Error(`Error syncing ${rule.name}`); + } + } + + async _getInputOutputFieldsData(rule: RuleData): Promise<{ inputs: KlammField[]; outputs: KlammField[] }> { + try { + const { inputs, resultOutputs } = await this.ruleMappingService.inputOutputSchemaFile(rule.filepath); + const inputIds = inputs.map(({ id }) => Number(id)); + const outputIds = resultOutputs.map(({ id }) => Number(id)); + const inputResults = await this._getFieldsFromIds(inputIds); + const outputResults = await this._getFieldsFromIds(outputIds); + return { inputs: inputResults, outputs: outputResults }; + } catch (error) { + console.error(`Error getting input/output fields for rule ${rule.name}`, error.message); + throw new Error(`Error getting input/output fields for rule ${rule.name}`); + } + } + + async _getFieldsFromIds(ids: number[]): Promise { + try { + const promises = ids.map((id) => this._fetchFieldById(id)); + return await Promise.all(promises); + } catch (error) { + console.error(`Error fetching fields by IDs`, error.message); + throw new Error(`Error fetching fields by IDs: ${error.message}`); + } + } + + private async _fetchFieldById(id: number): Promise { + try { + const response = await this.axiosKlammInstance.get(`${process.env.KLAMM_API_URL}/api/brerules/${id}`); + return response.data; + } catch (error) { + if (error.response && error.response.status === 404) { + console.warn(`Field with ID ${id} not found`); + return null; + } else { + console.error(`Error fetching field with ID ${id}`, error.message); + throw new Error(`Error fetching field with ID ${id}: ${error.message}`); + } + } + } + + async _getChildRules(rule: RuleData) { + try { + const fileContent = await this.documentsService.getFileContent(rule.filepath); + const ruleContent = JSON.parse(fileContent.toString()); + const childNodeNames: string[] = ruleContent.nodes + .filter(({ type, content }) => type === 'decisionNode' && content?.key) + .map(({ content: { key } }) => deriveNameFromFilepath(key)); + const promises = childNodeNames.map((childNodeName) => this._getKlammRuleFromName(childNodeName)); + const results = await Promise.all(promises); + return results.map((response) => response); + } catch (error) { + console.error(`Error gettting child rules for ${rule.name}:`, error.message); + throw new Error(`Error gettting child rules for ${rule.name}`); + } + } + + async _addOrUpdateRuleInKlamm(rulePayload: KlammRulePayload) { + // Check if rule exists already and update it if it does, add it if it doesn't + const existingRule = await this._getKlammRuleFromName(rulePayload.name); + if (existingRule) { + this._updateRuleInKlamm(existingRule.id, rulePayload); + } else { + this._addRuleInKlamm(rulePayload); + } + } + + async _getKlammRuleFromName(ruleName: string): Promise { + try { + const { data } = await this.axiosKlammInstance.get(`${process.env.KLAMM_API_URL}/api/brerules`, { + params: { name: ruleName }, + }); + return data.data[0]; + } catch (error) { + console.error(`Error getting rule ${ruleName} from Klamm:`, error.message); + throw new Error(`Error getting rule ${ruleName} from Klamm`); + } + } + + async _addRuleInKlamm(rulePayload: KlammRulePayload) { + try { + await this.axiosKlammInstance.post(`${process.env.KLAMM_API_URL}/api/brerules`, rulePayload); + } catch (error) { + console.error('Error adding rule to Klamm:', error.message); + throw new Error('Error adding rule to Klamm'); + } + } + + async _updateRuleInKlamm(currentKlamRuleId: number, rulePayload: KlammRulePayload) { + try { + await this.axiosKlammInstance.put(`${process.env.KLAMM_API_URL}/api/brerules/${currentKlamRuleId}`, rulePayload); + } catch (error) { + console.error(`Error updating rule ${currentKlamRuleId} in Klamm:`, error.message); + throw new Error(`Error updating rule ${currentKlamRuleId} in Klamm`); + } + } + + async _updateLastSyncTimestamp(): Promise { + try { + const timestamp = Date.now(); + await this.klammSyncMetadata.findOneAndUpdate( + { key: 'singleton' }, + { lastSyncTimestamp: timestamp }, + { upsert: true, new: true }, + ); + } catch (error) { + console.error('Failed to update last sync timestamp', error.message); + throw new Error('Failed to update last sync timestamp'); + } + } + + private async _getLastSyncTimestamp(): Promise { + try { + const record = await this.klammSyncMetadata.findOne({ key: 'singleton' }); + return record ? record.lastSyncTimestamp : 0; + } catch (error) { + console.error('Failed to get last sync timestamp:', error.message); + throw new Error('Failed to get last sync timestamp'); + } + } } diff --git a/src/api/klamm/klammSyncMetadata.schema.ts b/src/api/klamm/klammSyncMetadata.schema.ts new file mode 100644 index 0000000..40a4f11 --- /dev/null +++ b/src/api/klamm/klammSyncMetadata.schema.ts @@ -0,0 +1,15 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +@Schema() +export class KlammSyncMetadata { + @Prop({ type: String, unique: true, default: 'singleton' }) // ensures only one copy of this document + key: string; + + @Prop({ type: Number, description: 'Timestamp of when the rules were last synced to Klamm' }) + lastSyncTimestamp: number; +} + +export type KlammSyncMetadataDocument = KlammSyncMetadata & Document; + +export const KlammSyncMetadataSchema = SchemaFactory.createForClass(KlammSyncMetadata); diff --git a/src/api/ruleData/ruleData.module.ts b/src/api/ruleData/ruleData.module.ts index e73b410..531357f 100644 --- a/src/api/ruleData/ruleData.module.ts +++ b/src/api/ruleData/ruleData.module.ts @@ -15,5 +15,6 @@ import { DocumentsService } from '../documents/documents.service'; ], controllers: [RuleDataController], providers: [RuleDataService, DocumentsService], + exports: [RuleDataService], }) export class RuleDataModule {} diff --git a/src/api/ruleData/ruleData.service.spec.ts b/src/api/ruleData/ruleData.service.spec.ts index 2bfd2f1..6b3bb51 100644 --- a/src/api/ruleData/ruleData.service.spec.ts +++ b/src/api/ruleData/ruleData.service.spec.ts @@ -79,6 +79,11 @@ describe('RuleDataService', () => { expect(await service.getRuleData(mockRuleData._id)).toEqual(mockRuleData); }); + it('should get data for a rule by filepath', async () => { + MockRuleDataModel.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(mockRuleData) }); + expect(await service.getRuleDataByFilepath(mockRuleData.filepath)).toEqual(mockRuleData); + }); + it('should create rule data', async () => { expect(await service.createRuleData(mockRuleData)).toEqual(mockRuleData); }); diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index 2229ad5..c2611f1 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -53,6 +53,18 @@ export class RuleDataService { } } + async getRuleDataByFilepath(filepath: string): Promise { + try { + const ruleData = await this.ruleDataModel.findOne({ filepath }).exec(); + if (!ruleData) { + throw new Error('Rule data not found'); + } + return ruleData; + } catch (error) { + throw new Error(`Error getting all rule data for ${filepath}: ${error.message}`); + } + } + async _addOrUpdateDraft(ruleData: Partial): Promise> { // If there is a rule draft, update that document specifically // This is necessary because we don't store the draft on the ruleData object directly @@ -116,16 +128,24 @@ export class RuleDataService { * Remove current reviewBranch if it no longer exists (aka review branch has been merged in and removed) */ async updateInReviewStatus(existingRules: RuleData[]) { - // Get current branches from github - const branchesResponse = await axios.get('https://api.github.com/repos/bcgov/brms-rules/branches'); - const currentBranches = branchesResponse?.data.map(({ name }) => name); - // Remove current reviewBranch if it no longer exists - if (currentBranches) { - existingRules.forEach(({ _id, reviewBranch }) => { - if (reviewBranch && !currentBranches.includes(reviewBranch)) { - this.updateRuleData(_id, { reviewBranch: null }); - } - }); + try { + // Get current branches from github + const headers: Record = {}; + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + const branchesResponse = await axios.get('https://api.github.com/repos/bcgov/brms-rules/branches', { headers }); + const currentBranches = branchesResponse?.data.map(({ name }) => name); + // Remove current reviewBranch if it no longer exists + if (currentBranches) { + existingRules.forEach(({ _id, reviewBranch }) => { + if (reviewBranch && !currentBranches.includes(reviewBranch)) { + this.updateRuleData(_id, { reviewBranch: null }); + } + }); + } + } catch (error) { + console.error('Error updating review status:', error.message); } } diff --git a/src/api/ruleMapping/ruleMapping.module.ts b/src/api/ruleMapping/ruleMapping.module.ts new file mode 100644 index 0000000..399c82c --- /dev/null +++ b/src/api/ruleMapping/ruleMapping.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { RuleMappingController } from './ruleMapping.controller'; +import { RuleMappingService } from './ruleMapping.service'; +import { DocumentsService } from '../documents/documents.service'; + +@Module({ + controllers: [RuleMappingController], + providers: [RuleMappingService, DocumentsService], + exports: [RuleMappingService], +}) +export class RuleMappingModule {} diff --git a/src/app.module.ts b/src/app.module.ts index adcc4a0..d341f79 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,17 +3,15 @@ import { ConfigModule } from '@nestjs/config'; import { MongooseModule } from '@nestjs/mongoose'; import { GithubAuthModule } from './auth/github-auth/github-auth.module'; import { RuleDataModule } from './api/ruleData/ruleData.module'; +import { RuleMappingModule } from './api/ruleMapping/ruleMapping.module'; +import { KlammModule } from './api/klamm/klamm.module'; import { DecisionsController } from './api/decisions/decisions.controller'; import { DecisionsService } from './api/decisions/decisions.service'; import { DocumentsController } from './api/documents/documents.controller'; import { DocumentsService } from './api/documents/documents.service'; -import { RuleMappingController } from './api/ruleMapping/ruleMapping.controller'; -import { RuleMappingService } from './api/ruleMapping/ruleMapping.service'; import { ScenarioData, ScenarioDataSchema } from './api/scenarioData/scenarioData.schema'; import { ScenarioDataController } from './api/scenarioData/scenarioData.controller'; import { ScenarioDataService } from './api/scenarioData/scenarioData.service'; -import { KlammController } from './api/klamm/klamm.controller'; -import { KlammService } from './api/klamm/klamm.service'; @Module({ imports: [ @@ -29,14 +27,10 @@ import { KlammService } from './api/klamm/klamm.service'; MongooseModule.forFeature([{ name: ScenarioData.name, schema: ScenarioDataSchema }]), GithubAuthModule, RuleDataModule, + RuleMappingModule, + KlammModule, ], - controllers: [ - DecisionsController, - DocumentsController, - RuleMappingController, - ScenarioDataController, - KlammController, - ], - providers: [DecisionsService, DocumentsService, RuleMappingService, ScenarioDataService, KlammService], + controllers: [DecisionsController, DocumentsController, ScenarioDataController], + providers: [DecisionsService, DocumentsService, ScenarioDataService], }) export class AppModule {} From 1c042ce4f8db9ff83aea3d12bcdb08a4e721c29e Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:17:29 -0700 Subject: [PATCH 07/15] Reorganize onModuleInit flow. --- src/api/ruleData/ruleData.service.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index e0e8348..ed1af12 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -18,6 +18,15 @@ export class RuleDataService { private documentsService: DocumentsService, ) {} + async onModuleInit() { + console.info('Syncing existing rules with any updates to the rules repository'); + const existingRules = await this.getAllRuleData(); + const { data: existingRuleData } = existingRules; + this.updateCategories(existingRuleData); + this.updateInReviewStatus(existingRuleData); + this.addUnsyncedFiles(existingRuleData); + } + private updateCategories(ruleData: RuleData[]) { const filePathsArray = ruleData.map((filePath) => filePath.filepath); const splitFilePaths = filePathsArray.map((filepath) => { @@ -28,14 +37,6 @@ export class RuleDataService { this.categories = categorySet.map((category: string) => ({ text: category, value: category })); } - async onModuleInit() { - console.info('Syncing existing rules with any updates to the rules repository'); - const existingRules = await this.getAllRuleData(); - const { data: existingRuleData } = existingRules; - this.updateCategories(existingRuleData); - this.updateInReviewStatus(existingRuleData); - this.addUnsyncedFiles(existingRuleData); - } async getAllRuleData( params: PaginationDto = { page: 1, pageSize: 5000 }, ): Promise<{ data: RuleData[]; total: number; categories: Array }> { From b58284dcf592aecc9bdc5306199130352cfaa3dc Mon Sep 17 00:00:00 2001 From: timwekkenbc Date: Thu, 17 Oct 2024 08:50:34 -0700 Subject: [PATCH 08/15] Minor fixes to klamm sync updates --- src/api/klamm/klamm.service.ts | 8 ++++++-- src/api/ruleData/ruleData.service.ts | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/api/klamm/klamm.service.ts b/src/api/klamm/klamm.service.ts index 618939e..aa72443 100644 --- a/src/api/klamm/klamm.service.ts +++ b/src/api/klamm/klamm.service.ts @@ -10,7 +10,7 @@ import { RuleData } from '../ruleData/ruleData.schema'; import { DocumentsService } from '../documents/documents.service'; import { KlammSyncMetadata, KlammSyncMetadataDocument } from './klammSyncMetadata.schema'; -export const GITHUB_RULES_REPO = 'https://api.github.com/repos/bcgov/brms-rules'; +export const GITHUB_RULES_REPO = process.env.GITHUB_RULES_REPO || 'https://api.github.com/repos/bcgov/brms-rules'; export class InvalidFieldRequest extends Error { constructor(message: string) { @@ -85,6 +85,7 @@ export class KlammService { } private async _syncRules(updatedFiles: string[]): Promise { + const errors: string[] = []; for (const ruleFilepath of updatedFiles) { try { const rule = await this.ruleDataService.getRuleDataByFilepath(ruleFilepath); @@ -96,9 +97,12 @@ export class KlammService { } } catch (error) { console.error(`Failed to sync rule from file ${ruleFilepath}:`, error.message); - throw new Error(`Failed to sync rule from file ${ruleFilepath}`); + errors.push(`Failed to sync rule from file ${ruleFilepath}: ${error.message}`); } } + if (errors.length > 0) { + throw new Error(`Errors occurred during rule sync: ${errors.join('; ')}`); + } } async _getUpdatedFilesFromGithub(): Promise { diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index c2611f1..101f739 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -8,6 +8,8 @@ import { RuleData, RuleDataDocument } from './ruleData.schema'; import { RuleDraft, RuleDraftDocument } from './ruleDraft.schema'; import { deriveNameFromFilepath } from '../../utils/helpers'; +const GITHUB_RULES_REPO = process.env.GITHUB_RULES_REPO || 'https://api.github.com/repos/bcgov/brms-rules'; + @Injectable() export class RuleDataService { constructor( @@ -134,7 +136,7 @@ export class RuleDataService { if (process.env.GITHUB_TOKEN) { headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; } - const branchesResponse = await axios.get('https://api.github.com/repos/bcgov/brms-rules/branches', { headers }); + const branchesResponse = await axios.get(`${GITHUB_RULES_REPO}/branches`, { headers }); const currentBranches = branchesResponse?.data.map(({ name }) => name); // Remove current reviewBranch if it no longer exists if (currentBranches) { From acade0e86f58621b09b0827d72869f05a0f24f22 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Thu, 17 Oct 2024 09:27:09 -0700 Subject: [PATCH 09/15] Revert "Revert "Draft decision validation error checks."" This reverts commit af09bf12acdb303ce3bdce790de14131790b3ca2. --- src/api/decisions/decisions.controller.ts | 7 +- src/api/decisions/decisions.service.spec.ts | 6 +- src/api/decisions/decisions.service.ts | 13 +- .../validations/validationError.service.ts | 19 ++ .../validations/validations.service.spec.ts | 284 ++++++++++++++++ .../validations/validations.service.ts | 311 ++++++++++++++++++ 6 files changed, 636 insertions(+), 4 deletions(-) create mode 100644 src/api/decisions/validations/validationError.service.ts create mode 100644 src/api/decisions/validations/validations.service.spec.ts create mode 100644 src/api/decisions/validations/validations.service.ts diff --git a/src/api/decisions/decisions.controller.ts b/src/api/decisions/decisions.controller.ts index 398b96b..930aae2 100644 --- a/src/api/decisions/decisions.controller.ts +++ b/src/api/decisions/decisions.controller.ts @@ -1,6 +1,7 @@ import { Controller, Post, Query, Body, HttpException, HttpStatus } from '@nestjs/common'; import { DecisionsService } from './decisions.service'; import { EvaluateDecisionDto, EvaluateDecisionWithContentDto } from './dto/evaluate-decision.dto'; +import { ValidationError } from './validations/validationError.service'; @Controller('api/decisions') export class DecisionsController { @@ -11,7 +12,11 @@ export class DecisionsController { try { return await this.decisionsService.runDecisionByContent(ruleContent, context, { trace }); } catch (error) { - throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + if (error instanceof ValidationError) { + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + } else { + throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } } } diff --git a/src/api/decisions/decisions.service.spec.ts b/src/api/decisions/decisions.service.spec.ts index d67250f..6b76022 100644 --- a/src/api/decisions/decisions.service.spec.ts +++ b/src/api/decisions/decisions.service.spec.ts @@ -2,6 +2,7 @@ import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { ZenEngine, ZenDecision, ZenEvaluateOptions } from '@gorules/zen-engine'; import { DecisionsService } from './decisions.service'; +import { ValidationService } from './validations/validations.service'; import { readFileSafely } from '../../utils/readFile'; jest.mock('../../utils/readFile', () => ({ @@ -11,6 +12,7 @@ jest.mock('../../utils/readFile', () => ({ describe('DecisionsService', () => { let service: DecisionsService; + let validationService: ValidationService; let mockEngine: Partial; let mockDecision: Partial; @@ -23,11 +25,13 @@ describe('DecisionsService', () => { }; const module: TestingModule = await Test.createTestingModule({ - providers: [ConfigService, DecisionsService, { provide: ZenEngine, useValue: mockEngine }], + providers: [ConfigService, DecisionsService, ValidationService, { provide: ZenEngine, useValue: mockEngine }], }).compile(); service = module.get(DecisionsService); + validationService = module.get(ValidationService); service.engine = mockEngine as ZenEngine; + jest.spyOn(validationService, 'validateInputs').mockImplementation(() => {}); }); describe('runDecision', () => { diff --git a/src/api/decisions/decisions.service.ts b/src/api/decisions/decisions.service.ts index ce3ea7f..1894465 100644 --- a/src/api/decisions/decisions.service.ts +++ b/src/api/decisions/decisions.service.ts @@ -3,6 +3,8 @@ import { ZenEngine, ZenEvaluateOptions } from '@gorules/zen-engine'; import { ConfigService } from '@nestjs/config'; import { RuleContent } from '../ruleMapping/ruleMapping.interface'; import { readFileSafely, FileNotFoundError } from '../../utils/readFile'; +import { ValidationService } from './validations/validations.service'; +import { ValidationError } from './validations/validationError.service'; @Injectable() export class DecisionsService { @@ -16,12 +18,19 @@ export class DecisionsService { } async runDecisionByContent(ruleContent: RuleContent, context: object, options: ZenEvaluateOptions) { + const validator = new ValidationService(); + const ruleInputs = ruleContent?.nodes?.filter((node) => node.type === 'inputNode')[0]?.content; try { + validator.validateInputs(ruleInputs, context); const decision = this.engine.createDecision(ruleContent); return await decision.evaluate(context, options); } catch (error) { - console.error(error.message); - throw new Error(`Failed to run decision: ${error.message}`); + if (error instanceof ValidationError) { + throw new ValidationError(`Invalid input: ${error.message}`); + } else { + console.error(error.message); + throw new Error(`Failed to run decision: ${error.message}`); + } } } diff --git a/src/api/decisions/validations/validationError.service.ts b/src/api/decisions/validations/validationError.service.ts new file mode 100644 index 0000000..183057e --- /dev/null +++ b/src/api/decisions/validations/validationError.service.ts @@ -0,0 +1,19 @@ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + Object.setPrototypeOf(this, ValidationError.prototype); + } + + getErrorCode(): string { + return 'VALIDATION_ERROR'; + } + + toJSON(): object { + return { + name: this.name, + message: this.message, + code: this.getErrorCode(), + }; + } +} diff --git a/src/api/decisions/validations/validations.service.spec.ts b/src/api/decisions/validations/validations.service.spec.ts new file mode 100644 index 0000000..41913a1 --- /dev/null +++ b/src/api/decisions/validations/validations.service.spec.ts @@ -0,0 +1,284 @@ +import { ValidationService } from './validations.service'; +import { ValidationError } from '../validations/validationError.service'; + +describe('ValidationService', () => { + let validator: ValidationService; + + beforeEach(() => { + validator = new ValidationService(); + }); + + describe('validateInputs', () => { + it('should validate valid inputs', () => { + const ruleContent = { + fields: [ + { field: 'age', type: 'number-input' }, + { field: 'name', type: 'text-input' }, + ], + }; + const context = { age: 25, name: 'John Doe' }; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + }); + + describe('validateField', () => { + it('should validate number input', () => { + const field = { field: 'age', type: 'number-input' }; + const context = { age: 25 }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + + it('should validate date input', () => { + const field = { field: 'birthDate', type: 'date' }; + const context = { birthDate: '2000-01-01' }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + + it('should validate text input', () => { + const field = { field: 'name', type: 'text-input' }; + const context = { name: 'John Doe' }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + + it('should validate boolean input', () => { + const field = { field: 'isActive', type: 'true-false' }; + const context = { isActive: true }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + + it('should throw ValidationError for invalid input type', () => { + const field = { field: 'age', type: 'number-input' }; + const context = { age: 'twenty-five' }; + expect(() => validator['validateField'](field, context)).toThrow(ValidationError); + }); + }); + + describe('validateNumberCriteria', () => { + it('should validate equal to criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '==', validationCriteria: '25' }; + expect(() => validator['validateNumberCriteria'](field, 25)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 26)).toThrow(ValidationError); + }); + + it('should validate not equal to criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '!=', validationCriteria: '25' }; + expect(() => validator['validateNumberCriteria'](field, 26)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 25)).toThrow(ValidationError); + }); + + it('should validate greater than or equal to criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '>=', validationCriteria: '18' }; + expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 17)).toThrow(ValidationError); + }); + + it('should validate greater than criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '>', validationCriteria: '18' }; + expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 17)).toThrow(ValidationError); + }); + + it('should validate less than or equal to criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '<=', validationCriteria: '18' }; + expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 19)).toThrow(ValidationError); + }); + + it('should validate less than criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '<', validationCriteria: '18' }; + expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 18)).toThrow(ValidationError); + }); + + it('should validate between exclusive criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '(num)', validationCriteria: '[16, 20]' }; + expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 16)).toThrow(ValidationError); + expect(() => validator['validateNumberCriteria'](field, 20)).toThrow(ValidationError); + expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); + }); + + it('should validate between inclusive criteria', () => { + const field = { field: 'age', type: 'number-input', validationType: '[num]', validationCriteria: '[16, 20]' }; + expect(() => validator['validateNumberCriteria'](field, 16)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 17)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 19)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 15)).toThrow(ValidationError); + expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); + }); + + it('should validate multiple criteria', () => { + const field = { + field: 'age', + type: 'number-input', + validationType: '[=num]', + validationCriteria: '[18, 20]', + }; + expect(() => validator['validateNumberCriteria'](field, 18)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 20)).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, 21)).toThrow(ValidationError); + }); + + it('should validate multiple criteria with multiple values', () => { + const field = { + field: 'age', + type: 'number-input', + validationType: '[=nums]', + validationCriteria: '[18, 20]', + }; + expect(() => validator['validateNumberCriteria'](field, [18])).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, [18, 20])).not.toThrow(); + expect(() => validator['validateNumberCriteria'](field, [19, 20])).toThrow(ValidationError); + }); + }); + + describe('validateDateCriteria', () => { + it('should validate equal to criteria', () => { + const field = { field: 'date', type: 'date', validationType: '==', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); + }); + + it('should validate not equal to criteria', () => { + const field = { field: 'date', type: 'date', validationType: '!=', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); + }); + + it('should validate greater than criteria', () => { + const field = { field: 'date', type: 'date', validationType: '>', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); + }); + + it('should validate greater than or equal to criteria', () => { + const field = { field: 'date', type: 'date', validationType: '>=', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2022-01-01')).toThrow(ValidationError); + }); + + it('should validate less than criteria', () => { + const field = { field: 'date', type: 'date', validationType: '<', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2022-12-31')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); + }); + + it('should validate less than or equal to criteria', () => { + const field = { field: 'date', type: 'date', validationType: '<=', validationCriteria: '2023-01-01' }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); + }); + + it('should validate between exclusive criteria', () => { + const field = { + field: 'date', + type: 'date', + validationType: '(date)', + validationCriteria: '[2023-01-01, 2024-01-03]', + }; + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2024-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-06-05')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2024-01-04')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).toThrow(ValidationError); + }); + + it('should validate between inclusive criteria', () => { + const field = { + field: 'date', + type: 'date', + validationType: '[date]', + validationCriteria: '[2023-01-01, 2023-01-03]', + }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-03')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-04')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2022-12-31')).toThrow(ValidationError); + }); + + it('should validate multiple criteria with single dates', () => { + const field = { + field: 'date', + type: 'date', + validationType: '[=date]', + validationCriteria: '[2023-01-01, 2023-01-03]', + }; + expect(() => validator['validateDateCriteria'](field, '2023-01-01')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-03')).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, '2023-01-02')).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, '2023-01-04')).toThrow(ValidationError); + }); + + it('should validate multiple criteria with multiple dates', () => { + const field = { + field: 'date', + type: 'date', + validationType: '[=dates]', + validationCriteria: '[2023-01-01, 2023-01-03]', + }; + expect(() => validator['validateDateCriteria'](field, ['2023-01-01'])).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-03'])).not.toThrow(); + expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-02'])).toThrow(ValidationError); + expect(() => validator['validateDateCriteria'](field, ['2023-01-01', '2023-01-04'])).toThrow(ValidationError); + }); + }); + + describe('validateTextCriteria', () => { + it('should validate equal to criteria', () => { + const field = { field: 'status', type: 'text-input', validationType: '==', validationCriteria: 'active' }; + expect(() => validator['validateTextCriteria'](field, 'active')).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, 'inactive')).toThrow(ValidationError); + }); + + it('should validate not equal to criteria', () => { + const field = { field: 'status', type: 'text-input', validationType: '!=', validationCriteria: 'active' }; + expect(() => validator['validateTextCriteria'](field, 'inactive')).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, 'active')).toThrow(ValidationError); + }); + + it('should validate regex criteria', () => { + const field = { + field: 'email', + type: 'text-input', + validationType: 'regex', + validationCriteria: '^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$', + }; + expect(() => validator['validateTextCriteria'](field, 'test@example.com')).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, 'invalid-email')).toThrow(ValidationError); + }); + + it('should validate multiple criteria with single text', () => { + const field = { + field: 'status', + type: 'text-input', + validationType: '[=text]', + validationCriteria: 'active,testing', + }; + expect(() => validator['validateTextCriteria'](field, 'active')).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, 'inactive')).toThrow(ValidationError); + }); + + it('should validate multiple criteria with multiple texts', () => { + const field = { + field: 'status', + type: 'text-input', + validationType: '[=texts]', + validationCriteria: 'active,inactive', + }; + expect(() => validator['validateTextCriteria'](field, ['active'])).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, ['active', 'inactive'])).not.toThrow(); + expect(() => validator['validateTextCriteria'](field, ['active', 'inactive', 'testing'])).toThrow( + ValidationError, + ); + }); + }); +}); diff --git a/src/api/decisions/validations/validations.service.ts b/src/api/decisions/validations/validations.service.ts new file mode 100644 index 0000000..3ed67e7 --- /dev/null +++ b/src/api/decisions/validations/validations.service.ts @@ -0,0 +1,311 @@ +import { Injectable } from '@nestjs/common'; +import { ValidationError } from '../validations/validationError.service'; + +@Injectable() +export class ValidationService { + validateInputs(ruleContent: any, context: Record): void { + if (typeof ruleContent !== 'object' || !Array.isArray(ruleContent.fields)) { + return; + } + + for (const field of ruleContent.fields) { + this.validateField(field, context); + } + } + + private validateField(field: any, context: Record): void { + if (!field.field || typeof field.field !== 'string') { + throw new ValidationError(`Invalid field definition: ${JSON.stringify(field)}`); + } + + if (!(field.field in context)) { + return; + } + + const input = context[field.field]; + + if (input === null || input === undefined) { + return; + } + + this.validateType(field, input); + this.validateCriteria(field, input); + + if (Array.isArray(field.childFields)) { + for (const childField of field.childFields) { + this.validateField(childField, input); + } + } + } + + private validateType(field: any, input: any): void { + const { dataType, type, validationType } = field; + const actualType = typeof input; + + switch (type || dataType) { + case 'number-input': + if (typeof input !== 'number' && validationType !== '[=nums]') { + throw new ValidationError(`Input ${field.field} should be a number, but got ${actualType}`); + } else if (validationType === '[=nums]' && !Array.isArray(input)) { + throw new ValidationError(`Input ${field.field} should be an array of numbers, but got ${actualType}`); + } + break; + case 'date': + if ((typeof input !== 'string' && validationType !== '[=dates]') || isNaN(Date.parse(input))) { + throw new ValidationError(`Input ${field.field} should be a valid date string, but got ${input}`); + } else if (validationType === '[=dates]' && !Array.isArray(input)) { + throw new ValidationError(`Input ${field.field} should be an array of date strings, but got ${input}`); + } + break; + case 'text-input': + if (typeof input !== 'string' && validationType !== '[=texts]') { + throw new ValidationError(`Input ${field.field} should be a string, but got ${actualType}`); + } else if (validationType === '[=texts]' && !Array.isArray(input)) { + throw new ValidationError(`Input ${field.field} should be an array of strings, but got ${actualType}`); + } + break; + case 'true-false': + if (typeof input !== 'boolean') { + throw new ValidationError(`Input ${field.field} should be a boolean, but got ${actualType}`); + } + break; + } + } + + private validateCriteria(field: any, input: any): void { + const { dataType, type } = field; + + switch (type || dataType) { + case 'number-input': + this.validateNumberCriteria(field, input); + break; + case 'date': + this.validateDateCriteria(field, input); + break; + case 'text-input': + this.validateTextCriteria(field, input); + break; + } + } + + private validateNumberCriteria(field: any, input: number | number[]): void { + const { validationCriteria, validationType } = field; + const numberValues = validationCriteria + ? validationCriteria + .replace(/[\[\]]/g, '') + .split(',') + .map((val: string) => parseFloat(val.trim())) + : []; + + const validationValue = parseFloat(validationCriteria) || 0; + const [minValue, maxValue] = + numberValues.length > 1 ? [numberValues[0], numberValues[numberValues.length - 1]] : [0, 10]; + + const numberInput = typeof input === 'number' ? parseFloat(input.toFixed(2)) : null; + + switch (validationType) { + case '==': + if (numberInput !== parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must equal ${validationValue}`); + } + break; + case '!=': + if (numberInput === parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must not equal ${validationValue}`); + } + break; + case '>=': + if (numberInput < parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must be greater than or equal to ${validationValue}`); + } + break; + case '<=': + if (numberInput > parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must be less than or equal to ${validationValue}`); + } + break; + case '>': + if (numberInput <= parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must be greater than ${validationValue}`); + } + break; + case '<': + if (numberInput >= parseFloat(validationValue.toFixed(2))) { + throw new ValidationError(`Input ${field.field} must be less than ${validationValue}`); + } + break; + case '(num)': + if (numberInput <= minValue || numberInput >= maxValue) { + throw new ValidationError(`Input ${field.field} must be between ${minValue} and ${maxValue} (exclusive)`); + } + break; + case '[num]': + if (numberInput < minValue || numberInput > maxValue) { + throw new ValidationError(`Input ${field.field} must be between ${minValue} and ${maxValue} (inclusive)`); + } + break; + case '[=num]': + if (!numberValues.includes(numberInput)) { + throw new ValidationError(`Input ${field.field} must be one of: ${numberValues.join(', ')}`); + } + break; + case '[=nums]': + if (!Array.isArray(input) || !input.every((inp: number) => numberValues.includes(parseFloat(inp.toFixed(2))))) { + throw new ValidationError( + `Input ${field.field} must be an array with values from: ${numberValues.join(', ')}`, + ); + } + break; + } + } + + private validateDateCriteria(field: any, input: string | string[]): void { + const { validationCriteria, validationType } = field; + const dateValues = validationCriteria + ? validationCriteria + .replace(/[\[\]]/g, '') + .split(',') + .map((val: string) => new Date(val.trim()).getTime()) + : []; + + const dateValidationValue = new Date(validationCriteria).getTime() || new Date().getTime(); + const [minDate, maxDate] = + dateValues.length > 1 + ? [dateValues[0], dateValues[dateValues.length - 1]] + : [new Date().getTime(), new Date().setFullYear(new Date().getFullYear() + 1)]; + + const dateInput = typeof input === 'string' ? new Date(input).getTime() : null; + + switch (validationType) { + case '==': + if (dateInput !== dateValidationValue) { + throw new ValidationError(`Input ${field.field} must equal ${new Date(dateValidationValue).toISOString()}`); + } + break; + case '!=': + if (dateInput === dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must not equal ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '>=': + if (dateInput < dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must be on or after ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '<=': + if (dateInput > dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must be on or before ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '>': + if (dateInput <= dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must be after ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '<': + if (dateInput >= dateValidationValue) { + throw new ValidationError( + `Input ${field.field} must be before ${new Date(dateValidationValue).toISOString()}`, + ); + } + break; + case '(date)': + if (dateInput <= minDate || dateInput >= maxDate) { + throw new ValidationError( + `Input ${field.field} must be between ${new Date(minDate).toISOString()} and ${new Date(maxDate).toISOString()} (exclusive)`, + ); + } + break; + case '[date]': + if (dateInput < minDate || dateInput > maxDate) { + throw new ValidationError( + `Input ${field.field} must be between ${new Date(minDate).toISOString()} and ${new Date(maxDate).toISOString()} (inclusive)`, + ); + } + break; + case '[=date]': + if (!dateValues.includes(dateInput)) { + throw new ValidationError( + `Input ${field.field} must be one of: ${dateValues.map((d) => new Date(d).toISOString()).join(', ')}`, + ); + } + break; + case '[=dates]': + if (!Array.isArray(input) || !input.every((inp: string) => dateValues.includes(new Date(inp).getTime()))) { + throw new ValidationError( + `Input ${field.field} must be an array with dates from: ${dateValues.map((d) => new Date(d).toISOString()).join(', ')}`, + ); + } + break; + } + } + + private validateTextCriteria(field: any, input: string | string[]): void { + const { validationCriteria, validationType } = field; + + switch (validationType) { + case '==': + if (input !== validationCriteria) { + throw new ValidationError(`Input ${field.field} must equal "${validationCriteria}"`); + } + break; + case '!=': + if (input === validationCriteria) { + throw new ValidationError(`Input ${field.field} must not equal "${validationCriteria}"`); + } + break; + case 'regex': + if (typeof input === 'string') { + try { + const regex = new RegExp(validationCriteria); + if (!regex.test(input)) { + throw new ValidationError(`Input ${field.field} must match the pattern: ${validationCriteria}`); + } + } catch (e) { + throw new ValidationError(`Invalid regex pattern for ${field.field}: ${validationCriteria}`); + } + break; + } + case '[=text]': + if (typeof input === 'string') { + const validTexts = validationCriteria + .replace(/[\[\]]/g, '') + .split(',') + .map((val: string) => val.trim()); + if (!validTexts.includes(input.trim())) { + throw new ValidationError(`Input ${field.field} must be one of: ${validTexts.join(', ')}`); + } + break; + } + case '[=texts]': + const validTextArray = validationCriteria + .replace(/[\[\]]/g, '') + .split(',') + .map((val: string) => val.trim()); + const inputArray = Array.isArray(input) + ? input + : input + .replace(/[\[\]]/g, '') + .split(',') + .map((val) => val.trim()); + if (!inputArray.every((inp: string) => validTextArray.includes(inp))) { + throw new ValidationError( + `Input ${field.field} must be on or many of the values from: ${validTextArray.join(', ')}`, + ); + } + break; + } + } + + validateOutput(outputSchema: any, output: any): void { + this.validateField(outputSchema, { output }); + } +} From 02f7e7020c6ece8d10ac3d8be5fd086a019658f8 Mon Sep 17 00:00:00 2001 From: timwekkenbc Date: Thu, 17 Oct 2024 13:45:42 -0700 Subject: [PATCH 10/15] Fix practical issues with klamm sync --- src/api/klamm/klamm.service.spec.ts | 60 ++++++++++-------------- src/api/klamm/klamm.service.ts | 70 ++++++++++++++++------------ src/api/ruleData/ruleData.service.ts | 3 -- 3 files changed, 63 insertions(+), 70 deletions(-) diff --git a/src/api/klamm/klamm.service.spec.ts b/src/api/klamm/klamm.service.spec.ts index 5672b2c..526754a 100644 --- a/src/api/klamm/klamm.service.spec.ts +++ b/src/api/klamm/klamm.service.spec.ts @@ -37,7 +37,9 @@ describe('KlammService', () => { }); it('should initialize module correctly', async () => { - jest.spyOn(service as any, '_getUpdatedFilesFromGithub').mockResolvedValue(['file1.js']); + jest + .spyOn(service as any, '_getUpdatedFilesFromGithub') + .mockResolvedValue({ updatedFilesSinceLastDeploy: ['file1.js'], lastCommitAsyncTimestamp: 0 }); jest.spyOn(service as any, '_syncRules').mockResolvedValue(undefined); jest.spyOn(service as any, '_updateLastSyncTimestamp').mockResolvedValue(undefined); @@ -87,7 +89,10 @@ describe('KlammService', () => { it('should get updated files from GitHub correctly', async () => { const mockFiles = ['file1.js', 'file2.js']; const mockCommits = [{ url: 'commit1' }, { url: 'commit2' }]; - const mockCommitDetails = { files: [{ filename: 'rules/file1.js' }, { filename: 'rules/file2.js' }] }; + const mockCommitDetails = { + files: [{ filename: 'rules/file1.js' }, { filename: 'rules/file2.js' }], + commit: { author: { date: 1234567790 } }, + }; jest.spyOn(service as any, '_getLastSyncTimestamp').mockResolvedValue(1234567890); jest .spyOn(service.axiosGithubInstance, 'get') @@ -101,7 +106,7 @@ describe('KlammService', () => { expect(service.axiosGithubInstance.get).toHaveBeenCalledWith( `${GITHUB_RULES_REPO}/commits?since=${new Date(1234567890).toISOString().split('.')[0]}Z&sha=${process.env.GITHUB_RULES_BRANCH}`, ); - expect(result).toEqual(mockFiles); + expect(result.updatedFilesSinceLastDeploy).toEqual(mockFiles); }); it('should handle error in _getUpdatedFilesFromGithub', async () => { @@ -143,12 +148,13 @@ describe('KlammService', () => { const mockRule = { name: 'rule1', filepath: 'file1.js' } as RuleData; const mockInputsOutputs = { inputs: [], resultOutputs: [] }; jest.spyOn(ruleMappingService, 'inputOutputSchemaFile').mockResolvedValue(mockInputsOutputs); - jest.spyOn(service as any, '_getFieldsFromIds').mockResolvedValue([]); + jest.spyOn(service as any, '_getAllKlammFields').mockResolvedValue([]); + jest.spyOn(service as any, '_getFieldsFromIds').mockReturnValue([]); const result = await service['_getInputOutputFieldsData'](mockRule); expect(ruleMappingService.inputOutputSchemaFile).toHaveBeenCalledWith(mockRule.filepath); - expect(service['_getFieldsFromIds']).toHaveBeenCalledWith([]); + expect(service['_getFieldsFromIds']).toHaveBeenCalledWith([], []); expect(result).toEqual({ inputs: [], outputs: [] }); }); @@ -163,36 +169,16 @@ describe('KlammService', () => { it('should get fields from IDs correctly', async () => { const mockIds = [1, 2, 3]; - jest.spyOn(service as any, '_fetchFieldById').mockResolvedValue({}); - - const result = await service['_getFieldsFromIds'](mockIds); - - expect(service['_fetchFieldById']).toHaveBeenCalledTimes(mockIds.length); - expect(result).toEqual([{}, {}, {}]); - }); + const mockFields = [ + { id: 1, name: 'field1', label: 'Field 1', description: 'Description 1' }, + { id: 2, name: 'field2', label: 'Field 2', description: 'Description 2' }, + { id: 3, name: 'field3', label: 'Field 3', description: 'Description 3' }, + ]; + jest.spyOn(service as any, '_getAllKlammFields').mockResolvedValue(mockFields); - it('should handle error in _getFieldsFromIds', async () => { - const mockIds = [1, 2, 3]; - jest.spyOn(service as any, '_fetchFieldById').mockRejectedValue(new Error('Error')); + const result = service['_getFieldsFromIds'](mockFields, mockIds); - await expect(service['_getFieldsFromIds'](mockIds)).rejects.toThrow('Error fetching fields by IDs: Error'); - }); - - it('should fetch field by ID correctly', async () => { - const mockId = 1; - jest.spyOn(service.axiosKlammInstance, 'get').mockResolvedValue({ data: {} }); - - const result = await service['_fetchFieldById'](mockId); - - expect(service.axiosKlammInstance.get).toHaveBeenCalledWith(`${process.env.KLAMM_API_URL}/api/brerules/${mockId}`); - expect(result).toEqual({}); - }); - - it('should handle error in _fetchFieldById', async () => { - const mockId = 1; - jest.spyOn(service.axiosKlammInstance, 'get').mockRejectedValue(new Error('Error')); - - await expect(service['_fetchFieldById'](mockId)).rejects.toThrow('Error fetching field with ID 1: Error'); + expect(result).toEqual(mockFields); }); it('should get child rules correctly', async () => { @@ -299,11 +285,11 @@ describe('KlammService', () => { it('should update last sync timestamp correctly', async () => { jest.spyOn(klammSyncMetadata, 'findOneAndUpdate').mockResolvedValue(undefined); - await service['_updateLastSyncTimestamp'](); + await service['_updateLastSyncTimestamp'](1234567890); expect(klammSyncMetadata.findOneAndUpdate).toHaveBeenCalledWith( { key: 'singleton' }, - { lastSyncTimestamp: expect.any(Number) }, + { lastSyncTimestamp: 1234567890 }, { upsert: true, new: true }, ); }); @@ -311,7 +297,9 @@ describe('KlammService', () => { it('should handle error in _updateLastSyncTimestamp', async () => { jest.spyOn(klammSyncMetadata, 'findOneAndUpdate').mockRejectedValue(new Error('Error')); - await expect(service['_updateLastSyncTimestamp']()).rejects.toThrow('Failed to update last sync timestamp'); + await expect(service['_updateLastSyncTimestamp'](1234567890)).rejects.toThrow( + 'Failed to update last sync timestamp', + ); }); it('should get last sync timestamp correctly', async () => { diff --git a/src/api/klamm/klamm.service.ts b/src/api/klamm/klamm.service.ts index aa72443..51a4264 100644 --- a/src/api/klamm/klamm.service.ts +++ b/src/api/klamm/klamm.service.ts @@ -43,11 +43,18 @@ export class KlammService { async onModuleInit() { try { console.info('Syncing existing rules with Klamm...'); - const updatedFilesSinceLastDeploy = await this._getUpdatedFilesFromGithub(); - console.info('Files updated since last deploy:', updatedFilesSinceLastDeploy); - await this._syncRules(updatedFilesSinceLastDeploy); - console.info('Completed syncing existing rules with Klamm'); - await this._updateLastSyncTimestamp(); + const { updatedFilesSinceLastDeploy, lastCommitAsyncTimestamp } = await this._getUpdatedFilesFromGithub(); + if (lastCommitAsyncTimestamp != undefined) { + console.info( + `Files updated since last deploy up to ${new Date(lastCommitAsyncTimestamp)}:`, + updatedFilesSinceLastDeploy, + ); + await this._syncRules(updatedFilesSinceLastDeploy); + console.info('Completed syncing existing rules with Klamm'); + await this._updateLastSyncTimestamp(lastCommitAsyncTimestamp); + } else { + console.info('Klamm file syncing up to date'); + } } catch (error) { console.error('Unable to sync latest updates to Klamm:', error.message); } @@ -105,17 +112,22 @@ export class KlammService { } } - async _getUpdatedFilesFromGithub(): Promise { + async _getUpdatedFilesFromGithub(): Promise<{ + updatedFilesSinceLastDeploy: string[]; + lastCommitAsyncTimestamp: number; + }> { try { // Get last updated time from from db const timestamp = await this._getLastSyncTimestamp(); const date = new Date(timestamp); const formattedDate = date.toISOString().split('.')[0] + 'Z'; // Format required for github api + console.log(`Getting files from Github from ${formattedDate} onwards...`); // Fetch commits since the specified timestamp const commitsResponse = await this.axiosGithubInstance.get( `${GITHUB_RULES_REPO}/commits?since=${formattedDate}&sha=${process.env.GITHUB_RULES_BRANCH}`, ); - const commits = commitsResponse.data; + const commits = commitsResponse.data.reverse(); + let lastCommitAsyncTimestamp; // Fetch details for each commit to get the list of changed files const updatedFiles = new Set(); for (const commit of commits) { @@ -128,8 +140,9 @@ export class KlammService { } } } + lastCommitAsyncTimestamp = new Date(commitDetails.commit.author.date).getTime() + 1000; } - return Array.from(updatedFiles); + return { updatedFilesSinceLastDeploy: Array.from(updatedFiles), lastCommitAsyncTimestamp }; } catch (error) { console.error('Error fetching updated files from GitHub:', error); throw new Error('Error fetching updated files from GitHub'); @@ -163,8 +176,9 @@ export class KlammService { const { inputs, resultOutputs } = await this.ruleMappingService.inputOutputSchemaFile(rule.filepath); const inputIds = inputs.map(({ id }) => Number(id)); const outputIds = resultOutputs.map(({ id }) => Number(id)); - const inputResults = await this._getFieldsFromIds(inputIds); - const outputResults = await this._getFieldsFromIds(outputIds); + const klammFields: KlammField[] = await this._getAllKlammFields(); + const inputResults = this._getFieldsFromIds(klammFields, inputIds); + const outputResults = this._getFieldsFromIds(klammFields, outputIds); return { inputs: inputResults, outputs: outputResults }; } catch (error) { console.error(`Error getting input/output fields for rule ${rule.name}`, error.message); @@ -172,28 +186,23 @@ export class KlammService { } } - async _getFieldsFromIds(ids: number[]): Promise { - try { - const promises = ids.map((id) => this._fetchFieldById(id)); - return await Promise.all(promises); - } catch (error) { - console.error(`Error fetching fields by IDs`, error.message); - throw new Error(`Error fetching fields by IDs: ${error.message}`); - } + _getFieldsFromIds(klammFields: KlammField[], ids: number[]): KlammField[] { + const fieldObjects: KlammField[] = []; + klammFields.forEach((fieldObject) => { + if (ids.includes(fieldObject.id)) { + fieldObjects.push(fieldObject); + } + }); + return fieldObjects; } - private async _fetchFieldById(id: number): Promise { + async _getAllKlammFields(): Promise { try { - const response = await this.axiosKlammInstance.get(`${process.env.KLAMM_API_URL}/api/brerules/${id}`); - return response.data; + const response = await this.axiosKlammInstance.get(`${process.env.KLAMM_API_URL}/api/brerules`); + return response.data.data; } catch (error) { - if (error.response && error.response.status === 404) { - console.warn(`Field with ID ${id} not found`); - return null; - } else { - console.error(`Error fetching field with ID ${id}`, error.message); - throw new Error(`Error fetching field with ID ${id}: ${error.message}`); - } + console.error('Error fetching fields from Klamm', error.message); + throw new Error(`Error fetching fields from Klamm: ${error.message}`); } } @@ -253,12 +262,11 @@ export class KlammService { } } - async _updateLastSyncTimestamp(): Promise { + async _updateLastSyncTimestamp(lastSyncTimestamp: number): Promise { try { - const timestamp = Date.now(); await this.klammSyncMetadata.findOneAndUpdate( { key: 'singleton' }, - { lastSyncTimestamp: timestamp }, + { lastSyncTimestamp }, { upsert: true, new: true }, ); } catch (error) { diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index 355bdb4..a066916 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -126,9 +126,6 @@ export class RuleDataService { async getRuleDataByFilepath(filepath: string): Promise { try { const ruleData = await this.ruleDataModel.findOne({ filepath }).exec(); - if (!ruleData) { - throw new Error('Rule data not found'); - } return ruleData; } catch (error) { throw new Error(`Error getting all rule data for ${filepath}: ${error.message}`); From fea68d53182a71edd60ccf11006c0cd572bb3501 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Thu, 17 Oct 2024 16:26:31 -0700 Subject: [PATCH 11/15] Add error field in csv generation, with multi-error handling. --- .../validations/validations.service.ts | 14 ++++++++-- .../scenarioData/scenarioData.service.spec.ts | 23 ++++++++++----- src/api/scenarioData/scenarioData.service.ts | 28 ++++++++++++++++--- src/utils/handleTrace.ts | 4 +-- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/api/decisions/validations/validations.service.ts b/src/api/decisions/validations/validations.service.ts index 3ed67e7..add8fe9 100644 --- a/src/api/decisions/validations/validations.service.ts +++ b/src/api/decisions/validations/validations.service.ts @@ -8,8 +8,18 @@ export class ValidationService { return; } + const errors: string[] = []; + for (const field of ruleContent.fields) { - this.validateField(field, context); + try { + this.validateField(field, context); + } catch (e) { + errors.push(e.message); + } + } + + if (errors.length > 0) { + throw new ValidationError(`${errors.join('; ')}`); } } @@ -19,7 +29,7 @@ export class ValidationService { } if (!(field.field in context)) { - return; + return; } const input = context[field.field]; diff --git a/src/api/scenarioData/scenarioData.service.spec.ts b/src/api/scenarioData/scenarioData.service.spec.ts index 0ca0ffb..200a5dd 100644 --- a/src/api/scenarioData/scenarioData.service.spec.ts +++ b/src/api/scenarioData/scenarioData.service.spec.ts @@ -421,7 +421,16 @@ describe('ScenarioDataService', () => { const results = await service.runDecisionsForScenarios(filepath, ruleContent); expect(results).toEqual({ - [testObjectId.toString()]: { error: 'Decision execution error' }, + [testObjectId.toString()]: { + expectedResults: {}, + inputs: { + familyComposition: 'single', + }, + outputs: null, + result: {}, + resultMatch: false, + error: 'Decision execution error', + }, }); }); @@ -499,7 +508,7 @@ describe('ScenarioDataService', () => { const csvContent = await service.getCSVForRuleRun(filepath, ruleContent); - const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,2\nScenario 2,Fail,couple,3`; + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren,Error?\nScenario 1,Fail,single,2,\nScenario 2,Fail,couple,3,`; expect(csvContent.trim()).toBe(expectedCsvContent.trim()); }); @@ -524,7 +533,7 @@ describe('ScenarioDataService', () => { const csvContent = await service.getCSVForRuleRun(filepath, ruleContent); - const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,\nScenario 2,Fail,couple,3`; + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren,Error?\nScenario 1,Fail,single,,\nScenario 2,Fail,couple,3,`; expect(csvContent.trim()).toBe(expectedCsvContent.trim()); }); @@ -544,7 +553,7 @@ describe('ScenarioDataService', () => { const csvContent = await service.getCSVForRuleRun(filepath, ruleContent); - const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,2`; + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren,Error?\nScenario 1,Fail,single,2,`; expect(csvContent.trim()).toBe(expectedCsvContent.trim()); }); @@ -558,7 +567,7 @@ describe('ScenarioDataService', () => { const csvContent = await service.getCSVForRuleRun(filepath, ruleContent); - const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail)`; + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Error?\n`; expect(csvContent.trim()).toBe(expectedCsvContent.trim()); }); @@ -577,7 +586,7 @@ describe('ScenarioDataService', () => { const csvContent = await service.getCSVForRuleRun(filepath, ruleContent); - const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail)\nScenario 1,Fail`; + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Error?\nScenario 1,Fail,`; expect(csvContent.trim()).toBe(expectedCsvContent.trim()); }); @@ -597,7 +606,7 @@ describe('ScenarioDataService', () => { const csvContent = await service.getCSVForRuleRun(filepath, ruleContent); - const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: input1,Input: input2\nScenario 1,Fail,"value, with, commas",value "with" quotes`; + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: input1,Input: input2,Error?\nScenario 1,Fail,"value, with, commas",value "with" quotes,`; expect(csvContent.trim()).toBe(expectedCsvContent.trim()); }); diff --git a/src/api/scenarioData/scenarioData.service.ts b/src/api/scenarioData/scenarioData.service.ts index a7af917..80e69ac 100644 --- a/src/api/scenarioData/scenarioData.service.ts +++ b/src/api/scenarioData/scenarioData.service.ts @@ -119,11 +119,9 @@ export class ScenarioDataService { } const ruleSchema: RuleSchema = await this.ruleMappingService.inputOutputSchema(ruleContent); const results: { [scenarioId: string]: any } = {}; - for (const scenario of scenarios as ScenarioDataDocument[]) { const formattedVariablesObject = reduceToCleanObj(scenario?.variables, 'name', 'value'); const formattedExpectedResultsObject = reduceToCleanObj(scenario?.expectedResults, 'name', 'value'); - try { const decisionResult = await this.decisionsService.runDecision( ruleContent, @@ -150,7 +148,15 @@ export class ScenarioDataService { results[scenario.title.toString()] = scenarioResult; } catch (error) { console.error(`Error running decision for scenario ${scenario._id}: ${error.message}`); - results[scenario._id.toString()] = { error: error.message }; + const scenarioResult = { + inputs: formattedVariablesObject, + outputs: null, + expectedResults: formattedExpectedResultsObject || {}, + result: {}, + resultMatch: false, + error: error.message, + }; + results[scenario._id ? scenario._id.toString() : scenario?.title.toString()] = scenarioResult; } } return results; @@ -176,6 +182,7 @@ export class ScenarioDataService { ...this.prefixKeys(keys.inputs, 'Input'), ...this.prefixKeys(keys.expectedResults, 'Expected Result'), ...this.prefixKeys(keys.result, 'Result'), + 'Error?', ]; const rows = Object.entries(ruleRunResults).map(([scenarioName, data]) => [ @@ -184,6 +191,7 @@ export class ScenarioDataService { ...this.mapFields(data.inputs, keys.inputs), ...this.mapFields(data.expectedResults, keys.expectedResults), ...this.mapFields(data.result, keys.result), + data.error ? this.escapeCSVField(data.error) : '', ]); return [headers, ...rows].map((row) => row.join(',')).join('\n'); @@ -504,7 +512,17 @@ export class ScenarioDataService { }; } catch (error) { console.error(`Error running decision for scenario ${title}: ${error.message}`); - return { title, scenarioResult: { error: error.message } }; + return { + title, + scenarioResult: { + inputs: formattedVariablesObject, + outputs: null, + expectedResults: formattedExpectedResultsObject || {}, + result: {}, + resultMatch: false, + error: error.message, + }, + }; } }); @@ -544,6 +562,7 @@ export class ScenarioDataService { ...this.prefixKeys(keys.inputs, 'Input'), ...this.prefixKeys(keys.expectedResults, 'Expected Result'), ...this.prefixKeys(keys.result, 'Result'), + 'Error?', ]; const rows = Object.entries(ruleRunResults).map(([scenarioName, data]) => [ @@ -552,6 +571,7 @@ export class ScenarioDataService { ...this.mapFields(data.inputs, keys.inputs), ...this.mapFields(data.expectedResults, keys.expectedResults), ...this.mapFields(data.result, keys.result), + data.error ? this.escapeCSVField(data.error) : '', ]); return [headers, ...rows].map((row) => row.join(',')).join('\n'); diff --git a/src/utils/handleTrace.ts b/src/utils/handleTrace.ts index f2417ad..0a51940 100644 --- a/src/utils/handleTrace.ts +++ b/src/utils/handleTrace.ts @@ -24,7 +24,8 @@ export const mapTraceToResult = (trace: TraceObject, ruleSchema: RuleSchema, typ const result: { [key: string]: any } = {}; const schema = type === 'input' ? ruleSchema.inputs : ruleSchema.resultOutputs; for (const [key, value] of Object.entries(trace)) { - if (trace[key] && typeof trace[key] === 'object' && key !== '$' && key !== '$nodes') { + const keyExistsOnSchema = schema.some((item: any) => item.field === key); + if (trace[key] && typeof trace[key] === 'object' && key !== '$' && key !== '$nodes' && keyExistsOnSchema) { const newArray: any[] = []; const arrayName = key; for (const item in trace[key]) { @@ -60,7 +61,6 @@ export const mapTraceToResult = (trace: TraceObject, ruleSchema: RuleSchema, typ } } } - return result; }; From 300033f948eddef336f083368e6ed4e2c009a725 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:56:32 -0700 Subject: [PATCH 12/15] Fix deletion handling in admin page. --- src/api/ruleData/ruleData.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index ed1af12..63a8686 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -1,5 +1,5 @@ import { ObjectId } from 'mongodb'; -import { Model } from 'mongoose'; +import { Model, Types } from 'mongoose'; import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import axios from 'axios'; @@ -125,6 +125,9 @@ export class RuleDataService { // If there is a rule draft, update that document specifically // This is necessary because we don't store the draft on the ruleData object directly // Instead it is stored elsewhere and linked to the ruleData via its id + if (ruleData.ruleDraft && typeof ruleData.ruleDraft === 'string') { + ruleData.ruleDraft = new Types.ObjectId(`${ruleData.ruleDraft}`); + } if (ruleData?.ruleDraft) { const newDraft = new this.ruleDraftModel(ruleData.ruleDraft); const savedDraft = await newDraft.save(); From 13d848fe15041eab78d7a89d2c9a847963fdfdae Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:08:05 -0700 Subject: [PATCH 13/15] Rename validation error. --- src/api/decisions/decisions.controller.ts | 2 +- src/api/decisions/decisions.service.ts | 2 +- .../{validationError.service.ts => validation.error.ts} | 0 src/api/decisions/validations/validations.service.spec.ts | 2 +- src/api/decisions/validations/validations.service.ts | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/api/decisions/validations/{validationError.service.ts => validation.error.ts} (100%) diff --git a/src/api/decisions/decisions.controller.ts b/src/api/decisions/decisions.controller.ts index 930aae2..5a18831 100644 --- a/src/api/decisions/decisions.controller.ts +++ b/src/api/decisions/decisions.controller.ts @@ -1,7 +1,7 @@ import { Controller, Post, Query, Body, HttpException, HttpStatus } from '@nestjs/common'; import { DecisionsService } from './decisions.service'; import { EvaluateDecisionDto, EvaluateDecisionWithContentDto } from './dto/evaluate-decision.dto'; -import { ValidationError } from './validations/validationError.service'; +import { ValidationError } from './validations/validation.error'; @Controller('api/decisions') export class DecisionsController { diff --git a/src/api/decisions/decisions.service.ts b/src/api/decisions/decisions.service.ts index 1894465..4f64eda 100644 --- a/src/api/decisions/decisions.service.ts +++ b/src/api/decisions/decisions.service.ts @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { RuleContent } from '../ruleMapping/ruleMapping.interface'; import { readFileSafely, FileNotFoundError } from '../../utils/readFile'; import { ValidationService } from './validations/validations.service'; -import { ValidationError } from './validations/validationError.service'; +import { ValidationError } from './validations/validation.error'; @Injectable() export class DecisionsService { diff --git a/src/api/decisions/validations/validationError.service.ts b/src/api/decisions/validations/validation.error.ts similarity index 100% rename from src/api/decisions/validations/validationError.service.ts rename to src/api/decisions/validations/validation.error.ts diff --git a/src/api/decisions/validations/validations.service.spec.ts b/src/api/decisions/validations/validations.service.spec.ts index 41913a1..71a8a72 100644 --- a/src/api/decisions/validations/validations.service.spec.ts +++ b/src/api/decisions/validations/validations.service.spec.ts @@ -1,5 +1,5 @@ import { ValidationService } from './validations.service'; -import { ValidationError } from '../validations/validationError.service'; +import { ValidationError } from './validation.error'; describe('ValidationService', () => { let validator: ValidationService; diff --git a/src/api/decisions/validations/validations.service.ts b/src/api/decisions/validations/validations.service.ts index add8fe9..4e4fb41 100644 --- a/src/api/decisions/validations/validations.service.ts +++ b/src/api/decisions/validations/validations.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ValidationError } from '../validations/validationError.service'; +import { ValidationError } from './validation.error'; @Injectable() export class ValidationService { From 6c25c85603dd6b4794243d7d8e10b38f20708ce5 Mon Sep 17 00:00:00 2001 From: timwekkenbc Date: Mon, 21 Oct 2024 15:19:05 -0700 Subject: [PATCH 14/15] Removed mapping for functions as it is not needed --- .../ruleMapping/ruleMapping.service.spec.ts | 29 --------------- src/api/ruleMapping/ruleMapping.service.ts | 36 ------------------- 2 files changed, 65 deletions(-) diff --git a/src/api/ruleMapping/ruleMapping.service.spec.ts b/src/api/ruleMapping/ruleMapping.service.spec.ts index 3aa7346..ae229f5 100644 --- a/src/api/ruleMapping/ruleMapping.service.spec.ts +++ b/src/api/ruleMapping/ruleMapping.service.spec.ts @@ -170,35 +170,6 @@ describe('RuleMappingService', () => { }); }); - it('should handle functionNode correctly', async () => { - const nodes: Node[] = [ - { - type: 'functionNode', - content: ` - /** - * @param input1 - * @param input2 - * @returns output1 - */ - `, - id: 'testNode', - }, - ]; - - const result = await service.extractFields(nodes, 'inputs'); - expect(result).toEqual({ - inputs: [ - { key: 'input1', field: 'input1' }, - { key: 'input2', field: 'input2' }, - ], - }); - - const resultOutputs = await service.extractFields(nodes, 'outputs'); - expect(resultOutputs).toEqual({ - outputs: [{ key: 'output1', field: 'output1' }], - }); - }); - it('should handle nodes with unknown type correctly', async () => { const nodes: Node[] = [ { diff --git a/src/api/ruleMapping/ruleMapping.service.ts b/src/api/ruleMapping/ruleMapping.service.ts index 49d7cea..84334b0 100644 --- a/src/api/ruleMapping/ruleMapping.service.ts +++ b/src/api/ruleMapping/ruleMapping.service.ts @@ -39,42 +39,6 @@ export class RuleMappingService { exception: isSimpleValue ? null : expr.value, }; }); - } else if (node.type === 'functionNode' && node?.content) { - if (node.content.source) { - if (node.content.source.length > 10000) { - throw new Error('Input too large'); - } - return (node.content.source.split('\n') || []).reduce((acc: any[], line: string) => { - const keyword = fieldKey === 'inputs' ? '@param' : '@returns'; - if (line.includes(keyword)) { - const item = line.split(keyword)[1]?.trim(); - if (item) { - acc.push({ - key: item, - field: item, - }); - } - } - return acc; - }, []); - } else { - if (node.content.length > 10000) { - throw new Error('Input too large'); - } - return (node.content.split('\n') || []).reduce((acc: any[], line: string) => { - const keyword = fieldKey === 'inputs' ? '@param' : '@returns'; - if (line.includes(keyword)) { - const item = line.split(keyword)[1]?.trim(); - if (item) { - acc.push({ - key: item, - field: item, - }); - } - } - return acc; - }, []); - } } else { return (node.content?.[fieldKey] || []).map((field: Field) => ({ id: field.id, From 82c76e61348f7fd68759fc24b89f1afc9f2055d8 Mon Sep 17 00:00:00 2001 From: timwekkenbc Date: Thu, 24 Oct 2024 11:59:09 -0700 Subject: [PATCH 15/15] Fix eslint in pipeline --- .github/workflows/eslint.yml | 7 ------- package.json | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 123bac0..61a603a 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -29,10 +29,3 @@ jobs: - name: Run ESLint run: npm run lint:pipeline - continue-on-error: true - - - name: Upload ESLint report - uses: actions/upload-artifact@v4 - with: - name: eslint-report - path: eslint-report.html diff --git a/package.json b/package.json index 77c1f14..a8dd4be 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "lint:pipeline": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix --format=html --output-file=eslint-report.html", + "lint:pipeline": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix --max-warnings=0", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage",