diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index bdb3b3e..123bac0 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -32,10 +32,7 @@ jobs: continue-on-error: true - name: Upload ESLint report - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: eslint-report path: eslint-report.html - - - name: Display ESLint report link - run: echo "::set-output name=eslint-report::${{ steps.upload.outputs.artifact_path }}" diff --git a/package-lock.json b/package-lock.json index 63059a1..437bcdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,13 @@ "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.0.0", "@nestjs/mongoose": "^10.0.5", - "@nestjs/platform-express": "^10.3.7", + "@nestjs/platform-express": "^10.3.9", "axios": "^1.6.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "csv-parser": "^3.0.0", "mongoose": "^8.3.0", + "multer": "^1.4.5-lts.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, @@ -28,6 +30,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -1985,9 +1988,9 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.3.7", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.7.tgz", - "integrity": "sha512-noNJ+PyIxQJLCKfuXz0tcQtlVAynfLIuKy62g70lEZ86UrIqSrZFqvWs/rFUgkbT6J8H7Rmv11hASOnX+7M2rA==", + "version": "10.3.9", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.9.tgz", + "integrity": "sha512-si/UzobP6YUtYtCT1cSyQYHHzU3yseqYT6l7OHSMVvfG1+TqxaAqI6nmrix02LO+l1YntHRXEs3p+v9a7EfrSQ==", "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -2004,6 +2007,23 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "1.4.4-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", + "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.1.tgz", @@ -2358,6 +2378,15 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.11.20", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", @@ -3861,6 +3890,20 @@ "node": ">= 8" } }, + "node_modules/csv-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.0.0.tgz", + "integrity": "sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ==", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -6809,9 +6852,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { - "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.0.0", diff --git a/package.json b/package.json index 30cb3d1..376b060 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,13 @@ "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.0.0", "@nestjs/mongoose": "^10.0.5", - "@nestjs/platform-express": "^10.3.7", + "@nestjs/platform-express": "^10.3.9", "axios": "^1.6.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "csv-parser": "^3.0.0", "mongoose": "^8.3.0", + "multer": "^1.4.5-lts.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, @@ -40,6 +42,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", diff --git a/src/api/decisions/decisions.service.ts b/src/api/decisions/decisions.service.ts index c723b14..4881dfb 100644 --- a/src/api/decisions/decisions.service.ts +++ b/src/api/decisions/decisions.service.ts @@ -9,8 +9,9 @@ export class DecisionsService { rulesDirectory: string; constructor(private configService: ConfigService) { - this.engine = new ZenEngine(); this.rulesDirectory = this.configService.get('RULES_DIRECTORY'); + const loader = async (key: string) => readFileSafely(this.rulesDirectory, key); + this.engine = new ZenEngine({ loader }); } async runDecision(content: object, context: object, options: ZenEvaluateOptions) { @@ -18,6 +19,7 @@ export class DecisionsService { const decision = this.engine.createDecision(content); return await decision.evaluate(context, options); } catch (error) { + console.error(error.message); throw new Error(`Failed to run decision: ${error.message}`); } } diff --git a/src/api/documents/documents.service.spec.ts b/src/api/documents/documents.service.spec.ts index 9a5fce6..6341b42 100644 --- a/src/api/documents/documents.service.spec.ts +++ b/src/api/documents/documents.service.spec.ts @@ -1,9 +1,6 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import * as fs from 'fs'; -import * as util from 'util'; -import * as path from 'path'; import { DocumentsService } from './documents.service'; import { readFileSafely } from '../../utils/readFile'; diff --git a/src/api/ruleData/ruleData.service.spec.ts b/src/api/ruleData/ruleData.service.spec.ts index dd5e29c..a29683f 100644 --- a/src/api/ruleData/ruleData.service.spec.ts +++ b/src/api/ruleData/ruleData.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getModelToken } from '@nestjs/mongoose'; +import { DocumentsService } from '../documents/documents.service'; import { RuleDataService } from './ruleData.service'; import { RuleData } from './ruleData.schema'; @@ -24,10 +25,17 @@ export const mockServiceProviders = [ })), }), }, + { + provide: DocumentsService, + useValue: { + getAllJSONFiles: jest.fn().mockResolvedValue(['doc1.json', 'doc2.json']), + }, + }, ]; describe('RuleDataService', () => { let service: RuleDataService; + let documentsService: DocumentsService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -35,6 +43,7 @@ describe('RuleDataService', () => { }).compile(); service = module.get(RuleDataService); + documentsService = module.get(DocumentsService); }); it('should be defined', () => { @@ -49,4 +58,18 @@ describe('RuleDataService', () => { it('should get data for a rule', async () => { expect(await service.getRuleData(mockRuleData._id)).toEqual(mockRuleData); }); + + it('should add unsynced files correctly', async () => { + // Mock the expected behavior getting db files, getting repo files, and adding to db + const unsyncedFiles = ['file1.txt', 'file2.txt']; + jest.spyOn(service, 'getAllRuleData').mockResolvedValue([mockRuleData]); + jest.spyOn(documentsService, 'getAllJSONFiles').mockResolvedValue(unsyncedFiles); + jest.spyOn(service, 'createRuleData').mockImplementation((file: RuleData) => Promise.resolve(file)); + + await service.addUnsyncedFiles(); + + expect(service.createRuleData).toHaveBeenCalled(); + expect(documentsService.getAllJSONFiles).toHaveBeenCalled(); + expect(service.createRuleData).toHaveBeenCalledTimes(unsyncedFiles.length); + }); }); diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index b2bfbcf..6d0be46 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -2,11 +2,20 @@ import { ObjectId } from 'mongodb'; import { Model } from 'mongoose'; import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; +import { DocumentsService } from '../documents/documents.service'; import { RuleData, RuleDataDocument } from './ruleData.schema'; @Injectable() export class RuleDataService { - constructor(@InjectModel(RuleData.name) private ruleDataModel: Model) {} + constructor( + @InjectModel(RuleData.name) private ruleDataModel: Model, + private documentsService: DocumentsService, + ) {} + + async onModuleInit() { + console.info('Syncing existing rules with any updates to the rules repository'); + this.addUnsyncedFiles(); + } async getAllRuleData(): Promise { try { @@ -29,7 +38,7 @@ export class RuleDataService { } } - async createRuleData(ruleData: RuleData): Promise { + async createRuleData(ruleData: Partial): Promise { try { if (!ruleData._id) { const newRuleID = new ObjectId(); @@ -75,4 +84,20 @@ export class RuleDataService { } return ruleData.chefsFormAPIKey; } + + /** + * Add rules to the db that exist in the repo, but not yet the db + */ + async addUnsyncedFiles() { + const existingRules = await this.getAllRuleData(); + const jsonRuleDocuments = await this.documentsService.getAllJSONFiles(); + // Find rules not yet defined in db (but with an exisitng JSON file) and add them + jsonRuleDocuments + .filter((goRulesJSONFilename: string) => { + return !existingRules.find((rule) => rule.goRulesJSONFilename === goRulesJSONFilename); + }) + .forEach((goRulesJSONFilename: string) => { + this.createRuleData({ goRulesJSONFilename }); + }); + } } diff --git a/src/api/ruleMapping/ruleMapping.controller.spec.ts b/src/api/ruleMapping/ruleMapping.controller.spec.ts index a6ec22f..f1f28b5 100644 --- a/src/api/ruleMapping/ruleMapping.controller.spec.ts +++ b/src/api/ruleMapping/ruleMapping.controller.spec.ts @@ -80,7 +80,7 @@ describe('RuleMappingController', () => { it('should return the evaluated rule map', async () => { const nodes = [{ id: '1', type: 'someType', content: { inputs: [], outputs: [] } }]; const edges = [{ id: '2', type: 'someType', targetId: '1', sourceId: '1' }]; - const result = { inputs: [], outputs: [], finalOutputs: [] }; + const result = { inputs: [], outputs: [], resultOutputs: [] }; jest.spyOn(service, 'ruleSchema').mockReturnValue(result); const dto: EvaluateRuleMappingDto = { nodes, edges }; diff --git a/src/api/ruleMapping/ruleMapping.controller.ts b/src/api/ruleMapping/ruleMapping.controller.ts index d5b88d2..5887aa5 100644 --- a/src/api/ruleMapping/ruleMapping.controller.ts +++ b/src/api/ruleMapping/ruleMapping.controller.ts @@ -1,19 +1,20 @@ -import { Controller, Get, Param, Res, Post, Body, HttpException, HttpStatus } from '@nestjs/common'; +import { Controller, Query, Res, Post, Body, HttpException, HttpStatus } from '@nestjs/common'; import { RuleMappingService } from './ruleMapping.service'; import { Response } from 'express'; import { EvaluateRuleRunSchemaDto, EvaluateRuleMappingDto } from './dto/evaluate-rulemapping.dto'; + @Controller('api/rulemap') export class RuleMappingController { constructor(private ruleMappingService: RuleMappingService) {} // Map a rule file to its unique inputs, and all outputs - @Get('/:ruleFileName') - async getRuleFile(@Param('ruleFileName') ruleFileName: string, @Res() res: Response) { - const rulemap = await this.ruleMappingService.ruleSchemaFile(ruleFileName); + @Post('/') + async getRuleFile(@Query('goRulesJSONFilename') goRulesJSONFilename: string, @Res() res: Response) { + const rulemap = await this.ruleMappingService.ruleSchemaFile(goRulesJSONFilename); try { res.setHeader('Content-Type', 'application/json'); - res.setHeader('Content-Disposition', `attachment; filename=${ruleFileName}`); + res.setHeader('Content-Disposition', `attachment; filename=${goRulesJSONFilename}`); res.send(rulemap); } catch (error) { throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/src/api/ruleMapping/ruleMapping.service.spec.ts b/src/api/ruleMapping/ruleMapping.service.spec.ts index 5ba3f69..46ee873 100644 --- a/src/api/ruleMapping/ruleMapping.service.spec.ts +++ b/src/api/ruleMapping/ruleMapping.service.spec.ts @@ -91,8 +91,8 @@ describe('RuleMappingService', () => { }); }); - describe('extractfinalOutputs', () => { - it('should extract final outputs correctly when there is one output node and corresponding edges', () => { + describe('extractResultOutputs', () => { + it('should extract result outputs correctly when there is one output node and corresponding edges', () => { const nodes: Node[] = [ { id: '1', @@ -129,9 +129,9 @@ describe('RuleMappingService', () => { ], }); - const result = service.extractfinalOutputs(nodes, edges); + const result = service.extractResultOutputs(nodes, edges); expect(result).toEqual({ - finalOutputs: [ + resultOutputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, { id: '2', name: 'Output2', type: 'number', property: 'field3' }, ], @@ -151,7 +151,7 @@ describe('RuleMappingService', () => { const edges: Edge[] = [{ id: '1', type: 'someType', sourceId: '1', targetId: '2' }]; - expect(() => service.extractfinalOutputs(nodes, edges)).toThrow('No outputNode found in the nodes array'); + expect(() => service.extractResultOutputs(nodes, edges)).toThrow('No outputNode found in the nodes array'); }); it('should return an empty array if no target edges are found for the output node', () => { @@ -178,9 +178,9 @@ describe('RuleMappingService', () => { outputs: [], }); - const result = service.extractfinalOutputs(nodes, edges); + const result = service.extractResultOutputs(nodes, edges); expect(result).toEqual({ - finalOutputs: [], + resultOutputs: [], }); }); @@ -221,9 +221,9 @@ describe('RuleMappingService', () => { ], }); - const result = service.extractfinalOutputs(nodes, edges); + const result = service.extractResultOutputs(nodes, edges); expect(result).toEqual({ - finalOutputs: [ + resultOutputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, { id: '2', name: 'Output2', type: 'number', property: 'field3' }, ], @@ -367,7 +367,7 @@ describe('RuleMappingService', () => { { id: '1', name: 'Output1', type: 'string', property: 'field2' }, { id: '2', name: 'Output2', type: 'number', property: 'field3' }, ], - finalOutputs: [], + resultOutputs: [], }); }); }); @@ -524,7 +524,7 @@ describe('RuleMappingService', () => { expect(mockGetFileContent).toHaveBeenCalledWith(filePath); expect(result).toEqual({ - finalOutputs: [], + resultOutputs: [], inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], outputs: [ { id: '3', name: 'Output1', type: 'string', property: 'field2' }, diff --git a/src/api/ruleMapping/ruleMapping.service.ts b/src/api/ruleMapping/ruleMapping.service.ts index de6b4e4..f957704 100644 --- a/src/api/ruleMapping/ruleMapping.service.ts +++ b/src/api/ruleMapping/ruleMapping.service.ts @@ -21,6 +21,18 @@ export class RuleMappingService { key: fieldKey === 'inputs' ? expr.key : expr.value, property: fieldKey === 'inputs' ? expr.value : expr.key, })); + } else if (node.type === 'functionNode' && node?.content) { + return (node.content.split('\n') || []).reduce((acc: any, line: string) => { + const match = line.match(fieldKey === 'inputs' ? /\s*\*\s*@param\s+/ : /\s*\*\s*@returns\s+/); + if (match) { + const item = line.replace(match[0], '').trim(); + acc.push({ + key: item, + property: item, + }); + } + return acc; + }, []); } else { return (node.content?.[fieldKey] || []).map((field: Field) => ({ id: field.id, @@ -30,16 +42,15 @@ export class RuleMappingService { })); } }); - return { [fieldKey]: fields }; } // Get the final outputs of a rule from mapping the target output nodes and the edges - extractfinalOutputs( + extractResultOutputs( nodes: Node[], edges: Edge[], ): { - finalOutputs: any[]; + resultOutputs: any[]; } { // Find the output node const outputNode = nodes.find((obj) => obj.type === 'outputNode'); @@ -53,9 +64,9 @@ export class RuleMappingService { // Find the edges that connect the output node to other nodes const targetEdges = edges.filter((edge) => edge.targetId === outputNodeID); const targetOutputNodes = targetEdges.map((edge) => nodes.find((node) => node.id === edge.sourceId)); - const finalOutputs: any[] = this.extractFields(targetOutputNodes, 'outputs').outputs; + const resultOutputs: any[] = this.extractFields(targetOutputNodes, 'outputs').outputs; - return { finalOutputs }; + return { resultOutputs }; } extractInputsAndOutputs(nodes: Node[]): { @@ -98,23 +109,23 @@ export class RuleMappingService { ): { inputs: any[]; outputs: any[]; - finalOutputs: any[]; + resultOutputs: any[]; } { const inputs: any[] = this.extractUniqueInputs(nodes).uniqueInputs; const generalOutputs: any[] = this.extractFields(nodes, 'outputs').outputs; - const finalOutputs: any[] = this.extractfinalOutputs(nodes, edges).finalOutputs; + const resultOutputs: any[] = this.extractResultOutputs(nodes, edges).resultOutputs; //get unique outputs excluding final outputs const outputs: any[] = generalOutputs.filter( (output) => - !finalOutputs.some( - (finalOutput) => - finalOutput.id === output.id || - (finalOutput.key === output.key && finalOutput.property === output.property), + !resultOutputs.some( + (resultOutput) => + resultOutput.id === output.id || + (resultOutput.key === output.key && resultOutput.property === output.property), ), ); - return { inputs, outputs, finalOutputs }; + return { inputs, outputs, resultOutputs }; } // generate a schema for the inputs and outputs of a rule given the trace data of a rule run diff --git a/src/api/scenarioData/dto/create-scenario.dto.ts b/src/api/scenarioData/dto/create-scenario.dto.ts new file mode 100644 index 0000000..89e056c --- /dev/null +++ b/src/api/scenarioData/dto/create-scenario.dto.ts @@ -0,0 +1,32 @@ +import { IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { Variable } from '../scenarioData.schema'; + +export class VariableClass implements Variable { + name: string; + value: any; + type: string; +} + +export class CreateScenarioDto { + @IsNotEmpty() + @IsString() + title: string; + + @IsNotEmpty() + @IsString() + ruleID: string; + + @IsNotEmpty() + @ValidateNested({ each: true }) + @Type(() => VariableClass) + variables: VariableClass[]; + + @ValidateNested({ each: true }) + @Type(() => VariableClass) + expectedResults: VariableClass[]; + + @IsNotEmpty() + @IsString() + goRulesJSONFilename: string; +} diff --git a/src/api/scenarioData/scenarioData.controller.spec.ts b/src/api/scenarioData/scenarioData.controller.spec.ts new file mode 100644 index 0000000..544c373 --- /dev/null +++ b/src/api/scenarioData/scenarioData.controller.spec.ts @@ -0,0 +1,349 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ScenarioDataController } from './scenarioData.controller'; +import { ScenarioDataService } from './scenarioData.service'; +import { ScenarioData } from './scenarioData.schema'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { Response } from 'express'; +import { VariableClass, CreateScenarioDto } from './dto/create-scenario.dto'; + +describe('ScenarioDataController', () => { + let controller: ScenarioDataController; + let service: ScenarioDataService; + + const testObjectId = new Types.ObjectId(); + + const mockScenarioDataService = { + getAllScenarioData: jest.fn(), + getScenariosByRuleId: jest.fn(), + getScenariosByFilename: jest.fn(), + getScenarioData: jest.fn(), + createScenarioData: jest.fn(), + updateScenarioData: jest.fn(), + deleteScenarioData: jest.fn(), + getCSVForRuleRun: jest.fn(), + processProvidedScenarios: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ScenarioDataController], + providers: [ + { + provide: ScenarioDataService, + useValue: mockScenarioDataService, + }, + ], + }).compile(); + + controller = module.get(ScenarioDataController); + service = module.get(ScenarioDataService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getAllScenarioData', () => { + it('should return an array of scenarios', async () => { + const result: ScenarioData[] = []; + jest.spyOn(service, 'getAllScenarioData').mockResolvedValue(result); + + expect(await controller.getAllScenarioData()).toBe(result); + }); + + it('should throw an error if service fails', async () => { + jest.spyOn(service, 'getAllScenarioData').mockRejectedValue(new Error('Service error')); + + await expect(controller.getAllScenarioData()).rejects.toThrow(HttpException); + }); + }); + + describe('getScenariosByRuleId', () => { + it('should return scenarios by rule ID', async () => { + const result: ScenarioData[] = []; + jest.spyOn(service, 'getScenariosByRuleId').mockResolvedValue(result); + + expect(await controller.getScenariosByRuleId('someRuleId')).toBe(result); + }); + + it('should throw an error if service fails', async () => { + jest.spyOn(service, 'getScenariosByRuleId').mockRejectedValue(new Error('Service error')); + + await expect(controller.getScenariosByRuleId('someRuleId')).rejects.toThrow(HttpException); + }); + }); + + describe('getScenariosByFilename', () => { + it('should return scenarios by filename', async () => { + const result: ScenarioData[] = []; + jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(result); + + expect(await controller.getScenariosByFilename('someFilename')).toBe(result); + }); + + it('should throw an error if service fails', async () => { + jest.spyOn(service, 'getScenariosByFilename').mockRejectedValue(new Error('Service error')); + + await expect(controller.getScenariosByFilename('someFilename')).rejects.toThrow(HttpException); + }); + }); + + describe('getScenariosByRuleId', () => { + it('should return scenarios by rule ID', async () => { + const result: ScenarioData[] = []; + jest.spyOn(service, 'getScenariosByRuleId').mockResolvedValue(result); + + expect(await controller.getScenariosByRuleId('ruleID')).toBe(result); + }); + + it('should throw an error if service fails', async () => { + jest.spyOn(service, 'getScenariosByRuleId').mockRejectedValue(new Error('Service error')); + + await expect(controller.getScenariosByRuleId('ruleID')).rejects.toThrow(HttpException); + }); + }); + + describe('createScenarioData', () => { + it('should create a scenario', async () => { + const result: ScenarioData = { + title: 'title', + ruleID: 'ruleID', + variables: [], + goRulesJSONFilename: 'filename', + expectedResults: [], + }; + + const variables: VariableClass[] = [ + { name: 'variable1', value: 'value1', type: 'string' }, + { name: 'variable2', value: 123, type: 'number' }, + ]; + + const expectedResults: VariableClass[] = []; + + jest.spyOn(service, 'createScenarioData').mockResolvedValue(result); + + const dto: CreateScenarioDto = { + title: result.title, + ruleID: result.ruleID, + variables: variables, + goRulesJSONFilename: result.goRulesJSONFilename, + expectedResults: expectedResults, + }; + + expect(await controller.createScenarioData(dto)).toBe(result); + }); + + it('should throw an error if service fails', async () => { + const errorMessage = 'Service error'; + jest.spyOn(service, 'createScenarioData').mockRejectedValue(new Error(errorMessage)); + + const dto: CreateScenarioDto = { + title: 'title', + ruleID: 'ruleID', + variables: [], + goRulesJSONFilename: 'filename', + expectedResults: [], + }; + + await expect(controller.createScenarioData(dto)).rejects.toThrow(HttpException); + }); + }); + + describe('updateScenarioData', () => { + it('should update a scenario', async () => { + const result: ScenarioData = { + title: 'title', + ruleID: 'ruleID', + variables: [], + goRulesJSONFilename: 'filename', + expectedResults: [], + }; + + jest.spyOn(service, 'updateScenarioData').mockResolvedValue(result); + + const dto: CreateScenarioDto = { + title: result.title, + ruleID: result.ruleID, + variables: [], + goRulesJSONFilename: result.goRulesJSONFilename, + expectedResults: [], + }; + + expect(await controller.updateScenarioData(testObjectId.toHexString(), dto)).toBe(result); + }); + + it('should throw an error if service fails', async () => { + const errorMessage = 'Service error'; + jest.spyOn(service, 'updateScenarioData').mockRejectedValue(new Error(errorMessage)); + + const dto: CreateScenarioDto = { + title: 'title', + ruleID: 'ruleID', + variables: [], + goRulesJSONFilename: 'filename', + expectedResults: [], + }; + + await expect(controller.updateScenarioData(testObjectId.toHexString(), dto)).rejects.toThrow(HttpException); + }); + }); + + describe('deleteScenarioData', () => { + it('should delete a scenario', async () => { + jest.spyOn(service, 'deleteScenarioData').mockResolvedValue(undefined); + + await expect(controller.deleteScenarioData(testObjectId.toHexString())).resolves.toBeUndefined(); + }); + + it('should throw an error if service fails', async () => { + jest.spyOn(service, 'deleteScenarioData').mockRejectedValue(new Error('Service error')); + + await expect(controller.deleteScenarioData(testObjectId.toHexString())).rejects.toThrow(HttpException); + }); + }); + + describe('getCSVForRuleRun', () => { + it('should return CSV content with correct headers', async () => { + const goRulesJSONFilename = 'test.json'; + const csvContent = `Scenario,Input: familyComposition,Input: numberOfChildren,Output: isEligible,Output: baseAmount +Scenario 1,single,,true, +Scenario 2,couple,3,,200`; + + jest.spyOn(service, 'getCSVForRuleRun').mockResolvedValue(csvContent); + + const mockResponse = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + setHeader: jest.fn(), + }; + + await controller.getCSVForRuleRun(goRulesJSONFilename, mockResponse as any); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv'); + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + `attachment; filename=${goRulesJSONFilename.replace(/\.json$/, '.csv')}`, + ); + expect(mockResponse.send).toHaveBeenCalledWith(csvContent); + }); + + it('should throw an error if service fails', async () => { + const errorMessage = 'Error generating CSV for rule run'; + const goRulesJSONFilename = 'test.json'; + jest.spyOn(service, 'getCSVForRuleRun').mockRejectedValue(new Error(errorMessage)); + + const mockResponse = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + await expect(async () => { + await controller.getCSVForRuleRun(goRulesJSONFilename, mockResponse as any); + }).rejects.toThrow(Error); + + try { + await controller.getCSVForRuleRun(goRulesJSONFilename, mockResponse as any); + } catch (error) { + expect(error.message).toBe('Error generating CSV for rule run'); + } + }); + }); + describe('uploadCSVAndProcess', () => { + it('should throw an error if no file is uploaded', async () => { + const res: Partial = { + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + await expect(controller.uploadCSVAndProcess(undefined, res as Response, 'test.json')).rejects.toThrow( + HttpException, + ); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.setHeader).not.toHaveBeenCalled(); + expect(res.send).not.toHaveBeenCalled(); + }); + + it('should process the CSV and return processed data', async () => { + const csvContent = Buffer.from('Title,Input: Age\nScenario 1,25\nScenario 2,30'); + const file: Express.Multer.File = { + buffer: csvContent, + fieldname: '', + originalname: '', + encoding: '', + mimetype: '', + size: 0, + stream: null, + destination: '', + filename: '', + path: '', + }; + + const scenarios = [ + { + _id: new Types.ObjectId(), + title: 'Scenario 1', + ruleID: '', + variables: [{ name: 'Age', value: 25, type: 'number' }], + goRulesJSONFilename: 'test.json', + }, + ]; + + const csvResult = 'Processed CSV Content'; + + mockScenarioDataService.processProvidedScenarios.mockResolvedValue(scenarios); + mockScenarioDataService.getCSVForRuleRun.mockResolvedValue(csvResult); + + const res: Partial = { + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + await controller.uploadCSVAndProcess(file, res as Response, 'test.json'); + + expect(service.processProvidedScenarios).toHaveBeenCalledWith('test.json', file); + expect(service.getCSVForRuleRun).toHaveBeenCalledWith('test.json', scenarios); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv'); + expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename=processed_data.csv'); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(res.send).toHaveBeenCalledWith(csvResult); + }); + + it('should handle errors during processing', async () => { + const csvContent = Buffer.from('Title,Input: Age\nScenario 1,25\nScenario 2,30'); + const file: Express.Multer.File = { + buffer: csvContent, + fieldname: '', + originalname: '', + encoding: '', + mimetype: '', + size: 0, + stream: null, + destination: '', + filename: '', + path: '', + }; + + mockScenarioDataService.processProvidedScenarios.mockRejectedValue(new Error('Mocked error')); + + const res: Partial = { + setHeader: jest.fn(), + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + await expect(controller.uploadCSVAndProcess(file, res as Response, 'test.json')).rejects.toThrow( + new HttpException('Error processing CSV file', HttpStatus.INTERNAL_SERVER_ERROR), + ); + + expect(service.processProvidedScenarios).toHaveBeenCalledWith('test.json', file); + expect(res.setHeader).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalledWith(HttpStatus.OK); + expect(res.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/api/scenarioData/scenarioData.controller.ts b/src/api/scenarioData/scenarioData.controller.ts new file mode 100644 index 0000000..8cd95c3 --- /dev/null +++ b/src/api/scenarioData/scenarioData.controller.ts @@ -0,0 +1,158 @@ +import { + Controller, + Get, + Param, + Post, + Body, + Put, + Delete, + HttpException, + HttpStatus, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { ScenarioDataService } from './scenarioData.service'; +import { ScenarioData } from './scenarioData.schema'; +import { CreateScenarioDto } from './dto/create-scenario.dto'; +import { FileNotFoundError } from '../../utils/readFile'; + +@Controller('api/scenario') +export class ScenarioDataController { + constructor(private readonly scenarioDataService: ScenarioDataService) {} + + @Get('/list') + async getAllScenarioData(): Promise { + try { + return await this.scenarioDataService.getAllScenarioData(); + } catch (error) { + throw new HttpException('Error getting all scenario data', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Get('/by-rule/:ruleId') + async getScenariosByRuleId(@Param('ruleId') ruleId: string): Promise { + try { + return await this.scenarioDataService.getScenariosByRuleId(ruleId); + } catch (error) { + if (error instanceof FileNotFoundError) { + throw new HttpException('Rule not found', HttpStatus.NOT_FOUND); + } else { + throw new HttpException('Error getting scenarios by rule ID', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + } + + @Post('/by-filename') + async getScenariosByFilename(@Body('goRulesJSONFilename') goRulesJSONFilename: string): Promise { + try { + return await this.scenarioDataService.getScenariosByFilename(goRulesJSONFilename); + } catch (error) { + if (error instanceof FileNotFoundError) { + throw new HttpException('Rule not found', HttpStatus.NOT_FOUND); + } else { + throw new HttpException('Error getting scenarios by filename', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + } + + @Get('/:scenarioId') + async getScenarioData(@Param('scenarioId') scenarioId: string): Promise { + try { + return await this.scenarioDataService.getScenarioData(scenarioId); + } catch (error) { + throw new HttpException('Error getting scenario data', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Post() + async createScenarioData(@Body() createScenarioDto: CreateScenarioDto): Promise { + try { + const scenarioData: ScenarioData = { + title: createScenarioDto.title, + ruleID: createScenarioDto.ruleID, + variables: createScenarioDto.variables, + goRulesJSONFilename: createScenarioDto.goRulesJSONFilename, + expectedResults: createScenarioDto.expectedResults, + }; + return await this.scenarioDataService.createScenarioData(scenarioData); + } catch (error) { + throw new HttpException('Error creating scenario data', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Put('/:scenarioId') + async updateScenarioData( + @Param('scenarioId') scenarioId: string, + @Body() updateScenarioDto: CreateScenarioDto, + ): Promise { + try { + const scenarioData: ScenarioData = { + title: updateScenarioDto.title, + ruleID: updateScenarioDto.ruleID, + variables: updateScenarioDto.variables, + goRulesJSONFilename: updateScenarioDto.goRulesJSONFilename, + expectedResults: updateScenarioDto.expectedResults, + }; + return await this.scenarioDataService.updateScenarioData(scenarioId, scenarioData); + } catch (error) { + throw new HttpException('Error updating scenario data', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Delete('/:scenarioId') + async deleteScenarioData(@Param('scenarioId') scenarioId: string): Promise { + try { + await this.scenarioDataService.deleteScenarioData(scenarioId); + } catch (error) { + throw new HttpException('Error deleting scenario data', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Post('/evaluation') + async getCSVForRuleRun(@Body('goRulesJSONFilename') goRulesJSONFilename: string, @Res() res: Response) { + try { + const fileContent = await this.scenarioDataService.getCSVForRuleRun(goRulesJSONFilename); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename=${goRulesJSONFilename.replace(/\.json$/, '.csv')}`); + res.status(HttpStatus.OK).send(fileContent); + } catch (error) { + throw new HttpException('Error generating CSV for rule run', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Post('/run-decisions') + async runDecisionsForScenarios( + @Body('goRulesJSONFilename') goRulesJSONFilename: string, + ): Promise<{ [scenarioId: string]: any }> { + try { + return await this.scenarioDataService.runDecisionsForScenarios(goRulesJSONFilename); + } catch (error) { + throw new HttpException('Error running scenario decisions', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Post('/evaluation/upload/') + @UseInterceptors(FileInterceptor('file')) + async uploadCSVAndProcess( + @UploadedFile() file: Express.Multer.File | undefined, + @Res() res: Response, + @Body('goRulesJSONFilename') goRulesJSONFilename: string, + ) { + if (!file) { + throw new HttpException('No file uploaded', HttpStatus.BAD_REQUEST); + } + + try { + const scenarios = await this.scenarioDataService.processProvidedScenarios(goRulesJSONFilename, file); + const csvContent = await this.scenarioDataService.getCSVForRuleRun(goRulesJSONFilename, scenarios); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename=processed_data.csv`); + res.status(HttpStatus.OK).send(csvContent); + } catch (error) { + throw new HttpException('Error processing CSV file', HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/api/scenarioData/scenarioData.interface.ts b/src/api/scenarioData/scenarioData.interface.ts new file mode 100644 index 0000000..b118873 --- /dev/null +++ b/src/api/scenarioData/scenarioData.interface.ts @@ -0,0 +1,8 @@ +export interface RuleSchema { + inputs: Array<{ id: string; property: string }>; + resultOutputs: Array<{ id: string; property: string }>; +} + +export interface RuleRunResults { + [scenarioId: string]: any; +} diff --git a/src/api/scenarioData/scenarioData.schema.ts b/src/api/scenarioData/scenarioData.schema.ts new file mode 100644 index 0000000..b991d48 --- /dev/null +++ b/src/api/scenarioData/scenarioData.schema.ts @@ -0,0 +1,63 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type ScenarioDataDocument = ScenarioData & Document; +export interface Variable { + name: string; + value: any; + type?: string; +} + +@Schema() +export class VariableSchema { + @Prop({ required: true, type: String }) + name: string; + + @Prop({ required: true, type: {} }) + value: any; + + @Prop({ required: false, type: String, default: '' }) + type: string; +} + +const VariableModelSchema = SchemaFactory.createForClass(VariableSchema); + +// impute the type of the value if not provided +VariableModelSchema.pre('save', function (next) { + if (!this.type) { + this.type = typeof this.value; + } + next(); +}); + +@Schema() +export class ScenarioData { + @Prop({ description: 'The title of the scenario' }) + title: string; + + @Prop({ + ref: 'RuleData', + required: true, + description: 'The ID of the rule', + }) + ruleID: string; + + @Prop({ + required: true, + description: 'The variables of the scenario', + type: [VariableModelSchema], + }) + variables: Variable[]; + + @Prop({ + required: false, + description: 'The expected result of the scenario', + type: [VariableModelSchema], + }) + expectedResults: Variable[]; + + @Prop({ required: true, description: 'The filename of the JSON file containing the rule' }) + goRulesJSONFilename: string; +} + +export const ScenarioDataSchema = SchemaFactory.createForClass(ScenarioData); diff --git a/src/api/scenarioData/scenarioData.service.spec.ts b/src/api/scenarioData/scenarioData.service.spec.ts new file mode 100644 index 0000000..68c725b --- /dev/null +++ b/src/api/scenarioData/scenarioData.service.spec.ts @@ -0,0 +1,624 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ScenarioDataService } from './scenarioData.service'; +import { getModelToken } from '@nestjs/mongoose'; +import { Types, Model } from 'mongoose'; +import { ScenarioData, ScenarioDataDocument } from './scenarioData.schema'; +import { DecisionsService } from '../decisions/decisions.service'; +import { RuleMappingService } from '../ruleMapping/ruleMapping.service'; +import { ConfigService } from '@nestjs/config'; +import { DocumentsService } from '../documents/documents.service'; +import { parseCSV } from '../../utils/csv'; + +jest.mock('../../utils/csv'); + +describe('ScenarioDataService', () => { + let service: ScenarioDataService; + let decisionsService: DecisionsService; + let ruleMappingService: RuleMappingService; + let model: Model; + + const testObjectId = new Types.ObjectId(); + + const mockScenarioData: ScenarioData = { + title: 'Test Title', + ruleID: 'ruleID', + variables: [], + goRulesJSONFilename: 'test.json', + expectedResults: [], + }; + + class MockScenarioDataModel { + constructor(private data: ScenarioData) {} + save = jest.fn().mockResolvedValue(this.data); + static find = jest.fn(); + static findOne = jest.fn(); + static findOneAndDelete = jest.fn(); + } + + let originalConsoleError: { + (...data: any[]): void; + (message?: any, ...optionalParams: any[]): void; + (...data: any[]): void; + (message?: any, ...optionalParams: any[]): void; + }; + + beforeAll(() => { + originalConsoleError = console.error; + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalConsoleError; + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DecisionsService, + RuleMappingService, + ScenarioDataService, + { + provide: getModelToken(ScenarioData.name), + useValue: MockScenarioDataModel, + }, + DocumentsService, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('mocked_value'), + }, + }, + ], + }).compile(); + + service = module.get(ScenarioDataService); + decisionsService = module.get(DecisionsService); + ruleMappingService = module.get(RuleMappingService); + model = module.get>(getModelToken(ScenarioData.name)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getAllScenarioData', () => { + it('should return all scenario data', async () => { + const scenarioDataList: ScenarioData[] = [mockScenarioData]; + MockScenarioDataModel.find = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(scenarioDataList) }); + + const result = await service.getAllScenarioData(); + + expect(result).toEqual(scenarioDataList); + expect(MockScenarioDataModel.find).toHaveBeenCalled(); + }); + + it('should throw an error if an error occurs while retrieving scenario data', async () => { + const errorMessage = 'DB Error'; + MockScenarioDataModel.find = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockRejectedValue(new Error(errorMessage)) }); + + await expect(async () => { + await service.getAllScenarioData(); + }).rejects.toThrowError(`Error getting all scenario data: ${errorMessage}`); + + expect(MockScenarioDataModel.find).toHaveBeenCalled(); + }); + }); + + describe('getScenarioData', () => { + it('should return scenario data by id', async () => { + const scenarioId = testObjectId.toString(); + const scenarioData: ScenarioData = mockScenarioData; + MockScenarioDataModel.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(scenarioData) }); + + const result = await service.getScenarioData(scenarioId); + + expect(result).toEqual(scenarioData); + expect(MockScenarioDataModel.findOne).toHaveBeenCalledWith({ _id: scenarioId }); + }); + + it('should throw an error if scenario data is not found', async () => { + const scenarioId = testObjectId.toString(); + MockScenarioDataModel.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + await expect(async () => { + await service.getScenarioData(scenarioId); + }).rejects.toThrowError('Scenario data not found'); + + expect(MockScenarioDataModel.findOne).toHaveBeenCalledWith({ _id: scenarioId }); + }); + + it('should throw an error if an error occurs while retrieving scenario data', async () => { + const scenarioId = testObjectId.toString(); + const errorMessage = 'DB Error'; + MockScenarioDataModel.findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockRejectedValue(new Error(errorMessage)) }); + + await expect(async () => { + await service.getScenarioData(scenarioId); + }).rejects.toThrowError(`Error getting scenario data: ${errorMessage}`); + + expect(MockScenarioDataModel.findOne).toHaveBeenCalledWith({ _id: scenarioId }); + }); + }); + + describe('createScenarioData', () => { + it('should create scenario data', async () => { + const scenarioData: ScenarioData = mockScenarioData; + const modelInstance = new model(scenarioData); + modelInstance.save = jest.fn().mockResolvedValue(scenarioData); + const result = await service.createScenarioData(scenarioData); + expect(result).toEqual(scenarioData); + }); + }); + + describe('updateScenarioData', () => { + it('should update scenario data', async () => { + const scenarioId = testObjectId.toString(); + const updatedData: Partial = { title: 'Updated Title' }; + const existingScenarioData = new model(mockScenarioData); + existingScenarioData.save = jest.fn().mockResolvedValue({ ...mockScenarioData, ...updatedData }); + + MockScenarioDataModel.findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(existingScenarioData) }); + + const result = await service.updateScenarioData(scenarioId, updatedData); + + expect(result).toEqual({ ...mockScenarioData, ...updatedData }); + expect(MockScenarioDataModel.findOne).toHaveBeenCalledWith({ _id: scenarioId }); + expect(existingScenarioData.save).toHaveBeenCalled(); + }); + + it('should throw an error if scenario data is not found', async () => { + const scenarioId = testObjectId.toString(); + const updatedData: Partial = { title: 'Updated Title' }; + + MockScenarioDataModel.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + await expect(async () => { + await service.updateScenarioData(scenarioId, updatedData); + }).rejects.toThrowError('Scenario data not found'); + + expect(MockScenarioDataModel.findOne).toHaveBeenCalledWith({ _id: scenarioId }); + }); + + it('should throw an error if an error occurs while updating scenario data', async () => { + const scenarioId = testObjectId.toString(); + const updatedData: Partial = { title: 'Updated Title' }; + const errorMessage = 'DB Error'; + + MockScenarioDataModel.findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockRejectedValue(new Error(errorMessage)) }); + + await expect(async () => { + await service.updateScenarioData(scenarioId, updatedData); + }).rejects.toThrowError(`Failed to update scenario data: ${errorMessage}`); + + expect(MockScenarioDataModel.findOne).toHaveBeenCalledWith({ _id: scenarioId }); + }); + }); + + describe('deleteScenarioData', () => { + it('should delete scenario data successfully', async () => { + const scenarioId = testObjectId.toString(); + const objectId = new Types.ObjectId(scenarioId); + MockScenarioDataModel.findOneAndDelete = jest.fn().mockResolvedValue({ _id: objectId }); + await expect(service.deleteScenarioData(scenarioId)).resolves.toBeUndefined(); + + expect(MockScenarioDataModel.findOneAndDelete).toHaveBeenCalledWith({ _id: objectId }); + }); + + it('should throw an error if scenario data is not found', async () => { + const scenarioId = testObjectId.toString(); + const objectId = new Types.ObjectId(scenarioId); + + MockScenarioDataModel.findOneAndDelete = jest.fn().mockResolvedValue(null); + + await expect(service.deleteScenarioData(scenarioId)).rejects.toThrowError('Scenario data not found'); + + expect(MockScenarioDataModel.findOneAndDelete).toHaveBeenCalledWith({ _id: objectId }); + }); + + it('should throw an error if an error occurs while deleting scenario data', async () => { + const scenarioId = testObjectId.toString(); + const objectId = new Types.ObjectId(scenarioId); + const errorMessage = 'DB Error'; + MockScenarioDataModel.findOneAndDelete = jest.fn().mockRejectedValue(new Error(errorMessage)); + await expect(service.deleteScenarioData(scenarioId)).rejects.toThrowError( + `Failed to delete scenario data: ${errorMessage}`, + ); + expect(MockScenarioDataModel.findOneAndDelete).toHaveBeenCalledWith({ _id: objectId }); + }); + }); + + describe('getScenariosByRuleId', () => { + it('should return scenarios by rule ID', async () => { + const ruleId = testObjectId.toString(); + const scenarioDataList: ScenarioData[] = [mockScenarioData]; + MockScenarioDataModel.find = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(scenarioDataList) }); + + const result = await service.getScenariosByRuleId(ruleId); + + expect(result).toEqual(scenarioDataList); + expect(MockScenarioDataModel.find).toHaveBeenCalledWith({ ruleID: ruleId }); + }); + + it('should throw an error if an error occurs while retrieving scenarios by rule ID', async () => { + const ruleId = testObjectId.toString(); + const errorMessage = 'DB Error'; + + MockScenarioDataModel.find = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockRejectedValue(new Error(errorMessage)) }); + + await expect(async () => { + await service.getScenariosByRuleId(ruleId); + }).rejects.toThrowError(`Error getting scenarios by rule ID: ${errorMessage}`); + + expect(MockScenarioDataModel.find).toHaveBeenCalledWith({ ruleID: ruleId }); + }); + }); + + describe('getScenariosByFilename', () => { + it('should return scenarios by filename', async () => { + const goRulesJSONFilename = 'test.json'; + const scenarioDataList: ScenarioData[] = [mockScenarioData]; + scenarioDataList[0].goRulesJSONFilename = goRulesJSONFilename; + MockScenarioDataModel.find = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(scenarioDataList) }); + + const result = await service.getScenariosByFilename(goRulesJSONFilename); + + expect(result).toEqual(scenarioDataList); + expect(MockScenarioDataModel.find).toHaveBeenCalledWith({ goRulesJSONFilename: { $eq: goRulesJSONFilename } }); + }); + + it('should throw an error if an error occurs while retrieving scenarios by filename', async () => { + const goRulesJSONFilename = 'test.json'; + const errorMessage = 'DB Error'; + + MockScenarioDataModel.find = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockRejectedValue(new Error(errorMessage)) }); + + await expect(async () => { + await service.getScenariosByFilename(goRulesJSONFilename); + }).rejects.toThrowError(`Error getting scenarios by filename: ${errorMessage}`); + + expect(MockScenarioDataModel.find).toHaveBeenCalledWith({ goRulesJSONFilename: { $eq: goRulesJSONFilename } }); + }); + }); + describe('runDecisionsForScenarios', () => { + it('should run decisions for scenarios and map inputs/outputs correctly', async () => { + const goRulesJSONFilename = 'test.json'; + const scenarios = [ + { + _id: testObjectId, + title: 'Scenario 1', + variables: [{ name: 'familyComposition', value: 'single' }], + ruleID: 'ruleID', + goRulesJSONFilename: 'test.json', + expectedResults: [], + }, + { + _id: testObjectId, + title: 'Scenario 2', + variables: [{ name: 'numberOfChildren', value: 2 }], + ruleID: 'ruleID', + goRulesJSONFilename: 'test.json', + expectedResults: [], + }, + ]; + const ruleSchema = { + inputs: [ + { id: 'id1', name: 'Family Composition', property: 'familyComposition' }, + { id: 'id2', name: 'Number of Children', property: 'numberOfChildren' }, + ], + resultOutputs: [ + { id: 'id3', name: 'Is Eligible', property: 'isEligible' }, + { id: 'id4', name: 'Base Amount', property: 'baseAmount' }, + ], + }; + const decisionResult = { + performance: '0.7', + result: { status: 'pass' }, + trace: { + trace1: { + id: 'trace1', + name: 'trace1', + input: { familyComposition: 'single' }, + output: { isEligible: true }, + }, + trace2: { + id: 'trace2', + name: 'trace2', + input: { numberOfChildren: 2 }, + output: { baseAmount: 100 }, + performance: '0.7', + }, + }, + }; + + jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(scenarios); + jest.spyOn(ruleMappingService, 'ruleSchemaFile').mockResolvedValue(ruleSchema); + jest.spyOn(decisionsService, 'runDecisionByFile').mockResolvedValue(decisionResult); + + const results = await service.runDecisionsForScenarios(goRulesJSONFilename); + + expect(results).toEqual({ + 'Scenario 1': { + inputs: { familyComposition: 'single', numberOfChildren: 2 }, + outputs: { baseAmount: 100, isEligible: true }, + result: { + status: 'pass', + }, + expectedResults: {}, + resultMatch: true, + }, + 'Scenario 2': { + inputs: { familyComposition: 'single', numberOfChildren: 2 }, + outputs: { baseAmount: 100, isEligible: true }, + result: { + status: 'pass', + }, + expectedResults: {}, + resultMatch: true, + }, + }); + }); + it('should handle errors in decision execution', async () => { + const goRulesJSONFilename = 'test.json'; + const scenarios = [ + { + _id: testObjectId, + title: 'Scenario 1', + variables: [{ name: 'familyComposition', value: 'single' }], + ruleID: 'ruleID', + goRulesJSONFilename: 'test.json', + expectedResults: [], + }, + ]; + const ruleSchema = { + inputs: [{ id: 'id1', name: 'Family Composition', property: 'familyComposition' }], + resultOutputs: [{ id: 'id3', name: 'Is Eligible', property: 'isEligible' }], + }; + + jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(scenarios); + jest.spyOn(ruleMappingService, 'ruleSchemaFile').mockResolvedValue(ruleSchema); + jest.spyOn(decisionsService, 'runDecisionByFile').mockRejectedValue(new Error('Decision execution error')); + + const results = await service.runDecisionsForScenarios(goRulesJSONFilename); + expect(results).toEqual({ + [testObjectId.toString()]: { error: 'Decision execution error' }, + }); + }); + + it('should handle scenarios with no variables', async () => { + const goRulesJSONFilename = 'test.json'; + const scenarios = [ + { + _id: testObjectId, + title: 'Scenario 1', + variables: [], + ruleID: 'ruleID', + goRulesJSONFilename: 'test.json', + expectedResults: [], + }, + ]; + const ruleSchema = { + inputs: [], + resultOutputs: [{ id: 'id3', name: 'Is Eligible', property: 'isEligible' }], + }; + const decisionResult = { + performance: '0.7', + result: { status: 'pass' }, + trace: { + trace1: { + id: 'trace1', + name: 'trace1', + input: {}, + output: { isEligible: true }, + }, + }, + }; + + jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(scenarios); + jest.spyOn(ruleMappingService, 'ruleSchemaFile').mockResolvedValue(ruleSchema); + jest.spyOn(decisionsService, 'runDecisionByFile').mockResolvedValue(decisionResult); + + const results = await service.runDecisionsForScenarios(goRulesJSONFilename); + + expect(results).toEqual({ + 'Scenario 1': { + inputs: {}, + outputs: { isEligible: true }, + expectedResults: {}, + result: { + status: 'pass', + }, + resultMatch: true, + }, + }); + }); + }); + + describe('getCSVForRuleRun', () => { + it('should generate a CSV with correct headers and data', async () => { + const goRulesJSONFilename = 'test.json'; + const ruleRunResults = { + 'Scenario 1': { + inputs: { familyComposition: 'single', numberOfChildren: 2 }, + outputs: { isEligible: true, baseAmount: 100 }, + expectedResults: {}, + }, + 'Scenario 2': { + inputs: { familyComposition: 'couple', numberOfChildren: 3 }, + outputs: { isEligible: false, baseAmount: 200 }, + expectedResults: {}, + }, + }; + + jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); + + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,2\nScenario 2,Fail,couple,3`; + + expect(csvContent.trim()).toBe(expectedCsvContent.trim()); + }); + + it('should generate a CSV with missing inputs/outputs filled as empty strings', async () => { + const goRulesJSONFilename = 'test.json'; + const ruleRunResults = { + 'Scenario 1': { + inputs: { familyComposition: 'single' }, + outputs: { isEligible: true }, + expectedResults: {}, + }, + 'Scenario 2': { + inputs: { familyComposition: 'couple', numberOfChildren: 3 }, + outputs: { baseAmount: 200 }, + expectedResults: {}, + }, + }; + + jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); + + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,\nScenario 2,Fail,couple,3`; + + expect(csvContent.trim()).toBe(expectedCsvContent.trim()); + }); + + it('should generate a CSV with only one scenario', async () => { + const goRulesJSONFilename = 'test.json'; + const ruleRunResults = { + 'Scenario 1': { + inputs: { familyComposition: 'single', numberOfChildren: 2 }, + outputs: { isEligible: true, baseAmount: 100 }, + expectedResults: {}, + }, + }; + + jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); + + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,2`; + + expect(csvContent.trim()).toBe(expectedCsvContent.trim()); + }); + + it('should generate an empty CSV if no scenarios are present', async () => { + const goRulesJSONFilename = 'test.json'; + const ruleRunResults = {}; + + jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); + + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail)`; + + expect(csvContent.trim()).toBe(expectedCsvContent.trim()); + }); + + it('should handle scenarios with no variables or outputs', async () => { + const goRulesJSONFilename = 'test.json'; + const ruleRunResults = { + 'Scenario 1': { + inputs: {}, + outputs: {}, + }, + }; + + jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); + + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail)\nScenario 1,Fail`; + + expect(csvContent.trim()).toBe(expectedCsvContent.trim()); + }); + }); + + describe('processProvidedScenarios', () => { + it('should process CSV content and return ScenarioData array', async () => { + const mockCSVContent = { + buffer: Buffer.from( + 'Title,Input: Age,Input: Income,Expected Result: Eligible\nScenario 1,30,50000,true\nScenario 2,25,30000,false', + ), + } as Express.Multer.File; + + const mockParsedData = [ + ['Title', 'Input: Age', 'Input: Income', 'Expected Result: Eligible'], + ['Scenario 1', '30', '50000', 'true'], + ['Scenario 2', '25', '30000', 'false'], + ]; + + (parseCSV as jest.Mock).mockResolvedValue(mockParsedData); + + const result = await service.processProvidedScenarios('test.json', mockCSVContent); + + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + title: 'Scenario 1', + ruleID: '', + goRulesJSONFilename: 'test.json', + }); + expect(result[1]).toMatchObject({ + title: 'Scenario 2', + ruleID: '', + goRulesJSONFilename: 'test.json', + }); + + expect(result[0]).toHaveProperty('variables'); + expect(result[0]).toHaveProperty('expectedResults'); + expect(result[1]).toHaveProperty('variables'); + expect(result[1]).toHaveProperty('expectedResults'); + + if (result[0].variables) { + expect(result[0].variables).toEqual({ + Age: '30', + Income: '50000', + }); + } + if (result[0].expectedResults) { + expect(result[0].expectedResults).toEqual({ + Eligible: true, + }); + } + }); + + it('should throw an error if CSV content is empty', async () => { + const mockCSVContent = { + buffer: Buffer.from(''), + } as Express.Multer.File; + + (parseCSV as jest.Mock).mockResolvedValue([]); + + await expect(service.processProvidedScenarios('test.json', mockCSVContent)).rejects.toThrow( + 'CSV content is empty or invalid', + ); + }); + + it('should handle CSV with only headers', async () => { + const mockCSVContent = { + buffer: Buffer.from('Title,Input: Age,Input: Income,Expected Result: Eligible'), + } as Express.Multer.File; + + const mockParsedData = [['Title', 'Input: Age', 'Input: Income', 'Expected Result: Eligible']]; + + (parseCSV as jest.Mock).mockResolvedValue(mockParsedData); + + const result = await service.processProvidedScenarios('test.json', mockCSVContent); + + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/src/api/scenarioData/scenarioData.service.ts b/src/api/scenarioData/scenarioData.service.ts new file mode 100644 index 0000000..5105949 --- /dev/null +++ b/src/api/scenarioData/scenarioData.service.ts @@ -0,0 +1,216 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { ScenarioData, ScenarioDataDocument } from './scenarioData.schema'; +import { DecisionsService } from '../decisions/decisions.service'; +import { RuleMappingService } from '../ruleMapping/ruleMapping.service'; +import { RuleSchema, RuleRunResults } from './scenarioData.interface'; +import { isEqual, reduceToCleanObj, extractUniqueKeys } from '../../utils/helpers'; +import { mapTraces } from '../../utils/handleTrace'; +import { parseCSV, extractKeys, formatVariables } from '../../utils/csv'; + +@Injectable() +export class ScenarioDataService { + constructor( + private decisionsService: DecisionsService, + private ruleMappingService: RuleMappingService, + + @InjectModel(ScenarioData.name) private scenarioDataModel: Model, + ) {} + + async getAllScenarioData(): Promise { + try { + const scenarioDataList = await this.scenarioDataModel.find().exec(); + return scenarioDataList; + } catch (error) { + throw new Error(`Error getting all scenario data: ${error.message}`); + } + } + + async getScenarioData(scenarioId: string): Promise { + try { + const scenarioData = await this.scenarioDataModel.findOne({ _id: scenarioId }).exec(); + if (!scenarioData) { + throw new Error('Scenario data not found'); + } + return scenarioData; + } catch (error) { + throw new Error(`Error getting scenario data: ${error.message}`); + } + } + + async createScenarioData(scenarioData: ScenarioData): Promise { + try { + const newScenarioData = new this.scenarioDataModel(scenarioData); + const response = await newScenarioData.save(); + return response; + } catch (error) { + console.error('Error in createScenarioData:', error); + throw new Error(`Failed to add scenario data: ${error.message}`); + } + } + + async updateScenarioData(scenarioId: string, updatedData: Partial): Promise { + try { + const existingScenarioData = await this.scenarioDataModel.findOne({ _id: scenarioId }).exec(); + if (!existingScenarioData) { + throw new Error('Scenario data not found'); + } + Object.assign(existingScenarioData, updatedData); + return await existingScenarioData.save(); + } catch (error) { + throw new Error(`Failed to update scenario data: ${error.message}`); + } + } + ƒ; + async deleteScenarioData(scenarioId: string): Promise { + try { + const objectId = new Types.ObjectId(scenarioId); + const deletedScenarioData = await this.scenarioDataModel.findOneAndDelete({ _id: objectId }); + if (!deletedScenarioData) { + throw new Error('Scenario data not found'); + } + return; + } catch (error) { + throw new Error(`Failed to delete scenario data: ${error.message}`); + } + } + + async getScenariosByRuleId(ruleId: string): Promise { + try { + return await this.scenarioDataModel.find({ ruleID: ruleId }).exec(); + } catch (error) { + throw new Error(`Error getting scenarios by rule ID: ${error.message}`); + } + } + + async getScenariosByFilename(goRulesJSONFilename: string): Promise { + try { + return await this.scenarioDataModel.find({ goRulesJSONFilename: { $eq: goRulesJSONFilename } }).exec(); + } catch (error) { + throw new Error(`Error getting scenarios by filename: ${error.message}`); + } + } + + /** + * Runs decisions for multiple scenarios based on the provided rules JSON file. + * Retrieves scenarios, retrieves rule schema, and executes decisions for each scenario. + * Maps inputs and outputs from decision traces to structured results. + */ + async runDecisionsForScenarios( + goRulesJSONFilename: string, + newScenarios?: ScenarioData[], + ): Promise<{ [scenarioId: string]: any }> { + const scenarios = newScenarios || (await this.getScenariosByFilename(goRulesJSONFilename)); + const ruleSchema: RuleSchema = await this.ruleMappingService.ruleSchemaFile(goRulesJSONFilename); + 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.runDecisionByFile( + scenario.goRulesJSONFilename, + formattedVariablesObject, + { trace: true }, + ); + + const resultMatches = + Object.keys(formattedExpectedResultsObject).length > 0 + ? isEqual(decisionResult.result, formattedExpectedResultsObject) + : true; + + const scenarioResult = { + inputs: mapTraces(decisionResult.trace, ruleSchema, 'input'), + outputs: mapTraces(decisionResult.trace, ruleSchema, 'output'), + expectedResults: formattedExpectedResultsObject || {}, + result: decisionResult.result || {}, + resultMatch: resultMatches, + }; + + 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 }; + } + } + return results; + } + + /** + * Generates a CSV string based on the results of running decisions for scenarios. + * Retrieves scenario results, extracts unique input and output keys, and maps them to CSV rows. + * Constructs CSV headers and rows based on input and output keys. + */ + async getCSVForRuleRun(goRulesJSONFilename: string, newScenarios?: ScenarioData[]): Promise { + const ruleRunResults: RuleRunResults = await this.runDecisionsForScenarios(goRulesJSONFilename, newScenarios); + + const inputKeys = extractUniqueKeys(ruleRunResults, 'inputs'); + const outputKeys = extractUniqueKeys(ruleRunResults, 'result'); + const expectedResultsKeys = extractUniqueKeys(ruleRunResults, 'expectedResults'); + + const headers = [ + 'Scenario', + 'Results Match Expected (Pass/Fail)', + ...inputKeys.map((key) => `Input: ${key}`), + ...expectedResultsKeys.map((key) => `Expected Result: ${key}`), + ...outputKeys.map((key) => `Result: ${key}`), + ]; + + const rows = Object.entries(ruleRunResults).map(([scenarioName, scenarioData]) => { + const resultsMatch = scenarioData.resultMatch ? 'Pass' : 'Fail'; + const inputs = inputKeys.map((key) => scenarioData.inputs[key] ?? ''); + const outputs = outputKeys.map((key) => scenarioData.result[key] ?? ''); + const expectedResults = expectedResultsKeys.map((key) => scenarioData.expectedResults[key] ?? ''); + + return [scenarioName, resultsMatch, ...inputs, ...expectedResults, ...outputs]; + }); + + const csvContent = [headers, ...rows].map((row) => row.join(',')).join('\n'); + return csvContent; + } + + /** + * Processes a CSV file containing scenario data and returns an array of ScenarioData objects based on the inputs. + * @param goRulesJSONFilename The name of the Go rules JSON file. + * @param csvContent The CSV file content. + * @returns An array of ScenarioData objects. + */ + async processProvidedScenarios( + goRulesJSONFilename: string, + csvContent: Express.Multer.File, + ): Promise { + const parsedData = await parseCSV(csvContent); + if (!parsedData || parsedData.length === 0) { + throw new Error('CSV content is empty or invalid'); + } + + const headers = parsedData[0]; + + const inputKeys = extractKeys(headers, 'Input: ') || []; + const expectedResultsKeys = extractKeys(headers, 'Expected Result: ') || []; + + const scenarios: ScenarioData[] = []; + + parsedData.slice(1).forEach((row) => { + const scenarioTitle = row[0]; + + const inputs = formatVariables(row, inputKeys, 2); + const expectedResultsStartIndex = 2 + inputKeys.length; + const expectedResults = formatVariables(row, expectedResultsKeys, expectedResultsStartIndex, true); + + const scenario: ScenarioData = { + title: scenarioTitle, + ruleID: '', + variables: inputs, + goRulesJSONFilename: goRulesJSONFilename, + expectedResults: expectedResults, + }; + + scenarios.push(scenario); + }); + + return scenarios; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 81dc55f..c72454e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,9 @@ import { SubmissionsController } from './api/submissions/submissions.controller' import { SubmissionsService } from './api/submissions/submissions.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'; @Module({ imports: [ @@ -24,7 +27,10 @@ import { RuleMappingService } from './api/ruleMapping/ruleMapping.service'; ], }), MongooseModule.forRoot(process.env.MONGODB_URL), - MongooseModule.forFeature([{ name: RuleData.name, schema: RuleDataSchema }]), + MongooseModule.forFeature([ + { name: RuleData.name, schema: RuleDataSchema }, + { name: ScenarioData.name, schema: ScenarioDataSchema }, + ]), ], controllers: [ RuleDataController, @@ -32,7 +38,15 @@ import { RuleMappingService } from './api/ruleMapping/ruleMapping.service'; DocumentsController, SubmissionsController, RuleMappingController, + ScenarioDataController, + ], + providers: [ + RuleDataService, + DecisionsService, + DocumentsService, + SubmissionsService, + RuleMappingService, + ScenarioDataService, ], - providers: [RuleDataService, DecisionsService, DocumentsService, SubmissionsService, RuleMappingService], }) export class AppModule {} diff --git a/src/utils/csv.ts b/src/utils/csv.ts new file mode 100644 index 0000000..85ced17 --- /dev/null +++ b/src/utils/csv.ts @@ -0,0 +1,56 @@ +import * as csvParser from 'csv-parser'; +import { formatValue } from './helpers'; +import { Variable } from '../api/scenarioData/scenarioData.schema'; + +/** + * Parses CSV file to an array of arrays + * @param file csv file + * @returns array of arrays + */ +export const parseCSV = async (file: Express.Multer.File | undefined): Promise => { + return new Promise((resolve, reject) => { + const results: string[][] = []; + const stream = csvParser({ headers: false }); + + stream.on('data', (data) => results.push(Object.values(data))); + stream.on('end', () => resolve(results)); + stream.on('error', (error) => reject(error)); + + stream.write(file.buffer); + stream.end(); + }); +}; + +/** + * Extracts keys from headers based on a given prefix. + * @param headers The CSV headers. + * @param prefix The prefix to filter and remove from headers. + * @returns An array of extracted keys. + */ +export const extractKeys = (headers: string[], prefix: string): string[] => { + return headers.filter((header) => header.startsWith(prefix)).map((header) => header.replace(prefix, '')); +}; + +/** + * Formats the variables from a CSV row based on provided keys. + * @param row The CSV row data. + * @param keys The keys to extract values for. + * @param startIndex The start index in the row to begin extraction. + * @param filterEmpty Whether to filter out empty values. + * @returns An array of formatted variables. + */ +export const formatVariables = (row: string[], keys: string[], startIndex: number, filterEmpty = false): Variable[] => { + return keys + .map((key, index) => { + const value = row[startIndex + index] ? formatValue(row[startIndex + index]) : null; + if (filterEmpty && (value === null || value === undefined || value === '')) { + return undefined; + } + return { + name: key, + value: value, + type: typeof value, + }; + }) + .filter((entry) => entry !== undefined); +}; diff --git a/src/utils/handleTrace.ts b/src/utils/handleTrace.ts new file mode 100644 index 0000000..3c1e53f --- /dev/null +++ b/src/utils/handleTrace.ts @@ -0,0 +1,63 @@ +import { RuleSchema } from '../api/scenarioData/scenarioData.interface'; +import { replaceSpecialCharacters } from './helpers'; +import { TraceObject } from '../api/ruleMapping/ruleMapping.interface'; + +/** + * Gets the property name from the trace object based on the id + * @param id Id of the property + * @param type Type of the trace object + * @returns Property name + */ +export const getPropertyById = (id: string, ruleSchema: RuleSchema, type: 'input' | 'output') => { + const schema = type === 'input' ? ruleSchema.inputs : ruleSchema.resultOutputs; + const item = schema.find((item: any) => item.id === id); + return item ? item.property : null; +}; + +/** + * Maps a trace object to a structured object + * @param trace Trace object + * @param type Type of trace object + * @returns Structured object + */ +export const mapTraceToResult = (trace: TraceObject, ruleSchema: RuleSchema, type: 'input' | 'output') => { + const result: { [key: string]: any } = {}; + const schema = type === 'input' ? ruleSchema.inputs : ruleSchema.resultOutputs; + + for (const [key, value] of Object.entries(trace)) { + const propertyUnformatted = getPropertyById(key, ruleSchema, type); + const property = propertyUnformatted ? replaceSpecialCharacters(propertyUnformatted, '') : null; + if (property) { + result[property] = value; + } else { + // Direct match without id + const directMatch = schema.find((item: any) => replaceSpecialCharacters(item.property, '') === key); + if (directMatch) { + const formattedKey = replaceSpecialCharacters(directMatch.property, ''); + result[formattedKey] = value; + } + } + } + + return result; +}; + +/** + * Maps a trace object to a structured object + * @param trace Trace object + * @param type Type of trace object + * @returns Structured object + */ +// Function to map traces for inputs or outputs +export const mapTraces = (traces: any, ruleSchema: RuleSchema, type: 'input' | 'output') => { + const result: { [key: string]: any } = {}; + for (const trace of Object.values(traces as TraceObject)) { + if (type === 'input' && trace.input) { + Object.assign(result, mapTraceToResult(trace.input, ruleSchema, type)); + } + if (type === 'output' && trace.output) { + Object.assign(result, mapTraceToResult(trace.output, ruleSchema, type)); + } + } + return result; +}; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 0000000..7f5847c --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,106 @@ +/** + * Replace special characters in strings + * @param input + * @param replacement + * @returns replaced string + */ +export const replaceSpecialCharacters = (input: string, replacement: string): string => { + const specialChars = /[\n\r\t\f,]/g; + return input.replace(specialChars, (match) => { + if (match === ',') { + return '-'; + } + return replacement; + }); +}; + +/** + * Compares two objects for equality + * @param obj1 first object + * @param obj2 second object + * @returns true if equal, false otherwise + */ + +export const isEqual = (obj1: any, obj2: any) => { + if (obj1 === obj2) return true; + if (obj1 == null || obj2 == null) return false; + if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return false; + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + if (!keys2.includes(key) || !isEqual(obj1[key], obj2[key])) return false; + } + + return true; +}; + +/** + * Reduces an array of objects to a single object with cleaned keys and values mapped + * @param arr Array of objects to reduce + * @param keyName Name of the key to use for the reduced object keys + * @param valueName Name of the key to use for the reduced object values + * @param replacement Replacement string for special characters + * @returns Reduced and cleaned object + */ +export const reduceToCleanObj = ( + arr: { [key: string]: any }[] | undefined | null, + keyName: string, + valueName: string, + replacement: string = '', +): Record => { + if (!Array.isArray(arr)) { + return {}; + } + + return arr.reduce((acc: Record, obj: { [key: string]: any }) => { + if (obj && typeof obj === 'object') { + const cleanedKey = replaceSpecialCharacters(obj[keyName], replacement); + acc[cleanedKey] = obj[valueName]; + } + return acc; + }, {}); +}; + +/** + * Extracts unique keys from a specified property. + * @param object The object to extract keys from. + * @param property The property to extract keys from ('inputs', 'outputs', 'expectedResults', etc.). + * @returns An array of unique keys. + */ +export const extractUniqueKeys = (object: Record, property: string): string[] => { + return Array.from( + new Set( + Object.values(object).flatMap((each) => { + if (each[property]) { + return Object.keys(each[property]); + } + return []; + }), + ), + ); +}; + +/** + * Formats a value based on its type. + * @param value The value to format. + * @returns The formatted value. + */ +export const formatValue = (value: string): boolean | number | string | null => { + if (value.toLowerCase() === 'true') { + return true; + } else if (value.toLowerCase() === 'false') { + return false; + } + const numberValue = parseFloat(value); + if (!isNaN(numberValue)) { + return numberValue; + } + if (value === '') { + return null; + } + return value; +};