diff --git a/README.md b/README.md index f706f6d..c02ded3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BRMS API/Backend -This project is the API/Backend for the SDPR Business Rules Engine (BRE) and Business Rules Engine Management System (BRMS). It will act as a middle layer between GoRules, CHEFS, and the [frontend](https://github.com/bcgov/brms-simulator-frontend) or any other integration. +This project is the API/Backend for the SDPR Business Rules Engine (BRE) and Business Rules Engine Management System (BRMS). It will act primarly as the backend for the [frontend simulator](https://github.com/bcgov/brms-simulator-frontend). ## Local Development Setup @@ -14,7 +14,6 @@ Before running your application locally, you'll need some environment variables. - MONGODB_URL: The URL for connecting to the MongoDB instance you created in the previous step. Set it to something like mongodb://localhost/nest. - FRONTEND_URI: The URI for the frontend application. Set it to http://localhost:8080. -- CHEFS_API_URL: The URL for the Chefs API. Set it to https://submit.digital.gov.bc.ca/app/api/v1. ### Including Rules from the Rules Repository 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/ruleData/ruleData.controller.ts b/src/api/ruleData/ruleData.controller.ts index a70bbd6..ab497dc 100644 --- a/src/api/ruleData/ruleData.controller.ts +++ b/src/api/ruleData/ruleData.controller.ts @@ -11,7 +11,7 @@ export class RuleDataController { try { return await this.ruleDataService.getAllRuleData(); } catch (error) { - throw new HttpException('Error getting submissions', HttpStatus.INTERNAL_SERVER_ERROR); + throw new HttpException('Error getting list of rule data', HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -20,7 +20,7 @@ export class RuleDataController { try { return await this.ruleDataService.getRuleData(ruleId); } catch (error) { - throw new HttpException('Error getting submissions', HttpStatus.INTERNAL_SERVER_ERROR); + throw new HttpException('Error getting rule data', HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/src/api/ruleData/ruleData.schema.ts b/src/api/ruleData/ruleData.schema.ts index ef0bc39..652d02d 100644 --- a/src/api/ruleData/ruleData.schema.ts +++ b/src/api/ruleData/ruleData.schema.ts @@ -13,12 +13,6 @@ export class RuleData { @Prop({ required: true, description: 'The filename of the JSON file containing the rule' }) goRulesJSONFilename: string; - - @Prop({ description: 'The ID of the form in Chefs that corresponds to this rule' }) - chefsFormId: string; - - @Prop({ description: 'The API key of the CHEFS form - needed to access submissions' }) - chefsFormAPIKey: string; } export const RuleDataSchema = SchemaFactory.createForClass(RuleData); diff --git a/src/api/ruleData/ruleData.service.spec.ts b/src/api/ruleData/ruleData.service.spec.ts index a29683f..2ffa8e4 100644 --- a/src/api/ruleData/ruleData.service.spec.ts +++ b/src/api/ruleData/ruleData.service.spec.ts @@ -8,8 +8,6 @@ export const mockRuleData = { _id: 'testId', title: 'Title', goRulesJSONFilename: 'filename.json', - chefsFormId: 'formId', - chefsFormAPIKey: '12345', }; export const mockServiceProviders = [ diff --git a/src/api/ruleData/ruleData.service.ts b/src/api/ruleData/ruleData.service.ts index 6d0be46..1646b78 100644 --- a/src/api/ruleData/ruleData.service.ts +++ b/src/api/ruleData/ruleData.service.ts @@ -77,14 +77,6 @@ export class RuleDataService { } } - async getFormAPIKeyForFormId(chefsFormId: string): Promise { - const ruleData = await this.ruleDataModel.findOne({ chefsFormId }).exec(); - if (!ruleData) { - throw new Error(`Rule data not found for CHEFS form id: ${chefsFormId}`); - } - return ruleData.chefsFormAPIKey; - } - /** * Add rules to the db that exist in the repo, but not yet the db */ diff --git a/src/api/ruleMapping/ruleMapping.controller.spec.ts b/src/api/ruleMapping/ruleMapping.controller.spec.ts index f1f28b5..3302c9c 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').mockResolvedValue(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', () => { @@ -81,23 +65,15 @@ describe('RuleMappingController', () => { const nodes = [{ id: '1', type: 'someType', content: { inputs: [], outputs: [] } }]; const edges = [{ id: '2', type: 'someType', targetId: '1', sourceId: '1' }]; const result = { inputs: [], outputs: [], resultOutputs: [] }; - jest.spyOn(service, 'ruleSchema').mockReturnValue(result); + jest.spyOn(service, 'ruleSchema').mockResolvedValue(result); 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..8442d37 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 = await 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 = await 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..a3c576b 100644 --- a/src/api/ruleMapping/ruleMapping.interface.ts +++ b/src/api/ruleMapping/ruleMapping.interface.ts @@ -9,7 +9,9 @@ export interface Field { export interface InputField extends Field {} -export interface OutputField extends Field {} +export interface OutputField extends Field { + exception?: string; +} export interface Expression { key: string; @@ -20,12 +22,13 @@ export interface NodeContent { inputs?: InputField[]; outputs?: OutputField[]; expressions?: Expression[]; + key?: string; } export interface Node { id: any; type: string; - content: NodeContent; + content: NodeContent | string; } export interface Edge { @@ -37,6 +40,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..7a3b4cf 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'; @@ -29,7 +29,7 @@ describe('RuleMappingService', () => { }); describe('extractFields', () => { - it('should extract inputs correctly', () => { + it('should extract inputs correctly', async () => { const nodes: Node[] = [ { type: 'someType', @@ -51,17 +51,17 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractFields(nodes, 'inputs'); + const result = await service.extractFields(nodes, 'inputs'); expect(result).toEqual({ inputs: [ { id: '1', name: 'Input1', type: 'string', property: 'field1' }, { id: '2', name: 'Input2', type: 'number', property: 'field2' }, - { key: 'expr1', property: 'field3' }, + { key: 'expr1', property: 'field3', exception: null }, ], }); }); - it('should extract outputs correctly', () => { + it('should extract outputs correctly', async () => { const nodes: Node[] = [ { type: 'someType', @@ -75,7 +75,7 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractFields(nodes, 'outputs'); + const result = await service.extractFields(nodes, 'outputs'); expect(result).toEqual({ outputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field1' }, @@ -84,15 +84,171 @@ describe('RuleMappingService', () => { }); }); - it('should handle empty nodes array', () => { + it('should handle empty nodes array', async () => { const nodes: Node[] = []; - const result = service.extractFields(nodes, 'outputs'); + const result = await service.extractFields(nodes, 'outputs'); expect(result).toEqual({ outputs: [] }); }); + it('should handle decisionNode correctly', async () => { + const nodes: Node[] = [ + { + type: 'decisionNode', + content: { + key: 'someKey', + }, + id: 'testNode', + }, + ]; + + // Mock the ruleSchemaFile method to return a sample schema + jest.spyOn(service, 'ruleSchemaFile').mockResolvedValue({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + resultOutputs: [{ id: '2', name: 'Output1', type: 'number', property: 'field2' }], + }); + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + }); + + const resultOutputs = await service.extractFields(nodes, 'outputs'); + expect(resultOutputs).toEqual({ + outputs: [{ id: '2', name: 'Output1', type: 'number', property: 'field2' }], + }); + }); + + it('should handle expressionNode with simple expressions correctly', async () => { + const nodes: Node[] = [ + { + type: 'expressionNode', + content: { + expressions: [ + { key: 'expr1', value: 'field3' }, + { key: 'expr2', value: '123' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [ + { key: 'expr1', property: 'field3', exception: null }, + { key: 'expr2', property: '123', exception: null }, + ], + }); + }); + + it('should handle expressionNode with complex expressions correctly', async () => { + const nodes: Node[] = [ + { + type: 'expressionNode', + content: { + expressions: [ + { key: 'expr1', value: 'field3 > 5' }, + { key: 'expr2', value: 'complexExpr + 2' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [ + { key: 'expr1', property: 'expr1', exception: 'field3 > 5' }, + { key: 'expr2', property: 'expr2', exception: 'complexExpr + 2' }, + ], + }); + }); + + it('should handle functionNode correctly', async () => { + const nodes: Node[] = [ + { + type: 'functionNode', + content: ` + /** + * @param input1 + * @param input2 + * @returns output1 + */ + `, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [ + { key: 'input1', property: 'input1' }, + { key: 'input2', property: 'input2' }, + ], + }); + + const resultOutputs = await service.extractFields(nodes, 'outputs'); + expect(resultOutputs).toEqual({ + outputs: [{ key: 'output1', property: 'output1' }], + }); + }); + + it('should handle nodes with unknown type correctly', async () => { + const nodes: Node[] = [ + { + type: 'unknownType', + content: { + inputs: [{ id: '1', name: 'Input1', type: 'string', field: 'field1' }], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + }); + }); + + it('should handle nodes without the specified fieldKey correctly', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [{ id: '1', name: 'Input1', type: 'string', field: 'field1' }], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'outputs'); + expect(result).toEqual({ + outputs: [], + }); + }); + + it('should handle nodes with duplicate properties correctly', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field1' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractFields(nodes, 'inputs'); + expect(result).toEqual({ + inputs: [{ id: '2', name: 'Input2', type: 'number', property: 'field1' }], + }); + }); }); describe('extractResultOutputs', () => { - it('should extract result outputs correctly when there is one output node and corresponding edges', () => { + it('should extract result outputs correctly when there is one output node and corresponding edges', async () => { const nodes: Node[] = [ { id: '1', @@ -122,14 +278,14 @@ describe('RuleMappingService', () => { { id: '2', type: 'someType', sourceId: '3', targetId: '2' }, ]; - jest.spyOn(service, 'extractFields').mockReturnValue({ + jest.spyOn(service, 'extractFields').mockResolvedValue({ outputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, { id: '2', name: 'Output2', type: 'number', property: 'field3' }, ], }); - const result = service.extractResultOutputs(nodes, edges); + const result = await service.extractResultOutputs(nodes, edges); expect(result).toEqual({ resultOutputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, @@ -138,7 +294,7 @@ describe('RuleMappingService', () => { }); }); - it('should throw an error if no outputNode is found', () => { + it('should throw an error if no outputNode is found', async () => { const nodes: Node[] = [ { id: '1', @@ -151,10 +307,15 @@ describe('RuleMappingService', () => { const edges: Edge[] = [{ id: '1', type: 'someType', sourceId: '1', targetId: '2' }]; - expect(() => service.extractResultOutputs(nodes, edges)).toThrow('No outputNode found in the nodes array'); + try { + await service.extractResultOutputs(nodes, edges); + fail('Expected extractResultOutputs to throw an error'); + } catch (error) { + expect(error.message).toBe('No outputNode found in the nodes array'); + } }); - it('should return an empty array if no target edges are found for the output node', () => { + it('should return an empty array if no target edges are found for the output node', async () => { const nodes: Node[] = [ { id: '1', @@ -174,17 +335,17 @@ describe('RuleMappingService', () => { const edges: Edge[] = []; - jest.spyOn(service, 'extractFields').mockReturnValue({ + jest.spyOn(service, 'extractFields').mockResolvedValue({ outputs: [], }); - const result = service.extractResultOutputs(nodes, edges); + const result = await service.extractResultOutputs(nodes, edges); expect(result).toEqual({ resultOutputs: [], }); }); - it('should handle cases where there are multiple output nodes', () => { + it('should handle cases where there are multiple output nodes', async () => { const nodes: Node[] = [ { id: '1', @@ -214,14 +375,14 @@ describe('RuleMappingService', () => { { id: '2', type: 'someType', sourceId: '1', targetId: '3' }, ]; - jest.spyOn(service, 'extractFields').mockReturnValue({ + jest.spyOn(service, 'extractFields').mockResolvedValue({ outputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, { id: '2', name: 'Output2', type: 'number', property: 'field3' }, ], }); - const result = service.extractResultOutputs(nodes, edges); + const result = await service.extractResultOutputs(nodes, edges); expect(result).toEqual({ resultOutputs: [ { id: '1', name: 'Output1', type: 'string', property: 'field2' }, @@ -232,7 +393,7 @@ describe('RuleMappingService', () => { }); describe('extractInputsAndOutputs', () => { - it('should extract inputs and outputs correctly', () => { + it('should extract inputs and outputs correctly', async () => { const nodes: Node[] = [ { type: 'someType', @@ -257,24 +418,24 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractInputsAndOutputs(nodes); + const result = await service.extractInputsAndOutputs(nodes); expect(result).toEqual({ inputs: [ { id: '1', name: 'Input1', type: 'string', property: 'field1' }, { id: '2', name: 'Input2', type: 'number', property: 'field2' }, - { key: 'expr1', property: 'field5' }, + { key: 'expr1', property: 'field5', exception: null }, ], outputs: [ { id: '3', name: 'Output1', type: 'string', property: 'field3' }, { id: '4', name: 'Output2', type: 'number', property: 'field4' }, - { key: 'field5', property: 'expr1' }, + { key: 'field5', property: 'expr1', exception: null }, ], }); }); - it('should handle empty nodes array', () => { + it('should handle empty nodes array', async () => { const nodes: Node[] = []; - const result = service.extractInputsAndOutputs(nodes); + const result = await service.extractInputsAndOutputs(nodes); expect(result).toEqual({ inputs: [], outputs: [] }); }); }); @@ -294,7 +455,7 @@ describe('RuleMappingService', () => { }); describe('extractUniqueInputs', () => { - it('should extract unique inputs correctly', () => { + it('should extract unique inputs correctly', async () => { const nodes: Node[] = [ { type: 'someType', @@ -309,13 +470,13 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractUniqueInputs(nodes); + const result = await service.extractUniqueInputs(nodes); expect(result).toEqual({ uniqueInputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], }); }); - it('should handle nodes without inputs', () => { + it('should handle nodes without inputs', async () => { const nodes: Node[] = [ { type: 'someType', @@ -326,15 +487,162 @@ describe('RuleMappingService', () => { }, ]; - const result = service.extractUniqueInputs(nodes); + const result = await service.extractUniqueInputs(nodes); expect(result).toEqual({ uniqueInputs: [], }); }); + + it('should handle nodes with inputs and no outputs', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [ + { id: '1', name: 'Input1', type: 'string', property: 'field1' }, + { id: '2', name: 'Input2', type: 'number', property: 'field2' }, + ], + }); + }); + + it('should handle nodes with inputs and outputs that match', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + outputs: [ + { id: '3', name: 'Output1', type: 'string', field: 'field1' }, + { id: '4', name: 'Output2', type: 'number', field: 'field2' }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [], + }); + }); + + it('should handle nodes with transformed inputs as exceptions', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + outputs: [ + { + id: '3', + name: 'Output1', + type: 'string', + field: 'field3', + exception: 'field2', + }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [ + { id: '1', name: 'Input1', type: 'string', property: 'field1' }, + { id: '2', name: 'Input2', type: 'number', property: 'field2' }, + ], + }); + }); + + it('should handle multiple nodes with overlapping inputs and outputs', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [{ id: '1', name: 'Input1', type: 'string', field: 'field1' }], + outputs: [{ id: '2', name: 'Output1', type: 'string', field: 'field2' }], + }, + id: 'testNode1', + }, + { + type: 'someType', + content: { + inputs: [ + { id: '3', name: 'Input3', type: 'boolean', field: 'field3' }, + { id: '4', name: 'Input4', type: 'number', field: 'field4' }, + ], + outputs: [{ id: '5', name: 'Output2', type: 'boolean', field: 'field3' }], + }, + id: 'testNode2', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [ + { id: '1', name: 'Input1', type: 'string', property: 'field1' }, + { id: '4', name: 'Input4', type: 'number', property: 'field4' }, + ], + }); + }); + + it('should handle nodes with exceptions correctly', async () => { + const nodes: Node[] = [ + { + type: 'someType', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + outputs: [ + { + id: '3', + name: 'Output1', + type: 'string', + field: 'field1', + exception: 'field1', + }, + { + id: '4', + name: 'Output2', + type: 'number', + field: 'field3', + exception: 'field2', + }, + ], + }, + id: 'testNode', + }, + ]; + + const result = await service.extractUniqueInputs(nodes); + expect(result).toEqual({ + uniqueInputs: [{ id: '2', name: 'Input2', type: 'number', property: 'field2' }], + }); + }); }); describe('ruleSchema', () => { - it('should generate a rule schema correctly', () => { + it('should generate a rule schema correctly', async () => { const nodes: Node[] = [ { id: '1', @@ -360,7 +668,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 = await service.ruleSchema({ nodes, edges }); expect(result).toEqual({ inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], outputs: [ @@ -370,6 +678,139 @@ describe('RuleMappingService', () => { resultOutputs: [], }); }); + + it('should handle invalid request data', async () => { + const ruleContent = { nodes: 'invalid' } as unknown as RuleContent; + + try { + await service.ruleSchema(ruleContent); + fail('Expected ruleSchema to throw an error'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidRuleContent); + expect(error.message).toBe('Rule has no nodes'); + } + }); + it('should handle nodes with inputs but no edges', async () => { + const nodes: Node[] = [ + { + id: '1', + type: 'inputNode', + content: { + inputs: [{ id: '1', name: 'Input1', type: 'string', field: 'field1' }], + }, + }, + { + id: '1', + type: 'outputNode', + content: { + outputs: [{ id: '1', name: 'Output1', type: 'string', field: 'field2' }], + }, + }, + ]; + const edges: Edge[] = []; + + const result = await service.ruleSchema({ nodes, edges }); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + outputs: [{ id: '1', name: 'Output1', type: 'string', property: 'field2' }], + resultOutputs: [], + }); + }); + + it('should handle nodes with outputs but no edges', async () => { + const nodes: Node[] = [ + { + id: '1', + type: 'outputNode', + content: { + outputs: [{ id: '1', name: 'Output1', type: 'string', field: 'field2' }], + }, + }, + ]; + const edges: Edge[] = []; + + const result = await service.ruleSchema({ nodes, edges }); + expect(result).toEqual({ + inputs: [], + outputs: [{ id: '1', name: 'Output1', type: 'string', property: 'field2' }], + resultOutputs: [], + }); + }); + + it('should handle nodes with both inputs and outputs correctly', async () => { + const nodes: Node[] = [ + { + id: '1', + type: 'inputNode', + content: { + inputs: [ + { id: '1', name: 'Input1', type: 'string', field: 'field1' }, + { id: '2', name: 'Input2', type: 'number', field: 'field2' }, + ], + }, + }, + { + id: '2', + type: 'outputNode', + content: { + outputs: [ + { id: '1', name: 'Output1', type: 'string', field: 'field2' }, + { id: '2', name: 'Output2', type: 'number', field: 'field3' }, + ], + }, + }, + ]; + const edges: Edge[] = []; + + const result = await service.ruleSchema({ nodes, edges }); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'Input1', type: 'string', property: 'field1' }], + outputs: [ + { id: '1', name: 'Output1', type: 'string', property: 'field2' }, + { id: '2', name: 'Output2', type: 'number', property: 'field3' }, + ], + resultOutputs: [], + }); + }); + + it('should handle nodes with decisionNode', async () => { + const nodes: Node[] = [ + { + id: '1', + type: 'decisionNode', + content: { + key: 'someKey', + }, + }, + { + id: '2', + type: 'outputNode', + content: { + outputs: [ + { id: '1', name: 'Output1', type: 'string', field: 'field2' }, + { id: '2', name: 'Output2', type: 'number', field: 'field3' }, + ], + }, + }, + ]; + const edges: Edge[] = []; + + // Mock the ruleSchemaFile method to return a sample schema + jest.spyOn(service, 'ruleSchemaFile').mockResolvedValue({ + inputs: [{ id: '1', name: 'DecisionInput1', type: 'string', property: 'field1' }], + resultOutputs: [{ id: '2', name: 'DecisionOutput1', type: 'number', property: 'field2' }], + }); + + const result = await service.ruleSchema({ nodes, edges }); + expect(result).toEqual({ + inputs: [{ id: '1', name: 'DecisionInput1', type: 'string', property: 'field1' }], + outputs: [ + { id: '1', name: 'Output1', type: 'string', property: 'field2' }, + { id: '2', name: 'Output2', type: 'number', property: 'field3' }, + ], + resultOutputs: [], + }); + }); }); describe('evaluateRuleSchema', () => { diff --git a/src/api/ruleMapping/ruleMapping.service.ts b/src/api/ruleMapping/ruleMapping.service.ts index f957704..8b8c794 100644 --- a/src/api/ruleMapping/ruleMapping.service.ts +++ b/src/api/ruleMapping/ruleMapping.service.ts @@ -1,7 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { Node, Edge, TraceObject, Field } from './ruleMapping.interface'; +import { Node, Edge, TraceObject, Field, RuleContent } from './ruleMapping.interface'; import { DocumentsService } from '../documents/documents.service'; import { ConfigService } from '@nestjs/config'; +import { RuleSchema } from '../scenarioData/scenarioData.interface'; + +export class InvalidRuleContent extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidRuleContentError'; + } +} @Injectable() export class RuleMappingService { @@ -14,22 +22,37 @@ export class RuleMappingService { } // Extract all fields from a list of nodes - extractFields(nodes: Node[], fieldKey: 'inputs' | 'outputs'): { [key: string]: any[] } { - const fields: any[] = nodes.flatMap((node: any) => { + async extractFields(nodes: Node[], fieldKey: 'inputs' | 'outputs'): Promise<{ [key: string]: any[] }> { + const promises = nodes.map(async (node: any) => { + if (node.type === 'decisionNode' && node.content?.key) { + const generateNestedSchema = await this.ruleSchemaFile(node.content.key); + const { inputs, resultOutputs } = generateNestedSchema; + return fieldKey === 'inputs' ? inputs : resultOutputs; + } if (node.type === 'expressionNode' && node.content?.expressions) { - return node.content.expressions.map((expr: { key: any; value: any }) => ({ - key: fieldKey === 'inputs' ? expr.key : expr.value, - property: fieldKey === 'inputs' ? expr.value : expr.key, - })); + const simpleExpressionRegex = /^[a-zA-Z0-9]+$/; + return node.content.expressions.map((expr: { key: any; value: any }) => { + const isSimpleValue = simpleExpressionRegex.test(expr.value); + return { + key: isSimpleValue ? (fieldKey === 'inputs' ? expr.key : expr.value) : expr.key, + property: isSimpleValue ? (fieldKey === 'inputs' ? expr.value : expr.key) : expr.key, + exception: isSimpleValue ? null : expr.value, + }; + }); } 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, - }); + if (node.content.length > 10000) { + throw new Error('Input too large'); + } + return (node.content.split('\n') || []).reduce((acc: any[], line: string) => { + const keyword = fieldKey === 'inputs' ? '@param' : '@returns'; + if (line.includes(keyword)) { + const item = line.split(keyword)[1]?.trim(); + if (item) { + acc.push({ + key: item, + property: item, + }); + } } return acc; }, []); @@ -42,16 +65,18 @@ export class RuleMappingService { })); } }); - return { [fieldKey]: fields }; + + const results = await Promise.all(promises); + const fields = results.flat(); + + const uniqueFieldsMap = new Map(fields.map((field) => [field.property, field])); + + const uniqueFields = Array.from(uniqueFieldsMap.values()); + return { [fieldKey]: uniqueFields }; } // Get the final outputs of a rule from mapping the target output nodes and the edges - extractResultOutputs( - nodes: Node[], - edges: Edge[], - ): { - resultOutputs: any[]; - } { + async extractResultOutputs(nodes: Node[], edges: Edge[]): Promise<{ resultOutputs: any[] }> { // Find the output node const outputNode = nodes.find((obj) => obj.type === 'outputNode'); @@ -64,17 +89,15 @@ 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 resultOutputs: any[] = this.extractFields(targetOutputNodes, 'outputs').outputs; + const resultOutputs: any[] = (await this.extractFields(targetOutputNodes, 'outputs')).outputs; return { resultOutputs }; } - extractInputsAndOutputs(nodes: Node[]): { - inputs: any[]; - outputs: any[]; - } { - const inputs: any[] = this.extractFields(nodes, 'inputs').inputs; - const outputs: any[] = this.extractFields(nodes, 'outputs').outputs; + async extractInputsAndOutputs(nodes: Node[]): Promise<{ inputs: any[]; outputs: any[] }> { + const inputs: any[] = (await this.extractFields(nodes, 'inputs')).inputs; + const outputs: any[] = (await this.extractFields(nodes, 'outputs')).outputs; + return { inputs, outputs }; } @@ -92,9 +115,21 @@ export class RuleMappingService { // extract only the unique inputs from a list of nodes // excludes inputs found in the outputs of other nodes - extractUniqueInputs(nodes: Node[]) { - const { inputs, outputs } = this.extractInputsAndOutputs(nodes); - const outputFields = new Set(outputs.map((outputField) => outputField.property)); + // inputs that are only transformed are still included as unique as marked as exception + async extractUniqueInputs(nodes: Node[]): Promise<{ uniqueInputs: any[] }> { + const { inputs, outputs } = await this.extractInputsAndOutputs(nodes); + const outputFields = new Set( + outputs + // check for exceptions where input is transformed and exclude from output fields + .filter((outputField) => + outputField.exception + ? outputField.exception.includes(outputField.key) + ? outputField.exception === outputField.key + : true + : true, + ) + .map((outputField) => outputField.property), + ); const uniqueInputFields = this.findUniqueFields(inputs, outputFields); return { @@ -103,17 +138,17 @@ 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[]; - } { - const inputs: any[] = this.extractUniqueInputs(nodes).uniqueInputs; - const generalOutputs: any[] = this.extractFields(nodes, 'outputs').outputs; - const resultOutputs: any[] = this.extractResultOutputs(nodes, edges).resultOutputs; + async ruleSchema(ruleContent: RuleContent): Promise { + 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[] = (await this.extractUniqueInputs(nodes)).uniqueInputs; + const generalOutputs: any[] = (await this.extractFields(nodes, 'outputs')).outputs; + const resultOutputs: any[] = (await this.extractResultOutputs(nodes, edges)).resultOutputs; //get unique outputs excluding final outputs const outputs: any[] = generalOutputs.filter( @@ -128,6 +163,13 @@ export class RuleMappingService { return { inputs, outputs, resultOutputs }; } + // generate a rule schema from a given local file + async ruleSchemaFile(ruleFileName: string): Promise { + const fileContent = await this.documentsService.getFileContent(ruleFileName); + const ruleContent = await JSON.parse(fileContent.toString()); + return this.ruleSchema(ruleContent); + } + // generate a schema for the inputs and outputs of a rule given the trace data of a rule run evaluateRuleSchema(trace: TraceObject): { input: any; @@ -148,11 +190,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..bd685b4 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').mockResolvedValue(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').mockResolvedValue(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').mockResolvedValue(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,22 +455,25 @@ 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 }, outputs: { isEligible: true, baseAmount: 100 }, expectedResults: {}, + resultMatch: false, }, 'Scenario 2': { inputs: { familyComposition: 'couple', numberOfChildren: 3 }, outputs: { isEligible: false, baseAmount: 200 }, expectedResults: {}, + resultMatch: false, }, }; 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 +482,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 +498,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 +507,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 +518,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 +527,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 +541,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,12 +551,32 @@ 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`; expect(csvContent.trim()).toBe(expectedCsvContent.trim()); }); + + it('should escape inputs and outputs containing commas or quotes', async () => { + const goRulesJSONFilename = 'test.json'; + const ruleContent = { nodes: [], edges: [] }; + const ruleRunResults = { + 'Scenario 1': { + inputs: { input1: 'value, with, commas', input2: 'value "with" quotes' }, + outputs: { output1: 'result, with, commas', output2: 'result "with" quotes' }, + expectedResults: {}, + }, + }; + + jest.spyOn(service, 'runDecisionsForScenarios').mockResolvedValue(ruleRunResults); + + const csvContent = await service.getCSVForRuleRun(goRulesJSONFilename, ruleContent); + + const expectedCsvContent = `Scenario,Results Match Expected (Pass/Fail),Input: input1,Input: input2\nScenario 1,Fail,"value, with, commas",value "with" quotes`; + + expect(csvContent.trim()).toBe(expectedCsvContent.trim()); + }); }); describe('processProvidedScenarios', () => { diff --git a/src/api/scenarioData/scenarioData.service.ts b/src/api/scenarioData/scenarioData.service.ts index 5105949..af12f24 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 = await 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,32 +153,54 @@ 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); - - const inputKeys = extractUniqueKeys(ruleRunResults, 'inputs'); - const outputKeys = extractUniqueKeys(ruleRunResults, 'result'); - const expectedResultsKeys = extractUniqueKeys(ruleRunResults, 'expectedResults'); + async getCSVForRuleRun( + goRulesJSONFilename: string, + ruleContent: RuleContent, + newScenarios?: ScenarioData[], + ): Promise { + const ruleRunResults: RuleRunResults = await this.runDecisionsForScenarios( + goRulesJSONFilename, + ruleContent, + newScenarios, + ); + + const keys = { + inputs: extractUniqueKeys(ruleRunResults, 'inputs'), + expectedResults: extractUniqueKeys(ruleRunResults, 'expectedResults'), + result: extractUniqueKeys(ruleRunResults, 'result'), + }; const headers = [ 'Scenario', 'Results Match Expected (Pass/Fail)', - ...inputKeys.map((key) => `Input: ${key}`), - ...expectedResultsKeys.map((key) => `Expected Result: ${key}`), - ...outputKeys.map((key) => `Result: ${key}`), + ...this.prefixKeys(keys.inputs, 'Input'), + ...this.prefixKeys(keys.expectedResults, 'Expected Result'), + ...this.prefixKeys(keys.result, 'Result'), ]; - 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] ?? ''); + const rows = Object.entries(ruleRunResults).map(([scenarioName, data]) => [ + this.escapeCSVField(scenarioName), + data.resultMatch ? 'Pass' : 'Fail', + ...this.mapFields(data.inputs, keys.inputs), + ...this.mapFields(data.expectedResults, keys.expectedResults), + ...this.mapFields(data.result, keys.result), + ]); - return [scenarioName, resultsMatch, ...inputs, ...expectedResults, ...outputs]; - }); + return [headers, ...rows].map((row) => row.join(',')).join('\n'); + } + + private prefixKeys(keys: string[], prefix: string): string[] { + return keys.map((key) => `${prefix}: ${key}`); + } + + private mapFields(data: Record, keys: string[]): string[] { + return keys.map((key) => this.escapeCSVField(data[key])); + } - const csvContent = [headers, ...rows].map((row) => row.join(',')).join('\n'); - return csvContent; + private escapeCSVField(field: any): string { + if (field == null) return ''; + const stringField = typeof field === 'string' ? field : String(field); + return stringField.includes(',') ? `"${stringField.replace(/"/g, '""')}"` : stringField; } /** diff --git a/src/api/submissions/submission.interface.ts b/src/api/submissions/submission.interface.ts deleted file mode 100644 index 5a553ab..0000000 --- a/src/api/submissions/submission.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface Submission { - submissionId: string; - createdBy: string; - createdAt: string; - submission: { - data: SubmissionData; - }; -} - -export type SubmissionData = Record; diff --git a/src/api/submissions/submissions.controller.spec.ts b/src/api/submissions/submissions.controller.spec.ts deleted file mode 100644 index 84ccc76..0000000 --- a/src/api/submissions/submissions.controller.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SubmissionsController } from './submissions.controller'; -import { SubmissionsService } from './submissions.service'; -import { Submission } from './submission.interface'; -import { SubmissionServiceMockProviders } from './submissions.service.spec'; - -describe('SubmissionsController', () => { - let controller: SubmissionsController; - let service: SubmissionsService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [SubmissionsController], - providers: SubmissionServiceMockProviders, - }).compile(); - - controller = module.get(SubmissionsController); - service = module.get(SubmissionsService); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - it('should get submissions', async () => { - const result: Submission[] = []; - jest.spyOn(service, 'getSubmissions').mockImplementation(() => Promise.resolve(result)); - expect(await controller.getSubmissions('formId')).toBe(result); - }); - - it('should get submission by id', async () => { - const result: Submission = { - submissionId: '123456', - createdBy: 'TestCreator', - createdAt: new Date().toDateString(), - submission: { - data: { - name: 'TestName', - age: 25, - }, - }, - }; - jest.spyOn(service, 'getSubmissionById').mockImplementation(() => Promise.resolve(result)); - expect(await controller.getSubmissionById('formId', 'id')).toBe(result); - }); -}); diff --git a/src/api/submissions/submissions.controller.ts b/src/api/submissions/submissions.controller.ts deleted file mode 100644 index 53cb437..0000000 --- a/src/api/submissions/submissions.controller.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Controller, Get, Param, HttpException, HttpStatus } from '@nestjs/common'; -import { SubmissionsService } from './submissions.service'; -import { Submission } from './submission.interface'; - -@Controller('api/submissions') -export class SubmissionsController { - constructor(private readonly submissionsService: SubmissionsService) {} - - @Get('/list/:formId') - async getSubmissions(@Param('formId') formId: string): Promise { - try { - return await this.submissionsService.getSubmissions(formId); - } catch (error) { - throw new HttpException('Error getting submissions', HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @Get('/:formId/:id') - async getSubmissionById(@Param('formId') formId: string, @Param('id') id: string): Promise { - try { - return await this.submissionsService.getSubmissionById(formId, id); - } catch (error) { - throw new HttpException('Error getting submission by id', HttpStatus.INTERNAL_SERVER_ERROR); - } - } -} diff --git a/src/api/submissions/submissions.service.spec.ts b/src/api/submissions/submissions.service.spec.ts deleted file mode 100644 index 8ee8dfa..0000000 --- a/src/api/submissions/submissions.service.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { SubmissionsService } from './submissions.service'; -import { RuleDataService } from '../ruleData/ruleData.service'; - -export const SubmissionServiceMockProviders = [ - SubmissionsService, - { - provide: ConfigService, - useValue: { - get: jest.fn().mockReturnValue('mock value'), - }, - }, - { - provide: RuleDataService, - useValue: {}, - }, -]; - -describe('SubmissionsService', () => { - let service: SubmissionsService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: SubmissionServiceMockProviders, - }).compile(); - - service = module.get(SubmissionsService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - // TODO: Add more tests here for each method in your service -}); diff --git a/src/api/submissions/submissions.service.ts b/src/api/submissions/submissions.service.ts deleted file mode 100644 index 0b7b03e..0000000 --- a/src/api/submissions/submissions.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import axios, { AxiosInstance } from 'axios'; -import { Submission } from './submission.interface'; // assuming you have this interface -import { RuleDataService } from '../ruleData/ruleData.service'; - -@Injectable() -export class SubmissionsService { - chefsAPIURL: string; - - constructor( - private configService: ConfigService, - private readonly ruleDataService: RuleDataService, - ) { - this.chefsAPIURL = this.configService.get('CHEFS_API_URL'); - - if (!this.chefsAPIURL) { - throw new InternalServerErrorException('Environment variables CHEFS_API_URL is not not set'); - } - } - - async getAxiosCHEFSInstance(formId: string): Promise { - // Get the form API key for the form - const chefsFormAPIKey = await this.ruleDataService.getFormAPIKeyForFormId(formId); - if (!chefsFormAPIKey) { - throw new InternalServerErrorException('chefsFormAPIKey is not set for this CHEFS form'); - } - // Need to convert formid and chefsFormAPIKey to base64 in order to access submissions - const chefsAuth = Buffer.from(`${formId}:${chefsFormAPIKey}`).toString('base64'); - return axios.create({ - headers: { - Authorization: `Basic ${chefsAuth}`, - }, - }); - } - - async getSubmissions(formId: string): Promise { - try { - const axiosCHEFSInstance = await this.getAxiosCHEFSInstance(formId); - const { data } = await axiosCHEFSInstance.get(`${this.chefsAPIURL}/forms/${formId}/submissions`); - return data; - } catch (error) { - throw new Error(`Error getting submissions: ${error.message}`); - } - } - - async getSubmissionById(formId: string, id: string): Promise { - try { - const axiosCHEFSInstance = await this.getAxiosCHEFSInstance(formId); - const { data } = await axiosCHEFSInstance.get(`${this.chefsAPIURL}/submissions/${id}`); - return data; - } catch (error) { - throw new Error(`Error getting submission by id: ${error.message}`); - } - } -} diff --git a/src/app.module.ts b/src/app.module.ts index c72454e..0e0f0e7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,8 +8,6 @@ import { DecisionsController } from './api/decisions/decisions.controller'; import { DecisionsService } from './api/decisions/decisions.service'; import { DocumentsController } from './api/documents/documents.controller'; import { DocumentsService } from './api/documents/documents.service'; -import { 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'; @@ -36,17 +34,9 @@ import { ScenarioDataService } from './api/scenarioData/scenarioData.service'; RuleDataController, DecisionsController, DocumentsController, - SubmissionsController, RuleMappingController, ScenarioDataController, ], - providers: [ - RuleDataService, - DecisionsService, - DocumentsService, - SubmissionsService, - RuleMappingService, - ScenarioDataService, - ], + providers: [RuleDataService, DecisionsService, DocumentsService, RuleMappingService, ScenarioDataService], }) export class AppModule {} diff --git a/src/utils/csv.ts b/src/utils/csv.ts index 85ced17..c5c8f8b 100644 --- a/src/utils/csv.ts +++ b/src/utils/csv.ts @@ -40,17 +40,47 @@ export const extractKeys = (headers: string[], prefix: string): string[] => { * @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; + const result: { [key: string]: any } = {}; + + keys.forEach((key, index) => { + const value = row[startIndex + index] ? formatValue(row[startIndex + index]) : null; + if (filterEmpty && (value === null || value === undefined || value === '')) { + return; + } + + const parts = key.match(/^([^\[]+)(?:\[(\d+)\])?(.*)$/); + if (parts) { + const [, baseKey, arrayIndex, remainingKey] = parts; + + const pluralizedKey = `${baseKey}${Number(arrayIndex) > 0 ? 's' : ''}`; + + if (!result[pluralizedKey]) { + result[pluralizedKey] = arrayIndex ? [] : {}; } - return { - name: key, - value: value, - type: typeof value, - }; - }) - .filter((entry) => entry !== undefined); + + if (arrayIndex) { + const idx = parseInt(arrayIndex, 10) - 1; + if (!result[pluralizedKey][idx]) { + result[pluralizedKey][idx] = {}; + } + if (remainingKey) { + result[pluralizedKey][idx][remainingKey] = value; + } else { + result[pluralizedKey][idx] = value; + } + } else if (remainingKey) { + result[pluralizedKey][remainingKey] = value; + } else { + result[pluralizedKey] = value; + } + } else { + result[key] = value; + } + }); + + return Object.entries(result).map(([name, value]) => ({ + name, + value: Array.isArray(value) ? value.filter((v) => v !== undefined) : value, + type: Array.isArray(value) ? 'array' : typeof value, + })); }; diff --git a/src/utils/handleTrace.ts b/src/utils/handleTrace.ts index 3c1e53f..d60365a 100644 --- a/src/utils/handleTrace.ts +++ b/src/utils/handleTrace.ts @@ -23,8 +23,28 @@ export const getPropertyById = (id: string, ruleSchema: RuleSchema, type: 'input 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)) { + if (trace[key] && typeof trace[key] === 'object') { + const newArray: any[] = []; + const arrayName = key; + for (const item in trace[key]) { + if (trace[key].hasOwnProperty(item)) { + newArray.push(trace[key][item]); + } + } + newArray.forEach((item, index) => { + index++; + const keyName = `${arrayName.toString().slice(0, -1)}[${index}]`; + if (Object.keys(item).length === 0) { + result[`${keyName}${key}`] = item; + } else { + Object.keys(item).forEach((key) => { + result[`${keyName}${key}`] = item[key]; + }); + } + }); + } + const propertyUnformatted = getPropertyById(key, ruleSchema, type); const property = propertyUnformatted ? replaceSpecialCharacters(propertyUnformatted, '') : null; if (property) { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 7f5847c..21cbc54 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -94,6 +94,9 @@ export const formatValue = (value: string): boolean | number | string | null => return true; } else if (value.toLowerCase() === 'false') { return false; + // Check if the value matches the format yyyy-mm-dd + } else if (/\d{4}-\d{2}-\d{2}/.test(value)) { + return value; } const numberValue = parseFloat(value); if (!isNaN(numberValue)) {