diff --git a/src/api/decisions/decsions.controller.spec.ts b/src/api/decisions/decisions.controller.spec.ts similarity index 83% rename from src/api/decisions/decsions.controller.spec.ts rename to src/api/decisions/decisions.controller.spec.ts index 3afd715..7709223 100644 --- a/src/api/decisions/decsions.controller.spec.ts +++ b/src/api/decisions/decisions.controller.spec.ts @@ -15,7 +15,7 @@ describe('DecisionsController', () => { { provide: DecisionsService, useValue: { - runDecision: jest.fn(), + runDecisionByContent: jest.fn(), runDecisionByFile: jest.fn(), }, }, @@ -26,23 +26,23 @@ describe('DecisionsController', () => { service = module.get(DecisionsService); }); - it('should call runDecision with correct parameters', async () => { + it('should call runDecisionByContent with correct parameters', async () => { const dto: EvaluateDecisionWithContentDto = { - content: { value: 'content' }, + ruleContent: { nodes: [], edges: [] }, context: { value: 'context' }, trace: false, }; await controller.evaluateDecisionByContent(dto); - expect(service.runDecision).toHaveBeenCalledWith(dto.content, dto.context, { trace: dto.trace }); + expect(service.runDecisionByContent).toHaveBeenCalledWith(dto.ruleContent, dto.context, { trace: dto.trace }); }); it('should throw an error when runDecision fails', async () => { const dto: EvaluateDecisionWithContentDto = { - content: { value: 'content' }, + ruleContent: { nodes: [], edges: [] }, context: { value: 'context' }, trace: false, }; - (service.runDecision as jest.Mock).mockRejectedValue(new Error('Error')); + (service.runDecisionByContent as jest.Mock).mockRejectedValue(new Error('Error')); await expect(controller.evaluateDecisionByContent(dto)).rejects.toThrow(HttpException); }); diff --git a/src/api/decisions/decisions.controller.ts b/src/api/decisions/decisions.controller.ts index dd7a67b..398b96b 100644 --- a/src/api/decisions/decisions.controller.ts +++ b/src/api/decisions/decisions.controller.ts @@ -7,9 +7,9 @@ export class DecisionsController { constructor(private readonly decisionsService: DecisionsService) {} @Post('/evaluate') - async evaluateDecisionByContent(@Body() { content, context, trace }: EvaluateDecisionWithContentDto) { + async evaluateDecisionByContent(@Body() { ruleContent, context, trace }: EvaluateDecisionWithContentDto) { try { - return await this.decisionsService.runDecision(content, context, { trace }); + return await this.decisionsService.runDecisionByContent(ruleContent, context, { trace }); } catch (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 91d56af..d67250f 100644 --- a/src/api/decisions/decisions.service.spec.ts +++ b/src/api/decisions/decisions.service.spec.ts @@ -32,20 +32,45 @@ describe('DecisionsService', () => { describe('runDecision', () => { it('should run a decision', async () => { - const content = {}; + const ruleFileName = 'rule'; + const ruleContent = { nodes: [], edges: [] }; const context = {}; const options: ZenEvaluateOptions = { trace: false }; - await service.runDecision(content, context, options); - expect(mockEngine.createDecision).toHaveBeenCalledWith(content); + await service.runDecision(ruleContent, ruleFileName, context, options); + expect(mockEngine.createDecision).toHaveBeenCalledWith(ruleContent); expect(mockDecision.evaluate).toHaveBeenCalledWith(context, options); }); it('should throw an error if the decision fails', async () => { - const content = {}; + const ruleFileName = 'rule'; + const ruleContent = { nodes: [], edges: [] }; const context = {}; const options: ZenEvaluateOptions = { trace: false }; (mockDecision.evaluate as jest.Mock).mockRejectedValue(new Error('Error')); - await expect(service.runDecision(content, context, options)).rejects.toThrow('Failed to run decision: Error'); + await expect(service.runDecision(ruleContent, ruleFileName, context, options)).rejects.toThrow( + 'Failed to run decision: Error', + ); + }); + }); + + describe('runDecisionByContent', () => { + it('should run a decision by content', async () => { + const ruleContent = { nodes: [], edges: [] }; + const context = {}; + const options: ZenEvaluateOptions = { trace: false }; + await service.runDecisionByContent(ruleContent, context, options); + expect(mockEngine.createDecision).toHaveBeenCalledWith(ruleContent); + expect(mockDecision.evaluate).toHaveBeenCalledWith(context, options); + }); + + it('should throw an error if the decision fails', async () => { + const ruleContent = { nodes: [], edges: [] }; + const context = {}; + const options: ZenEvaluateOptions = { trace: false }; + (mockDecision.evaluate as jest.Mock).mockRejectedValue(new Error('Error')); + await expect(service.runDecisionByContent(ruleContent, context, options)).rejects.toThrow( + 'Failed to run decision: Error', + ); }); }); @@ -54,8 +79,8 @@ describe('DecisionsService', () => { const ruleFileName = 'rule'; const context = {}; const options: ZenEvaluateOptions = { trace: false }; - const content = JSON.stringify({ rule: 'rule' }); - (readFileSafely as jest.Mock).mockResolvedValue(content); + const content = { rule: 'rule' }; + (readFileSafely as jest.Mock).mockResolvedValue(Buffer.from(JSON.stringify(content))); await service.runDecisionByFile(ruleFileName, context, options); expect(readFileSafely).toHaveBeenCalledWith(service.rulesDirectory, ruleFileName); expect(mockEngine.createDecision).toHaveBeenCalledWith(content); diff --git a/src/api/decisions/decisions.service.ts b/src/api/decisions/decisions.service.ts index 4881dfb..ce3ea7f 100644 --- a/src/api/decisions/decisions.service.ts +++ b/src/api/decisions/decisions.service.ts @@ -1,6 +1,7 @@ import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { ZenEngine, ZenEvaluateOptions } from '@gorules/zen-engine'; import { ConfigService } from '@nestjs/config'; +import { RuleContent } from '../ruleMapping/ruleMapping.interface'; import { readFileSafely, FileNotFoundError } from '../../utils/readFile'; @Injectable() @@ -14,9 +15,9 @@ export class DecisionsService { this.engine = new ZenEngine({ loader }); } - async runDecision(content: object, context: object, options: ZenEvaluateOptions) { + async runDecisionByContent(ruleContent: RuleContent, context: object, options: ZenEvaluateOptions) { try { - const decision = this.engine.createDecision(content); + const decision = this.engine.createDecision(ruleContent); return await decision.evaluate(context, options); } catch (error) { console.error(error.message); @@ -26,8 +27,9 @@ export class DecisionsService { async runDecisionByFile(ruleFileName: string, context: object, options: ZenEvaluateOptions) { try { - const content = await readFileSafely(this.rulesDirectory, ruleFileName); - return this.runDecision(content, context, options); + const decisionFile = await readFileSafely(this.rulesDirectory, ruleFileName); + const content: RuleContent = JSON.parse(decisionFile.toString()); // Convert file buffer to rulecontent + return this.runDecisionByContent(content, context, options); } catch (error) { if (error instanceof FileNotFoundError) { throw new HttpException('Rule not found', HttpStatus.NOT_FOUND); @@ -36,4 +38,13 @@ export class DecisionsService { } } } + + /** Run the decision by content if it exists, otherwise run by filename */ + async runDecision(ruleContent: RuleContent, ruleFileName: string, context: object, options: ZenEvaluateOptions) { + if (ruleContent) { + return await this.runDecisionByContent(ruleContent, context, options); + } else { + return await this.runDecisionByFile(ruleFileName, context, options); + } + } } diff --git a/src/api/decisions/dto/evaluate-decision.dto.ts b/src/api/decisions/dto/evaluate-decision.dto.ts index f309051..40492a9 100644 --- a/src/api/decisions/dto/evaluate-decision.dto.ts +++ b/src/api/decisions/dto/evaluate-decision.dto.ts @@ -1,4 +1,5 @@ import { IsBoolean, IsObject } from 'class-validator'; +import { RuleContent } from 'src/api/ruleMapping/ruleMapping.interface'; export class EvaluateDecisionDto { @IsObject() @@ -10,5 +11,5 @@ export class EvaluateDecisionDto { export class EvaluateDecisionWithContentDto extends EvaluateDecisionDto { @IsObject() - content: object; + ruleContent: RuleContent; } diff --git a/src/api/ruleMapping/ruleMapping.controller.spec.ts b/src/api/ruleMapping/ruleMapping.controller.spec.ts index f1f28b5..62e22cc 100644 --- a/src/api/ruleMapping/ruleMapping.controller.spec.ts +++ b/src/api/ruleMapping/ruleMapping.controller.spec.ts @@ -25,7 +25,6 @@ describe('RuleMappingController', () => { { provide: RuleMappingService, useValue: { - ruleSchemaFile: jest.fn(), ruleSchema: jest.fn(), evaluateRuleSchema: jest.fn(), }, @@ -37,21 +36,21 @@ describe('RuleMappingController', () => { service = module.get(RuleMappingService); }); - describe('getRuleFile', () => { - it('should return the rule file with the correct headers', async () => { + describe('getRuleSchema', () => { + it('should return the rule schema with the correct headers', async () => { const ruleFileName = 'test-rule.json'; - const filePath = `${ruleFileName}`; - const rulemap = { inputs: [], outputs: [] }; - jest.spyOn(service, 'ruleSchemaFile').mockResolvedValue(rulemap); + const ruleContent = { nodes: [], edges: [] }; + const rulemap = { inputs: [], outputs: [], resultOutputs: [] }; + jest.spyOn(service, 'ruleSchema').mockReturnValue(rulemap); const mockResponse = { setHeader: jest.fn(), send: jest.fn(), } as unknown as Response; - await controller.getRuleFile(ruleFileName, mockResponse); + await controller.getRuleSchema(ruleFileName, ruleContent, mockResponse); - expect(service.ruleSchemaFile).toHaveBeenCalledWith(filePath); + expect(service.ruleSchema).toHaveBeenCalledWith(ruleContent); expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); expect(mockResponse.setHeader).toHaveBeenCalledWith( 'Content-Disposition', @@ -59,21 +58,6 @@ describe('RuleMappingController', () => { ); expect(mockResponse.send).toHaveBeenCalledWith(rulemap); }); - - it('should handle errors properly', async () => { - const ruleFileName = 'test-rule.json'; - const error = new Error('File not found'); - jest.spyOn(service, 'ruleSchemaFile').mockRejectedValue(error); - - const mockResponse = { - setHeader: jest.fn(), - send: jest.fn(), - } as unknown as Response; - - await expect(controller.getRuleFile(ruleFileName, mockResponse)).rejects.toThrow( - new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR), - ); - }); }); describe('evaluateRuleMap', () => { @@ -86,18 +70,10 @@ describe('RuleMappingController', () => { const dto: EvaluateRuleMappingDto = { nodes, edges }; const response = await controller.evaluateRuleMap(dto); - expect(service.ruleSchema).toHaveBeenCalledWith(nodes, edges); + expect(service.ruleSchema).toHaveBeenCalledWith({ nodes, edges }); expect(response).toEqual({ result }); }); - it('should handle invalid request data', async () => { - const dto = { nodes: 'invalid' } as unknown as EvaluateRuleMappingDto; - - await expect(controller.evaluateRuleMap(dto)).rejects.toThrow( - new HttpException('Invalid request data', HttpStatus.BAD_REQUEST), - ); - }); - it('should handle errors properly', async () => { const nodes = [{ id: '1', type: 'someType', content: { inputs: [], outputs: [] } }]; const edges = [{ id: '2', type: 'someType', targetId: '1', sourceId: '1' }]; diff --git a/src/api/ruleMapping/ruleMapping.controller.ts b/src/api/ruleMapping/ruleMapping.controller.ts index 5887aa5..04fae18 100644 --- a/src/api/ruleMapping/ruleMapping.controller.ts +++ b/src/api/ruleMapping/ruleMapping.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Query, Res, Post, Body, HttpException, HttpStatus } from '@nestjs/common'; -import { RuleMappingService } from './ruleMapping.service'; +import { Controller, Res, Post, Body, HttpException, HttpStatus } from '@nestjs/common'; +import { RuleMappingService, InvalidRuleContent } from './ruleMapping.service'; import { Response } from 'express'; import { EvaluateRuleRunSchemaDto, EvaluateRuleMappingDto } from './dto/evaluate-rulemapping.dto'; @@ -9,30 +9,35 @@ export class RuleMappingController { // Map a rule file to its unique inputs, and all outputs @Post('/') - async getRuleFile(@Query('goRulesJSONFilename') goRulesJSONFilename: string, @Res() res: Response) { - const rulemap = await this.ruleMappingService.ruleSchemaFile(goRulesJSONFilename); + async getRuleSchema( + @Body('goRulesJSONFilename') goRulesJSONFilename: string, + @Body('ruleContent') ruleContent: EvaluateRuleMappingDto, + @Res() res: Response, + ) { + const rulemap = this.ruleMappingService.ruleSchema(ruleContent); try { res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename=${goRulesJSONFilename}`); res.send(rulemap); } catch (error) { - throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + if (error instanceof InvalidRuleContent) { + throw new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST); + } else { + throw new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR); + } } } // Map a rule to its unique inputs, and all outputs @Post('/evaluate') - async evaluateRuleMap(@Body() { nodes, edges }: EvaluateRuleMappingDto) { + async evaluateRuleMap(@Body() ruleContent: EvaluateRuleMappingDto) { try { - if (!nodes || !Array.isArray(nodes)) { - throw new HttpException('Invalid request data', HttpStatus.BAD_REQUEST); - } - const result = this.ruleMappingService.ruleSchema(nodes, edges); + const result = this.ruleMappingService.ruleSchema(ruleContent); return { result }; } catch (error) { - if (error instanceof HttpException && error.getStatus() === HttpStatus.BAD_REQUEST) { - throw error; + if (error instanceof InvalidRuleContent) { + throw new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST); } else { throw new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/src/api/ruleMapping/ruleMapping.interface.ts b/src/api/ruleMapping/ruleMapping.interface.ts index 8993831..5caa6fd 100644 --- a/src/api/ruleMapping/ruleMapping.interface.ts +++ b/src/api/ruleMapping/ruleMapping.interface.ts @@ -37,6 +37,11 @@ export interface Edge { targetHandle?: string; } +export interface RuleContent { + nodes: Node[]; + edges: Edge[]; +} + export interface TraceObjectEntry extends ZenEngineTrace { id: string; name: string; diff --git a/src/api/ruleMapping/ruleMapping.service.spec.ts b/src/api/ruleMapping/ruleMapping.service.spec.ts index 46ee873..6b6c589 100644 --- a/src/api/ruleMapping/ruleMapping.service.spec.ts +++ b/src/api/ruleMapping/ruleMapping.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { RuleMappingService } from './ruleMapping.service'; -import { Node, TraceObject, Edge } from './ruleMapping.interface'; +import { RuleMappingService, InvalidRuleContent } from './ruleMapping.service'; +import { Node, TraceObject, Edge, RuleContent } from './ruleMapping.interface'; import { DocumentsService } from '../documents/documents.service'; import { ConfigService } from '@nestjs/config'; @@ -360,7 +360,7 @@ describe('RuleMappingService', () => { const edges = [{ id: '1', type: 'someType', targetId: '2', sourceId: '1' }]; // Edge connects input node to output node - const result = service.ruleSchema(nodes, edges); + const result = service.ruleSchema({ nodes, edges }); expect(result).toEqual({ inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], outputs: [ @@ -370,6 +370,11 @@ describe('RuleMappingService', () => { resultOutputs: [], }); }); + + it('should handle invalid request data', async () => { + const ruleContent = { nodes: 'invalid' } as unknown as RuleContent; + expect(() => service.ruleSchema(ruleContent)).toThrow(new InvalidRuleContent('Rule has no nodes')); + }); }); describe('evaluateRuleSchema', () => { @@ -480,57 +485,4 @@ describe('RuleMappingService', () => { }); }); }); - - describe('ruleSchemaFile', () => { - it('should generate a rule schema from a file', async () => { - const mockFileContent = JSON.stringify({ - nodes: [ - { - id: '1', - type: 'inputNode', // Assuming this node is an input node - content: { - inputs: [ - { id: '1', name: 'Input1', type: 'string', field: 'field1' }, - { id: '2', name: 'Input2', type: 'number', field: 'field2' }, - ], - }, - }, - { - id: '2', - type: 'outputNode', // This node is the output node - content: { - outputs: [ - { id: '3', name: 'Output1', type: 'string', field: 'field2' }, - { id: '4', name: 'Output2', type: 'number', field: 'field3' }, - ], - }, - }, - ], - edges: [ - { - id: '1', - type: 'someType', - targetId: '2', - sourceId: '1', - }, - ], - }); - - const mockGetFileContent = jest.fn().mockResolvedValue(mockFileContent); - DocumentsService.prototype.getFileContent = mockGetFileContent; - - const filePath = 'path/to/mock/file.json'; - const result = await service.ruleSchemaFile(filePath); - - expect(mockGetFileContent).toHaveBeenCalledWith(filePath); - expect(result).toEqual({ - resultOutputs: [], - inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], - outputs: [ - { id: '3', name: 'Output1', type: 'string', property: 'field2' }, - { id: '4', name: 'Output2', type: 'number', property: 'field3' }, - ], - }); - }); - }); }); diff --git a/src/api/ruleMapping/ruleMapping.service.ts b/src/api/ruleMapping/ruleMapping.service.ts index f957704..4256ff7 100644 --- a/src/api/ruleMapping/ruleMapping.service.ts +++ b/src/api/ruleMapping/ruleMapping.service.ts @@ -1,17 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { Node, Edge, TraceObject, Field } from './ruleMapping.interface'; -import { DocumentsService } from '../documents/documents.service'; -import { ConfigService } from '@nestjs/config'; +import { Node, Edge, TraceObject, Field, RuleContent } from './ruleMapping.interface'; +import { RuleSchema } from '../scenarioData/scenarioData.interface'; + +export class InvalidRuleContent extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidRuleContentError'; + } +} @Injectable() export class RuleMappingService { - rulesDirectory: string; - constructor( - private documentsService: DocumentsService, - private configService: ConfigService, - ) { - this.rulesDirectory = this.configService.get('RULES_DIRECTORY'); - } + constructor() {} // Extract all fields from a list of nodes extractFields(nodes: Node[], fieldKey: 'inputs' | 'outputs'): { [key: string]: any[] } { @@ -103,14 +103,14 @@ export class RuleMappingService { } // generate a rule schema from a list of nodes that represent the origin inputs and all outputs of a rule - ruleSchema( - nodes: Node[], - edges: Edge[], - ): { - inputs: any[]; - outputs: any[]; - resultOutputs: any[]; - } { + ruleSchema(ruleContent: RuleContent): RuleSchema { + if (!ruleContent) { + throw new InvalidRuleContent('No content'); + } + const { nodes, edges } = ruleContent; + if (!nodes || !Array.isArray(nodes)) { + throw new InvalidRuleContent('Rule has no nodes'); + } const inputs: any[] = this.extractUniqueInputs(nodes).uniqueInputs; const generalOutputs: any[] = this.extractFields(nodes, 'outputs').outputs; const resultOutputs: any[] = this.extractResultOutputs(nodes, edges).resultOutputs; @@ -148,11 +148,4 @@ export class RuleMappingService { } return { input, output }; } - - // generate a rule schema from a given local file - async ruleSchemaFile(ruleFileName: string): Promise { - const fileContent = await this.documentsService.getFileContent(ruleFileName); - const { nodes, edges } = await JSON.parse(fileContent.toString()); - return this.ruleSchema(nodes, edges); - } } diff --git a/src/api/scenarioData/scenarioData.controller.spec.ts b/src/api/scenarioData/scenarioData.controller.spec.ts index 544c373..31f12d0 100644 --- a/src/api/scenarioData/scenarioData.controller.spec.ts +++ b/src/api/scenarioData/scenarioData.controller.spec.ts @@ -206,6 +206,7 @@ describe('ScenarioDataController', () => { describe('getCSVForRuleRun', () => { it('should return CSV content with correct headers', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const csvContent = `Scenario,Input: familyComposition,Input: numberOfChildren,Output: isEligible,Output: baseAmount Scenario 1,single,,true, Scenario 2,couple,3,,200`; @@ -218,7 +219,7 @@ Scenario 2,couple,3,,200`; setHeader: jest.fn(), }; - await controller.getCSVForRuleRun(goRulesJSONFilename, mockResponse as any); + await controller.getCSVForRuleRun(goRulesJSONFilename, ruleContent, mockResponse as any); expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv'); @@ -232,6 +233,7 @@ Scenario 2,couple,3,,200`; it('should throw an error if service fails', async () => { const errorMessage = 'Error generating CSV for rule run'; const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; jest.spyOn(service, 'getCSVForRuleRun').mockRejectedValue(new Error(errorMessage)); const mockResponse = { @@ -240,11 +242,11 @@ Scenario 2,couple,3,,200`; }; await expect(async () => { - await controller.getCSVForRuleRun(goRulesJSONFilename, mockResponse as any); + await controller.getCSVForRuleRun(goRulesJSONFilename, ruleContent, mockResponse as any); }).rejects.toThrow(Error); try { - await controller.getCSVForRuleRun(goRulesJSONFilename, mockResponse as any); + await controller.getCSVForRuleRun(goRulesJSONFilename, ruleContent, mockResponse as any); } catch (error) { expect(error.message).toBe('Error generating CSV for rule run'); } @@ -252,15 +254,16 @@ Scenario 2,couple,3,,200`; }); describe('uploadCSVAndProcess', () => { it('should throw an error if no file is uploaded', async () => { + const ruleContent = { nodes: [], edges: [] }; 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, - ); + await expect( + controller.uploadCSVAndProcess(undefined, res as Response, 'test.json', ruleContent), + ).rejects.toThrow(HttpException); expect(res.status).not.toHaveBeenCalled(); expect(res.setHeader).not.toHaveBeenCalled(); @@ -281,6 +284,7 @@ Scenario 2,couple,3,,200`; filename: '', path: '', }; + const ruleContent = { nodes: [], edges: [] }; const scenarios = [ { @@ -303,10 +307,10 @@ Scenario 2,couple,3,,200`; send: jest.fn(), }; - await controller.uploadCSVAndProcess(file, res as Response, 'test.json'); + await controller.uploadCSVAndProcess(file, res as Response, 'test.json', ruleContent); expect(service.processProvidedScenarios).toHaveBeenCalledWith('test.json', file); - expect(service.getCSVForRuleRun).toHaveBeenCalledWith('test.json', scenarios); + expect(service.getCSVForRuleRun).toHaveBeenCalledWith('test.json', ruleContent, 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); @@ -327,6 +331,7 @@ Scenario 2,couple,3,,200`; filename: '', path: '', }; + const ruleContent = { nodes: [], edges: [] }; mockScenarioDataService.processProvidedScenarios.mockRejectedValue(new Error('Mocked error')); @@ -336,7 +341,7 @@ Scenario 2,couple,3,,200`; send: jest.fn(), }; - await expect(controller.uploadCSVAndProcess(file, res as Response, 'test.json')).rejects.toThrow( + await expect(controller.uploadCSVAndProcess(file, res as Response, 'test.json', ruleContent)).rejects.toThrow( new HttpException('Error processing CSV file', HttpStatus.INTERNAL_SERVER_ERROR), ); diff --git a/src/api/scenarioData/scenarioData.controller.ts b/src/api/scenarioData/scenarioData.controller.ts index 8cd95c3..f8a4667 100644 --- a/src/api/scenarioData/scenarioData.controller.ts +++ b/src/api/scenarioData/scenarioData.controller.ts @@ -16,6 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; import { ScenarioDataService } from './scenarioData.service'; import { ScenarioData } from './scenarioData.schema'; +import { RuleContent } from '../ruleMapping/ruleMapping.interface'; import { CreateScenarioDto } from './dto/create-scenario.dto'; import { FileNotFoundError } from '../../utils/readFile'; @@ -112,9 +113,13 @@ export class ScenarioDataController { } @Post('/evaluation') - async getCSVForRuleRun(@Body('goRulesJSONFilename') goRulesJSONFilename: string, @Res() res: Response) { + async getCSVForRuleRun( + @Body('goRulesJSONFilename') goRulesJSONFilename: string, + @Body('ruleContent') ruleContent: RuleContent, + @Res() res: Response, + ) { try { - const fileContent = await this.scenarioDataService.getCSVForRuleRun(goRulesJSONFilename); + const fileContent = await this.scenarioDataService.getCSVForRuleRun(goRulesJSONFilename, ruleContent); res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename=${goRulesJSONFilename.replace(/\.json$/, '.csv')}`); res.status(HttpStatus.OK).send(fileContent); @@ -126,9 +131,10 @@ export class ScenarioDataController { @Post('/run-decisions') async runDecisionsForScenarios( @Body('goRulesJSONFilename') goRulesJSONFilename: string, + @Body('ruleContent') ruleContent: RuleContent, ): Promise<{ [scenarioId: string]: any }> { try { - return await this.scenarioDataService.runDecisionsForScenarios(goRulesJSONFilename); + return await this.scenarioDataService.runDecisionsForScenarios(goRulesJSONFilename, ruleContent); } catch (error) { throw new HttpException('Error running scenario decisions', HttpStatus.INTERNAL_SERVER_ERROR); } @@ -140,6 +146,7 @@ export class ScenarioDataController { @UploadedFile() file: Express.Multer.File | undefined, @Res() res: Response, @Body('goRulesJSONFilename') goRulesJSONFilename: string, + @Body('ruleContent') ruleContent: RuleContent, ) { if (!file) { throw new HttpException('No file uploaded', HttpStatus.BAD_REQUEST); @@ -147,7 +154,7 @@ export class ScenarioDataController { try { const scenarios = await this.scenarioDataService.processProvidedScenarios(goRulesJSONFilename, file); - const csvContent = await this.scenarioDataService.getCSVForRuleRun(goRulesJSONFilename, scenarios); + const csvContent = await this.scenarioDataService.getCSVForRuleRun(goRulesJSONFilename, ruleContent, scenarios); res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename=processed_data.csv`); res.status(HttpStatus.OK).send(csvContent); diff --git a/src/api/scenarioData/scenarioData.interface.ts b/src/api/scenarioData/scenarioData.interface.ts index b118873..7aae7c2 100644 --- a/src/api/scenarioData/scenarioData.interface.ts +++ b/src/api/scenarioData/scenarioData.interface.ts @@ -1,6 +1,7 @@ export interface RuleSchema { - inputs: Array<{ id: string; property: string }>; - resultOutputs: Array<{ id: string; property: string }>; + inputs: Array<{ id: string; name?: string; property: string }>; + outputs: Array<{ id: string; name?: string; property: string }>; + resultOutputs: Array<{ id: string; name?: string; property: string }>; } export interface RuleRunResults { diff --git a/src/api/scenarioData/scenarioData.service.spec.ts b/src/api/scenarioData/scenarioData.service.spec.ts index 68c725b..8fe0d2c 100644 --- a/src/api/scenarioData/scenarioData.service.spec.ts +++ b/src/api/scenarioData/scenarioData.service.spec.ts @@ -2,10 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ScenarioDataService } from './scenarioData.service'; import { getModelToken } from '@nestjs/mongoose'; import { Types, Model } from 'mongoose'; +import { ConfigService } from '@nestjs/config'; import { ScenarioData, ScenarioDataDocument } from './scenarioData.schema'; +import { RuleSchema } from './scenarioData.interface'; 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'; @@ -294,6 +295,7 @@ describe('ScenarioDataService', () => { describe('runDecisionsForScenarios', () => { it('should run decisions for scenarios and map inputs/outputs correctly', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const scenarios = [ { _id: testObjectId, @@ -312,11 +314,12 @@ describe('ScenarioDataService', () => { expectedResults: [], }, ]; - const ruleSchema = { + const ruleSchemaOutput: RuleSchema = { inputs: [ { id: 'id1', name: 'Family Composition', property: 'familyComposition' }, { id: 'id2', name: 'Number of Children', property: 'numberOfChildren' }, ], + outputs: [], resultOutputs: [ { id: 'id3', name: 'Is Eligible', property: 'isEligible' }, { id: 'id4', name: 'Base Amount', property: 'baseAmount' }, @@ -343,10 +346,10 @@ describe('ScenarioDataService', () => { }; jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(scenarios); - jest.spyOn(ruleMappingService, 'ruleSchemaFile').mockResolvedValue(ruleSchema); - jest.spyOn(decisionsService, 'runDecisionByFile').mockResolvedValue(decisionResult); + jest.spyOn(ruleMappingService, 'ruleSchema').mockReturnValue(ruleSchemaOutput); + jest.spyOn(decisionsService, 'runDecisionByContent').mockResolvedValue(decisionResult); - const results = await service.runDecisionsForScenarios(goRulesJSONFilename); + const results = await service.runDecisionsForScenarios(goRulesJSONFilename, ruleContent); expect(results).toEqual({ 'Scenario 1': { @@ -371,6 +374,7 @@ describe('ScenarioDataService', () => { }); it('should handle errors in decision execution', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const scenarios = [ { _id: testObjectId, @@ -383,14 +387,15 @@ describe('ScenarioDataService', () => { ]; const ruleSchema = { inputs: [{ id: 'id1', name: 'Family Composition', property: 'familyComposition' }], + outputs: [], 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')); + jest.spyOn(ruleMappingService, 'ruleSchema').mockReturnValue(ruleSchema); + jest.spyOn(decisionsService, 'runDecisionByContent').mockRejectedValue(new Error('Decision execution error')); - const results = await service.runDecisionsForScenarios(goRulesJSONFilename); + const results = await service.runDecisionsForScenarios(goRulesJSONFilename, ruleContent); expect(results).toEqual({ [testObjectId.toString()]: { error: 'Decision execution error' }, }); @@ -398,6 +403,7 @@ describe('ScenarioDataService', () => { it('should handle scenarios with no variables', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const scenarios = [ { _id: testObjectId, @@ -408,8 +414,9 @@ describe('ScenarioDataService', () => { expectedResults: [], }, ]; - const ruleSchema = { + const ruleSchema: RuleSchema = { inputs: [], + outputs: [], resultOutputs: [{ id: 'id3', name: 'Is Eligible', property: 'isEligible' }], }; const decisionResult = { @@ -426,10 +433,10 @@ describe('ScenarioDataService', () => { }; jest.spyOn(service, 'getScenariosByFilename').mockResolvedValue(scenarios); - jest.spyOn(ruleMappingService, 'ruleSchemaFile').mockResolvedValue(ruleSchema); - jest.spyOn(decisionsService, 'runDecisionByFile').mockResolvedValue(decisionResult); + jest.spyOn(ruleMappingService, 'ruleSchema').mockReturnValue(ruleSchema); + jest.spyOn(decisionsService, 'runDecisionByContent').mockResolvedValue(decisionResult); - const results = await service.runDecisionsForScenarios(goRulesJSONFilename); + const results = await service.runDecisionsForScenarios(goRulesJSONFilename, ruleContent); expect(results).toEqual({ 'Scenario 1': { @@ -448,6 +455,7 @@ describe('ScenarioDataService', () => { describe('getCSVForRuleRun', () => { it('should generate a CSV with correct headers and data', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const ruleRunResults = { 'Scenario 1': { inputs: { familyComposition: 'single', numberOfChildren: 2 }, @@ -463,7 +471,7 @@ describe('ScenarioDataService', () => { jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); - const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename, ruleContent); const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,2\nScenario 2,Fail,couple,3`; @@ -472,6 +480,7 @@ describe('ScenarioDataService', () => { it('should generate a CSV with missing inputs/outputs filled as empty strings', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const ruleRunResults = { 'Scenario 1': { inputs: { familyComposition: 'single' }, @@ -487,7 +496,7 @@ describe('ScenarioDataService', () => { jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); - const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename, ruleContent); const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,\nScenario 2,Fail,couple,3`; @@ -496,6 +505,7 @@ describe('ScenarioDataService', () => { it('should generate a CSV with only one scenario', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const ruleRunResults = { 'Scenario 1': { inputs: { familyComposition: 'single', numberOfChildren: 2 }, @@ -506,7 +516,7 @@ describe('ScenarioDataService', () => { jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); - const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename, ruleContent); const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: familyComposition,Input: numberOfChildren\nScenario 1,Fail,single,2`; @@ -515,11 +525,12 @@ describe('ScenarioDataService', () => { it('should generate an empty CSV if no scenarios are present', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const ruleRunResults = {}; jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); - const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename, ruleContent); const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail)`; @@ -528,6 +539,7 @@ describe('ScenarioDataService', () => { it('should handle scenarios with no variables or outputs', async () => { const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; const ruleRunResults = { 'Scenario 1': { inputs: {}, @@ -537,7 +549,7 @@ describe('ScenarioDataService', () => { jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); - const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename); + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename, ruleContent); const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail)\nScenario 1,Fail`; diff --git a/src/api/scenarioData/scenarioData.service.ts b/src/api/scenarioData/scenarioData.service.ts index 5105949..a18952b 100644 --- a/src/api/scenarioData/scenarioData.service.ts +++ b/src/api/scenarioData/scenarioData.service.ts @@ -2,8 +2,10 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model, Types } from 'mongoose'; import { ScenarioData, ScenarioDataDocument } from './scenarioData.schema'; +import { RuleContent } from '../ruleMapping/ruleMapping.interface'; import { DecisionsService } from '../decisions/decisions.service'; import { RuleMappingService } from '../ruleMapping/ruleMapping.service'; +import { DocumentsService } from '../documents/documents.service'; import { RuleSchema, RuleRunResults } from './scenarioData.interface'; import { isEqual, reduceToCleanObj, extractUniqueKeys } from '../../utils/helpers'; import { mapTraces } from '../../utils/handleTrace'; @@ -14,7 +16,7 @@ export class ScenarioDataService { constructor( private decisionsService: DecisionsService, private ruleMappingService: RuleMappingService, - + private documentsService: DocumentsService, @InjectModel(ScenarioData.name) private scenarioDataModel: Model, ) {} @@ -99,10 +101,15 @@ export class ScenarioDataService { */ async runDecisionsForScenarios( goRulesJSONFilename: string, + ruleContent?: RuleContent, newScenarios?: ScenarioData[], ): Promise<{ [scenarioId: string]: any }> { const scenarios = newScenarios || (await this.getScenariosByFilename(goRulesJSONFilename)); - const ruleSchema: RuleSchema = await this.ruleMappingService.ruleSchemaFile(goRulesJSONFilename); + if (!ruleContent) { + const fileContent = await this.documentsService.getFileContent(goRulesJSONFilename); + ruleContent = await JSON.parse(fileContent.toString()); + } + const ruleSchema: RuleSchema = this.ruleMappingService.ruleSchema(ruleContent); const results: { [scenarioId: string]: any } = {}; for (const scenario of scenarios as ScenarioDataDocument[]) { @@ -110,10 +117,13 @@ export class ScenarioDataService { const formattedExpectedResultsObject = reduceToCleanObj(scenario?.expectedResults, 'name', 'value'); try { - const decisionResult = await this.decisionsService.runDecisionByFile( - scenario.goRulesJSONFilename, + const decisionResult = await this.decisionsService.runDecision( + ruleContent, + goRulesJSONFilename, formattedVariablesObject, - { trace: true }, + { + trace: true, + }, ); const resultMatches = @@ -143,8 +153,16 @@ export class ScenarioDataService { * 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); + async getCSVForRuleRun( + goRulesJSONFilename: string, + ruleContent: RuleContent, + newScenarios?: ScenarioData[], + ): Promise { + const ruleRunResults: RuleRunResults = await this.runDecisionsForScenarios( + goRulesJSONFilename, + ruleContent, + newScenarios, + ); const inputKeys = extractUniqueKeys(ruleRunResults, 'inputs'); const outputKeys = extractUniqueKeys(ruleRunResults, 'result');