From 9853d2a895bfcb77c17c480a0bad622110200fcb Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Tue, 29 Oct 2024 16:31:17 -0700 Subject: [PATCH 1/3] Add additional testing for validations, decisions, documents, klamm, and ruleData services. --- .../decisions/decisions.controller.spec.ts | 17 ++- src/api/decisions/decisions.service.spec.ts | 55 ++++++- .../validations/validations.service.spec.ts | 128 ++++++++++++++++ .../validations/validations.service.ts | 12 +- .../documents/documents.controller.spec.ts | 86 ++++++++--- src/api/documents/documents.service.spec.ts | 139 +++++++++++++++++- src/api/klamm/klamm.service.spec.ts | 47 ++++++ src/api/ruleData/ruleData.service.spec.ts | 137 +++++++++++++++++ 8 files changed, 592 insertions(+), 29 deletions(-) diff --git a/src/api/decisions/decisions.controller.spec.ts b/src/api/decisions/decisions.controller.spec.ts index 7709223..a62298b 100644 --- a/src/api/decisions/decisions.controller.spec.ts +++ b/src/api/decisions/decisions.controller.spec.ts @@ -1,8 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { HttpException } from '@nestjs/common'; +import { HttpException, HttpStatus } from '@nestjs/common'; import { DecisionsController } from './decisions.controller'; import { DecisionsService } from './decisions.service'; import { EvaluateDecisionDto, EvaluateDecisionWithContentDto } from './dto/evaluate-decision.dto'; +import { ValidationError } from './validations/validation.error'; describe('DecisionsController', () => { let controller: DecisionsController; @@ -46,6 +47,20 @@ describe('DecisionsController', () => { await expect(controller.evaluateDecisionByContent(dto)).rejects.toThrow(HttpException); }); + it('should throw a ValidationError when runDecision fails', async () => { + const dto: EvaluateDecisionWithContentDto = { + ruleContent: { nodes: [], edges: [] }, + context: { value: 'context' }, + trace: false, + }; + (service.runDecisionByContent as jest.Mock).mockRejectedValue(new ValidationError('Error')); + + await expect(controller.evaluateDecisionByContent(dto)).rejects.toThrow(HttpException); + await expect(controller.evaluateDecisionByContent(dto)).rejects.toThrow( + new HttpException('Error', HttpStatus.BAD_REQUEST), + ); + }); + it('should call runDecisionByFile with correct parameters', async () => { const dto: EvaluateDecisionDto = { context: { value: 'context' }, trace: false }; const ruleFileName = 'rule'; diff --git a/src/api/decisions/decisions.service.spec.ts b/src/api/decisions/decisions.service.spec.ts index 6b76022..b7f4dfd 100644 --- a/src/api/decisions/decisions.service.spec.ts +++ b/src/api/decisions/decisions.service.spec.ts @@ -3,7 +3,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ZenEngine, ZenDecision, ZenEvaluateOptions } from '@gorules/zen-engine'; import { DecisionsService } from './decisions.service'; import { ValidationService } from './validations/validations.service'; -import { readFileSafely } from '../../utils/readFile'; +import { readFileSafely, FileNotFoundError } from '../../utils/readFile'; +import { RuleContent } from '../ruleMapping/ruleMapping.interface'; +import { ValidationError } from './validations/validation.error'; +import { HttpException, HttpStatus } from '@nestjs/common'; jest.mock('../../utils/readFile', () => ({ readFileSafely: jest.fn(), @@ -55,6 +58,22 @@ describe('DecisionsService', () => { 'Failed to run decision: Error', ); }); + it('should fall back to runDecisionByFile when ruleContent is not provided', async () => { + const ruleFileName = 'fallback-rule'; + const context = {}; + const options: ZenEvaluateOptions = { trace: false }; + const content = { rule: 'rule' }; + + (readFileSafely as jest.Mock).mockResolvedValue(Buffer.from(JSON.stringify(content))); + + // Call runDecision with null/undefined ruleContent + await service.runDecision(null, ruleFileName, context, options); + + // Verify that readFileSafely was called, indicating fallback to runDecisionByFile + expect(readFileSafely).toHaveBeenCalledWith(service.rulesDirectory, ruleFileName); + expect(mockEngine.createDecision).toHaveBeenCalledWith(content); + expect(mockDecision.evaluate).toHaveBeenCalledWith(context, options); + }); }); describe('runDecisionByContent', () => { @@ -76,6 +95,29 @@ describe('DecisionsService', () => { 'Failed to run decision: Error', ); }); + it('should throw ValidationError when validation fails', async () => { + const ruleContent: RuleContent = { + nodes: [ + { + id: 'node1', + type: 'inputNode', + content: { + fields: [{ id: 'field1', name: 'someField', type: 'string' }], + }, + }, + ], + edges: [], + }; + + const context = {}; + const options: ZenEvaluateOptions = { trace: false }; + + jest.spyOn(validationService, 'validateInputs').mockImplementation(() => { + throw new ValidationError('Required field someField is missing'); + }); + + await expect(service.runDecisionByContent(ruleContent, context, options)).rejects.toThrow(ValidationError); + }); }); describe('runDecisionByFile', () => { @@ -101,4 +143,15 @@ describe('DecisionsService', () => { ); }); }); + it('should throw HttpException when file is not found', async () => { + const ruleFileName = 'nonexistent-rule'; + const context = {}; + const options: ZenEvaluateOptions = { trace: false }; + + (readFileSafely as jest.Mock).mockRejectedValue(new FileNotFoundError('File not found')); + + await expect(service.runDecisionByFile(ruleFileName, context, options)).rejects.toThrow( + new HttpException('Rule not found', HttpStatus.NOT_FOUND), + ); + }); }); diff --git a/src/api/decisions/validations/validations.service.spec.ts b/src/api/decisions/validations/validations.service.spec.ts index 71a8a72..22e1642 100644 --- a/src/api/decisions/validations/validations.service.spec.ts +++ b/src/api/decisions/validations/validations.service.spec.ts @@ -1,6 +1,33 @@ import { ValidationService } from './validations.service'; import { ValidationError } from './validation.error'; +describe('ValidationError', () => { + it('should be an instance of ValidationError', () => { + const error = new ValidationError('Test message'); + expect(error).toBeInstanceOf(ValidationError); + expect(error.name).toBe('ValidationError'); + }); + + it('should have correct prototype', () => { + const error = new ValidationError('Test message'); + expect(Object.getPrototypeOf(error)).toBe(ValidationError.prototype); + }); + + it('should return correct error code', () => { + const error = new ValidationError('Test message'); + expect(error.getErrorCode()).toBe('VALIDATION_ERROR'); + }); + + it('should return correct JSON representation', () => { + const error = new ValidationError('Test message'); + expect(error.toJSON()).toEqual({ + name: 'ValidationError', + message: 'Test message', + code: 'VALIDATION_ERROR', + }); + }); +}); + describe('ValidationService', () => { let validator: ValidationService; @@ -19,14 +46,79 @@ describe('ValidationService', () => { const context = { age: 25, name: 'John Doe' }; expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); }); + it('should return early if ruleContent is not an object', () => { + const ruleContent = 'not an object'; + const context = {}; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + + it('should return early if ruleContent.fields is not an array', () => { + const ruleContent = { fields: 'not an array' }; + const context = {}; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + it('should return early if ruleContent.fields is empty', () => { + const ruleContent = { fields: [] }; + const context = {}; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + it('should return early if context is empty', () => { + const ruleContent = { fields: [] }; + const context = {}; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + it('returns early ruleContent.fields is not an array of objects', () => { + const ruleContent = { fields: [{ field: 'age', type: 'number-input' }] }; + const context = { age: 25 }; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + it('returns blank if ruleContent.fields is an empty array', () => { + const ruleContent = { fields: [] }; + const context = { age: 25 }; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + it('returns early if field.field is not in context', () => { + const ruleContent = { fields: [{ field: 'age', type: 'number-input' }] }; + const context = {}; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + }); + it('returns early if input is null or undefined', () => { + const ruleContent = { fields: [{ field: 'age', type: 'number-input' }] }; + const context = { age: null }; + expect(() => validator.validateInputs(ruleContent, context)).not.toThrow(); + const context2 = { age: undefined }; + expect(() => validator.validateInputs(ruleContent, context2)).not.toThrow(); + }); }); describe('validateField', () => { + it('throws an error for missing field.field', () => { + const invalidField = { dataType: 'number-input' }; + expect(() => validator['validateField'](invalidField, {})).toThrow(ValidationError); + }); + + it('throws an error for unsupported data type in validateType', () => { + const field = { field: 'testField', dataType: 'unsupported-type' }; + const context = { testField: 'value' }; + expect(() => validator['validateField'](field, context)).toThrow(ValidationError); + }); + + it('throws an error for mismatched input type in validateType', () => { + const field = { field: 'testField', dataType: 'number-input' }; + const context = { testField: 'string' }; + expect(() => validator['validateField'](field, context)).toThrow(ValidationError); + }); + it('should validate number input', () => { const field = { field: 'age', type: 'number-input' }; const context = { age: 25 }; expect(() => validator['validateField'](field, context)).not.toThrow(); }); + it('should validate number input with validationType of [=nums]', () => { + const field = { field: 'age', type: 'number-input', validationType: '[=nums]', validationCriteria: '[25, 26]' }; + const context = { age: [25, 26] }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); it('should validate date input', () => { const field = { field: 'birthDate', type: 'date' }; @@ -34,12 +126,34 @@ describe('ValidationService', () => { expect(() => validator['validateField'](field, context)).not.toThrow(); }); + it('should validate date input with validationType of [=dates]', () => { + const field = { + field: 'birthDate', + type: 'date', + validationType: '[=dates]', + validationCriteria: '[2000-01-01, 2000-01-02]', + }; + const context = { birthDate: ['2000-01-01', '2000-01-02'] }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + it('should validate text input', () => { const field = { field: 'name', type: 'text-input' }; const context = { name: 'John Doe' }; expect(() => validator['validateField'](field, context)).not.toThrow(); }); + it('should validate text input with validationType of [=texts]', () => { + const field = { + field: 'name', + type: 'text-input', + validationType: '[=texts]', + validationCriteria: 'John Doe, Jane Doe', + }; + const context = { name: ['John Doe', 'Jane Doe'] }; + expect(() => validator['validateField'](field, context)).not.toThrow(); + }); + it('should validate boolean input', () => { const field = { field: 'isActive', type: 'true-false' }; const context = { isActive: true }; @@ -281,4 +395,18 @@ describe('ValidationService', () => { ); }); }); + + describe('ValidationService - Edge Cases in validateOutput', () => { + it('throws an error when output does not match outputSchema', () => { + const outputSchema = { field: 'output', dataType: 'number-input', validationType: '==', validationCriteria: '5' }; + const invalidOutput = { output: 10 }; + expect(() => validator.validateOutput(outputSchema, invalidOutput)).toThrow(ValidationError); + }); + + it('passes when output matches outputSchema', () => { + const outputSchema = { field: 'output', dataType: 'number-input', validationType: '==', validationCriteria: '5' }; + const validOutput = 5; + expect(() => validator.validateOutput(outputSchema, validOutput)).not.toThrow(); + }); + }); }); diff --git a/src/api/decisions/validations/validations.service.ts b/src/api/decisions/validations/validations.service.ts index 4e4fb41..6173324 100644 --- a/src/api/decisions/validations/validations.service.ts +++ b/src/api/decisions/validations/validations.service.ts @@ -61,10 +61,14 @@ export class ValidationService { } break; case 'date': - if ((typeof input !== 'string' && validationType !== '[=dates]') || isNaN(Date.parse(input))) { + if (validationType === '[=dates]') { + if (!Array.isArray(input) || !input.every((date) => !isNaN(Date.parse(date)))) { + throw new ValidationError( + `Input ${field.field} should be an array of valid date strings, but got ${JSON.stringify(input)}`, + ); + } + } else if (typeof input !== 'string' || isNaN(Date.parse(input))) { throw new ValidationError(`Input ${field.field} should be a valid date string, but got ${input}`); - } else if (validationType === '[=dates]' && !Array.isArray(input)) { - throw new ValidationError(`Input ${field.field} should be an array of date strings, but got ${input}`); } break; case 'text-input': @@ -79,6 +83,8 @@ export class ValidationService { throw new ValidationError(`Input ${field.field} should be a boolean, but got ${actualType}`); } break; + default: + throw new ValidationError(`Unsupported data type: ${dataType}`); } } diff --git a/src/api/documents/documents.controller.spec.ts b/src/api/documents/documents.controller.spec.ts index d394b5a..848640e 100644 --- a/src/api/documents/documents.controller.spec.ts +++ b/src/api/documents/documents.controller.spec.ts @@ -16,6 +16,7 @@ describe('DocumentsController', () => { provide: DocumentsService, useValue: { getFileContent: jest.fn(), + getAllJSONFiles: jest.fn(), }, }, ], @@ -24,33 +25,76 @@ describe('DocumentsController', () => { documentsController = module.get(DocumentsController); documentsService = module.get(DocumentsService); }); + describe('getAllDocuments', () => { + it('should return all JSON files', async () => { + const mockFiles = ['file1.json', 'file2.json']; + jest.spyOn(documentsService, 'getAllJSONFiles').mockResolvedValue(mockFiles); - it('should return file content', async () => { - const ruleFileName = 'test.json'; - const mockFileContent = Buffer.from(JSON.stringify({ key: 'value' })); - const res = { - setHeader: jest.fn(), - send: jest.fn(), - } as unknown as Response; + const result = await documentsController.getAllDocuments(); - jest.spyOn(documentsService, 'getFileContent').mockResolvedValueOnce(mockFileContent); + expect(result).toEqual(mockFiles); + expect(documentsService.getAllJSONFiles).toHaveBeenCalled(); + }); - await documentsController.getRuleFile(ruleFileName, res); - expect(res.send).toHaveBeenCalledWith(mockFileContent); + it('should throw an error when getAllJSONFiles fails', async () => { + const mockError = new Error('Failed to get files'); + jest.spyOn(documentsService, 'getAllJSONFiles').mockRejectedValue(mockError); + + await expect(documentsController.getAllDocuments()).rejects.toThrow(mockError); + }); }); - it('should throw an error when file not found', async () => { - const ruleFileName = 'test.json'; - const mockError = new Error('File not found'); - const res = { - setHeader: jest.fn(), - send: jest.fn(), - } as unknown as Response; + describe('getRuleFile', () => { + it('should return file content', async () => { + const ruleFileName = 'test.json'; + const mockFileContent = Buffer.from(JSON.stringify({ key: 'value' })); + const res = { + setHeader: jest.fn(), + send: jest.fn(), + } as unknown as Response; + + jest.spyOn(documentsService, 'getFileContent').mockResolvedValueOnce(mockFileContent); + + await documentsController.getRuleFile(ruleFileName, res); + + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', `attachment; filename=${ruleFileName}`); + expect(res.send).toHaveBeenCalledWith(mockFileContent); + }); + + it('should throw an error when file not found', async () => { + const ruleFileName = 'test.json'; + const mockError = new Error('File not found'); + const res = { + setHeader: jest.fn(), + send: jest.fn(), + } as unknown as Response; + + jest.spyOn(documentsService, 'getFileContent').mockRejectedValueOnce(mockError); + + await expect(documentsController.getRuleFile(ruleFileName, res)).rejects.toThrow( + new HttpException(mockError.message, HttpStatus.INTERNAL_SERVER_ERROR), + ); + }); + + it('should throw HttpException when response handling fails', async () => { + const ruleFileName = 'test.json'; + const mockFileContent = Buffer.from(JSON.stringify({ key: 'value' })); + const res = { + setHeader: jest.fn(), + send: jest.fn().mockImplementation(() => { + throw new Error('Failed to send response'); + }), + } as unknown as Response; + + jest.spyOn(documentsService, 'getFileContent').mockResolvedValueOnce(mockFileContent); - jest.spyOn(documentsService, 'getFileContent').mockRejectedValueOnce(mockError); + await expect(documentsController.getRuleFile(ruleFileName, res)).rejects.toThrow( + new HttpException('Failed to send response', HttpStatus.INTERNAL_SERVER_ERROR), + ); - await expect(documentsController.getRuleFile(ruleFileName, res)).rejects.toThrow( - new HttpException(mockError.message, HttpStatus.INTERNAL_SERVER_ERROR), - ); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', `attachment; filename=${ruleFileName}`); + }); }); }); diff --git a/src/api/documents/documents.service.spec.ts b/src/api/documents/documents.service.spec.ts index 6341b42..9a91434 100644 --- a/src/api/documents/documents.service.spec.ts +++ b/src/api/documents/documents.service.spec.ts @@ -2,23 +2,41 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { DocumentsService } from './documents.service'; -import { readFileSafely } from '../../utils/readFile'; +import { readFileSafely, FileNotFoundError } from '../../utils/readFile'; +import * as fsPromises from 'fs/promises'; +import { Dirent } from 'fs'; +import * as path from 'path'; jest.mock('../../utils/readFile', () => ({ readFileSafely: jest.fn(), - FileNotFoundError: jest.fn(), + FileNotFoundError: class FileNotFoundError extends Error {}, })); +jest.mock('fs/promises'); +const RULES_DIRECTORY = '../../../brms-rules/rules'; + describe('DocumentsService', () => { let service: DocumentsService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ConfigService, DocumentsService], + providers: [ + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue(RULES_DIRECTORY), + }, + }, + DocumentsService, + ], }).compile(); service = module.get(DocumentsService); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should throw a 404 error if the file does not exist', async () => { (readFileSafely as jest.Mock).mockRejectedValue(new Error('File not found')); @@ -45,4 +63,119 @@ describe('DocumentsService', () => { new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR), ); }); + + describe('getAllJSONFiles', () => { + it('should return a list of JSON files from directory and subdirectories', async () => { + const mockDirentFactory = (name: string, isDir: boolean): Dirent => + ({ + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + }) as Dirent; + + const mockFiles = [ + mockDirentFactory('file1.json', false), + mockDirentFactory('subdir', true), + mockDirentFactory('file2.txt', false), + mockDirentFactory('file3.JSON', false), + ]; + + const mockSubdirFiles = [mockDirentFactory('file4.json', false), mockDirentFactory('file5.txt', false)]; + + (fsPromises.readdir as jest.Mock).mockImplementation((dirPath) => { + if (dirPath.endsWith('subdir')) { + return Promise.resolve(mockSubdirFiles); + } + return Promise.resolve(mockFiles); + }); + + const result = await service.getAllJSONFiles(); + + expect(result).toEqual(['file1.json', 'subdir/file4.json', 'file3.JSON']); + + expect(fsPromises.readdir).toHaveBeenCalledTimes(2); + expect(fsPromises.readdir).toHaveBeenCalledWith(RULES_DIRECTORY, { withFileTypes: true }); + expect(fsPromises.readdir).toHaveBeenCalledWith(path.join(RULES_DIRECTORY, 'subdir'), { withFileTypes: true }); + }); + + it('should throw HttpException when directory reading fails', async () => { + (fsPromises.readdir as jest.Mock).mockRejectedValue(new Error('Failed to read directory')); + + await expect(service.getAllJSONFiles()).rejects.toThrow( + new HttpException('Error reading directory', HttpStatus.INTERNAL_SERVER_ERROR), + ); + }); + + it('should handle empty directories', async () => { + (fsPromises.readdir as jest.Mock).mockResolvedValue([]); + + const result = await service.getAllJSONFiles(); + + expect(result).toEqual([]); + expect(fsPromises.readdir).toHaveBeenCalledTimes(1); + expect(fsPromises.readdir).toHaveBeenCalledWith(RULES_DIRECTORY, { withFileTypes: true }); + }); + + it('should ignore non-JSON files', async () => { + const mockDirentFactory = (name: string): Dirent => + ({ + name, + isDirectory: () => false, + isFile: () => true, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + }) as Dirent; + + const mockFiles = [ + mockDirentFactory('file1.txt'), + mockDirentFactory('file2.png'), + mockDirentFactory('file3.doc'), + ]; + + (fsPromises.readdir as jest.Mock).mockResolvedValue(mockFiles); + + const result = await service.getAllJSONFiles(); + + expect(result).toEqual([]); + expect(fsPromises.readdir).toHaveBeenCalledTimes(1); + expect(fsPromises.readdir).toHaveBeenCalledWith(RULES_DIRECTORY, { withFileTypes: true }); + }); + }); + + describe('getFileContent', () => { + it('should throw a 404 error if the file does not exist', async () => { + (readFileSafely as jest.Mock).mockRejectedValue(new FileNotFoundError('File not found')); + + await expect(service.getFileContent('path/to/nonexistent/file')).rejects.toThrow( + new HttpException('File not found', HttpStatus.NOT_FOUND), + ); + }); + + it('should return file content if the file exists', async () => { + const mockContent = Buffer.from('file content'); + (readFileSafely as jest.Mock).mockResolvedValue(mockContent); + + const result = await service.getFileContent('path/to/existing/file'); + + expect(result).toBe(mockContent); + expect(readFileSafely).toHaveBeenCalledWith(RULES_DIRECTORY, 'path/to/existing/file'); + }); + + it('should throw a 500 error if reading the file fails', async () => { + const errorMessage = 'Read error'; + (readFileSafely as jest.Mock).mockRejectedValue(new Error(errorMessage)); + + await expect(service.getFileContent('path/to/existing/file')).rejects.toThrow( + new HttpException(errorMessage, HttpStatus.INTERNAL_SERVER_ERROR), + ); + }); + }); }); diff --git a/src/api/klamm/klamm.service.spec.ts b/src/api/klamm/klamm.service.spec.ts index 526754a..51eab4d 100644 --- a/src/api/klamm/klamm.service.spec.ts +++ b/src/api/klamm/klamm.service.spec.ts @@ -364,4 +364,51 @@ describe('KlammService', () => { await expect(service.getKlammBREFieldFromName(fieldName)).rejects.toThrow('Field name does not exist'); }); + it('should initialize axiosGithubInstance with auth header when GITHUB_TOKEN is set', () => { + process.env.GITHUB_TOKEN = 'test-token'; + const newService = new KlammService(ruleDataService, ruleMappingService, documentsService, klammSyncMetadata); + expect(newService.axiosGithubInstance.defaults.headers).toHaveProperty('Authorization', 'Bearer test-token'); + delete process.env.GITHUB_TOKEN; + }); + it('should handle case when no rule is found for changed file', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(ruleDataService, 'getRuleDataByFilepath').mockResolvedValue(null); + + await service['_syncRules'](['nonexistent-file.js']); + + expect(consoleSpy).toHaveBeenCalledWith('No rule found for changed file: nonexistent-file.js'); + consoleSpy.mockRestore(); + }); + + describe('_getAllKlammFields', () => { + it('should handle error when fetching fields from Klamm', async () => { + jest.spyOn(service.axiosKlammInstance, 'get').mockRejectedValue(new Error('Network error')); + + await expect(service['_getAllKlammFields']()).rejects.toThrow('Error fetching fields from Klamm: Network error'); + }); + + it('should successfully fetch all Klamm fields', async () => { + const mockFields = [ + { id: 1, name: 'field1' }, + { id: 2, name: 'field2' }, + ]; + jest.spyOn(service.axiosKlammInstance, 'get').mockResolvedValue({ + data: { data: mockFields }, + }); + + const result = await service['_getAllKlammFields'](); + + expect(result).toEqual(mockFields); + expect(service.axiosKlammInstance.get).toHaveBeenCalledWith(`${process.env.KLAMM_API_URL}/api/brerules`); + }); + }); + + it('should return 0 when no sync timestamp record exists', async () => { + jest.spyOn(klammSyncMetadata, 'findOne').mockResolvedValue(null); + + const result = await service['_getLastSyncTimestamp'](); + + expect(result).toBe(0); + expect(klammSyncMetadata.findOne).toHaveBeenCalledWith({ key: 'singleton' }); + }); }); diff --git a/src/api/ruleData/ruleData.service.spec.ts b/src/api/ruleData/ruleData.service.spec.ts index b58f84d..ae42c4c 100644 --- a/src/api/ruleData/ruleData.service.spec.ts +++ b/src/api/ruleData/ruleData.service.spec.ts @@ -247,4 +247,141 @@ describe('RuleDataService', () => { expect(service['categories']).toEqual([]); }); }); + + describe('updateCategories', () => { + it('should correctly update categories from filepaths', () => { + const testRules = [ + { _id: 'testId1', name: 'title1', title: 'Title 1', filepath: 'category1/subcategory/file1.json' }, + { _id: 'testId2', name: 'title2', title: 'Title 2', filepath: 'category1/file2.json' }, + { _id: 'testId3', name: 'title3', title: 'Title 3', filepath: 'category2/file3.json' }, + ]; + + service['updateCategories'](testRules); + + expect(service['categories']).toEqual([ + { text: 'category1', value: 'category1' }, + { text: 'category2', value: 'category2' }, + { text: 'subcategory', value: 'subcategory' }, + ]); + }); + + it('should handle empty rule data', () => { + service['updateCategories']([]); + expect(service['categories']).toEqual([]); + }); + }); + + describe('_addOrUpdateDraft', () => { + it('should handle undefined ruleDraft', async () => { + const testRuleData = { + _id: 'testId', + }; + + const result = await service['_addOrUpdateDraft'](testRuleData); + expect(result).toEqual(testRuleData); + }); + }); + + describe('getAllRuleData with additional cases', () => { + it('should handle array filters correctly', async () => { + await service.getAllRuleData({ + page: 1, + pageSize: 10, + filters: { status: ['active', 'pending'] }, + }); + + expect(MockRuleDataModel.find).toHaveBeenCalledWith({ + $and: [ + { + status: { $in: ['active', 'pending'] }, + }, + ], + }); + }); + + it('should handle single value filters', async () => { + await service.getAllRuleData({ + page: 1, + pageSize: 10, + filters: { isPublished: true }, + }); + + expect(MockRuleDataModel.find).toHaveBeenCalledWith({ + $and: [ + { + isPublished: true, + }, + ], + }); + }); + + it('should ignore null or undefined filter values', async () => { + await service.getAllRuleData({ + page: 1, + pageSize: 10, + filters: { status: null, isPublished: undefined }, + }); + + expect(MockRuleDataModel.find).toHaveBeenCalledWith({}); + }); + }); + + describe('getRuleDataWithDraft', () => { + it('should handle error when getting draft', async () => { + MockRuleDataModel.findById.mockImplementationOnce(() => ({ + populate: jest.fn().mockImplementationOnce(() => ({ + exec: jest.fn().mockRejectedValueOnce(new Error('Draft not found')), + })), + })); + + await expect(service.getRuleDataWithDraft('testId')).rejects.toThrow( + 'Error getting draft for testId: Draft not found', + ); + }); + }); + + describe('getRuleData', () => { + it('should throw error when rule data not found', async () => { + MockRuleDataModel.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(null), + }); + + await expect(service.getRuleData('nonexistentId')).rejects.toThrow('Rule data not found'); + }); + }); + + describe('getRuleDataByFilepath', () => { + it('should handle errors when getting rule by filepath', async () => { + MockRuleDataModel.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockRejectedValue(new Error('Database error')), + }); + + await expect(service.getRuleDataByFilepath('test/path.json')).rejects.toThrow( + 'Error getting all rule data for test/path.json: Database error', + ); + }); + }); + + describe('createRuleData', () => { + it('should generate new _id if not provided', async () => { + const testData = { + filepath: 'test/path.json', + title: 'Test Rule', + }; + + const result = await service.createRuleData(testData); + expect(result._id).toBeDefined(); + expect(typeof result._id).toBe('string'); + }); + + it('should derive name from filepath', async () => { + const testData = { + filepath: 'test/new-rule.json', + title: 'Test Rule', + }; + + const result = await service.createRuleData(testData); + expect(result.name).toBe('new-rule'); + }); + }); }); From f2de486044dc6252602032ea365e9868f1dce219 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Wed, 30 Oct 2024 16:27:40 -0700 Subject: [PATCH 2/3] Add additional testing for utilities, rulemapping, and scenarioData. --- .../dto/evaluate-rulemapping.dto.spec.ts | 132 +++++++++ .../ruleMapping.controller.spec.ts | 148 +++++++++- .../scenarioData/scenarioData.service.spec.ts | 117 +++++++- src/utils/csv.spec.ts | 98 ++++++- src/utils/handleTrace.spec.ts | 266 ++++++++++++++++++ src/utils/helpers.spec.ts | 105 +++++++ src/utils/readFile.spec.ts | 31 ++ 7 files changed, 894 insertions(+), 3 deletions(-) create mode 100644 src/api/ruleMapping/dto/evaluate-rulemapping.dto.spec.ts create mode 100644 src/utils/handleTrace.spec.ts create mode 100644 src/utils/helpers.spec.ts create mode 100644 src/utils/readFile.spec.ts diff --git a/src/api/ruleMapping/dto/evaluate-rulemapping.dto.spec.ts b/src/api/ruleMapping/dto/evaluate-rulemapping.dto.spec.ts new file mode 100644 index 0000000..87a8dcd --- /dev/null +++ b/src/api/ruleMapping/dto/evaluate-rulemapping.dto.spec.ts @@ -0,0 +1,132 @@ +import 'reflect-metadata'; + +import { + EdgeClass, + TraceObjectEntryClass, + TraceObjectClass, + EvaluateRuleMappingDto, + EvaluateRuleRunSchemaDto, +} from './evaluate-rulemapping.dto'; + +describe('DTO Classes', () => { + describe('EdgeClass', () => { + it('should create an edge with all properties', () => { + const edge = new EdgeClass('edge1', 'default', 'target1', 'source1', 'sourceHandle1', 'targetHandle1'); + + expect(edge).toEqual({ + id: 'edge1', + type: 'default', + targetId: 'target1', + sourceId: 'source1', + sourceHandle: 'sourceHandle1', + targetHandle: 'targetHandle1', + }); + }); + + it('should create an edge without optional properties', () => { + const edge = new EdgeClass('edge1', 'default', 'target1', 'source1'); + + expect(edge).toEqual({ + id: 'edge1', + type: 'default', + targetId: 'target1', + sourceId: 'source1', + sourceHandle: undefined, + targetHandle: undefined, + }); + }); + }); + + describe('TraceObjectEntryClass', () => { + it('should create a trace entry with all properties', () => { + const traceEntry = new TraceObjectEntryClass( + 'trace1', + 'Test Trace', + { input: 'data' }, + { output: 'data' }, + '100ms', + { extra: 'data' }, + ); + + expect(traceEntry).toEqual({ + id: 'trace1', + name: 'Test Trace', + input: { input: 'data' }, + output: { output: 'data' }, + performance: '100ms', + traceData: { extra: 'data' }, + }); + }); + + it('should create a trace entry without optional properties', () => { + const traceEntry = new TraceObjectEntryClass('trace1', 'Test Trace', { input: 'data' }, { output: 'data' }); + + expect(traceEntry).toEqual({ + id: 'trace1', + name: 'Test Trace', + input: { input: 'data' }, + output: { output: 'data' }, + performance: undefined, + traceData: undefined, + }); + }); + }); + + describe('TraceObjectClass', () => { + it('should create a trace object with entries', () => { + const entries = { + trace1: new TraceObjectEntryClass('trace1', 'Test Trace 1', { input: 'data1' }, { output: 'data1' }), + trace2: new TraceObjectEntryClass('trace2', 'Test Trace 2', { input: 'data2' }, { output: 'data2' }), + }; + + const traceObject = new TraceObjectClass(entries); + + expect(traceObject.trace1).toBeDefined(); + expect(traceObject.trace2).toBeDefined(); + expect(traceObject.trace1.id).toBe('trace1'); + expect(traceObject.trace2.id).toBe('trace2'); + }); + + it('should create an empty trace object', () => { + const traceObject = new TraceObjectClass({}); + expect(Object.keys(traceObject)).toHaveLength(0); + }); + }); + + describe('EvaluateRuleMappingDto', () => { + it('should create instance with nodes and edges', () => { + const dto = new EvaluateRuleMappingDto(); + dto.nodes = [{ id: 'node1', type: 'default', content: {} }]; + dto.edges = [new EdgeClass('edge1', 'default', 'target1', 'source1')]; + + expect(dto.nodes).toHaveLength(1); + expect(dto.edges).toHaveLength(1); + expect(dto.nodes[0].id).toBe('node1'); + expect(dto.edges[0].id).toBe('edge1'); + }); + + it('should create empty instance', () => { + const dto = new EvaluateRuleMappingDto(); + expect(dto.nodes).toBeUndefined(); + expect(dto.edges).toBeUndefined(); + }); + }); + + describe('EvaluateRuleRunSchemaDto', () => { + it('should create instance with trace', () => { + const dto = new EvaluateRuleRunSchemaDto(); + const traceEntries = { + trace1: new TraceObjectEntryClass('trace1', 'Test Trace', { input: 'data' }, { output: 'data' }), + }; + dto.trace = new TraceObjectClass(traceEntries); + + expect(dto.trace).toBeDefined(); + expect(dto.trace['trace1'].id).toBe('trace1'); + }); + + it('should create empty instance', () => { + const dto = new EvaluateRuleRunSchemaDto(); + expect(dto.trace).toBeUndefined(); + }); + }); +}); diff --git a/src/api/ruleMapping/ruleMapping.controller.spec.ts b/src/api/ruleMapping/ruleMapping.controller.spec.ts index c141f7a..badac9e 100644 --- a/src/api/ruleMapping/ruleMapping.controller.spec.ts +++ b/src/api/ruleMapping/ruleMapping.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { RuleMappingController } from './ruleMapping.controller'; -import { RuleMappingService } from './ruleMapping.service'; +import { RuleMappingService, InvalidRuleContent } from './ruleMapping.service'; import { EvaluateRuleMappingDto, EvaluateRuleRunSchemaDto } from './dto/evaluate-rulemapping.dto'; import { HttpException, HttpStatus } from '@nestjs/common'; import { Response } from 'express'; @@ -59,6 +59,67 @@ describe('RuleMappingController', () => { ); expect(mockResponse.send).toHaveBeenCalledWith(rulemap); }); + it('should set headers and send response', async () => { + const ruleFileName = 'test-rule.json'; + const ruleContent = { nodes: [], edges: [] }; + const rulemap = { inputs: [], resultOutputs: [] }; + jest.spyOn(service, 'inputOutputSchema').mockResolvedValue(rulemap); + + const mockResponse = { + setHeader: jest.fn(), + send: jest.fn(), + } as unknown as Response; + + await controller.getRuleSchema(ruleFileName, ruleContent, mockResponse); + + expect(service.inputOutputSchema).toHaveBeenCalledWith(ruleContent); + expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + `attachment; filename=${ruleFileName}`, + ); + expect(mockResponse.send).toHaveBeenCalledWith(rulemap); + }); + + it('should throw an error for invalid rule content', async () => { + const mockResponse = { setHeader: jest.fn(), send: jest.fn() } as unknown as Response; + const ruleFileName = 'test-rule.json'; + const ruleContent = { nodes: [], edges: [] }; + + jest.spyOn(service, 'inputOutputSchema').mockImplementation(() => { + throw new InvalidRuleContent('Invalid rule content'); + }); + + await expect(controller.getRuleSchema(ruleFileName, ruleContent, mockResponse)).rejects.toThrow( + new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST), + ); + }); + it('should set correct headers for JSON response in getRuleSchema', async () => { + const ruleFileName = 'sample-rule.json'; + const ruleContent = { nodes: [], edges: [] }; + const mockResponse = { setHeader: jest.fn(), send: jest.fn() } as unknown as Response; + + await controller.getRuleSchema(ruleFileName, ruleContent, mockResponse); + + expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + `attachment; filename=${ruleFileName}`, + ); + }); + it('should throw a BAD_REQUEST exception if InvalidRuleContent is encountered in getRuleSchema', async () => { + const ruleFileName = 'invalid-rule.json'; + const ruleContent = { nodes: [], edges: [] }; + const mockResponse = { setHeader: jest.fn(), send: jest.fn() } as unknown as Response; + + jest.spyOn(service, 'inputOutputSchema').mockImplementation(() => { + throw new InvalidRuleContent('Invalid rule content'); + }); + + await expect(controller.getRuleSchema(ruleFileName, ruleContent, mockResponse)).rejects.toThrow( + new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST), + ); + }); }); describe('evaluateRuleMap', () => { @@ -89,6 +150,17 @@ describe('RuleMappingController', () => { new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR), ); }); + it('should handle InvalidRuleContent errors properly', async () => { + const dto: EvaluateRuleMappingDto = { nodes: [{ id: '1', type: 'someType', content: {} }], edges: [] }; + + jest.spyOn(service, 'inputOutputSchema').mockImplementation(() => { + throw new InvalidRuleContent('Error'); + }); + + await expect(controller.evaluateRuleMap(dto)).rejects.toThrow( + new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST), + ); + }); }); describe('evaluateRuleSchema', () => { @@ -178,5 +250,79 @@ describe('RuleMappingController', () => { new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR), ); }); + it('should handle empty trace data correctly', async () => { + const dto: EvaluateRuleRunSchemaDto = { trace: null }; + + await expect(controller.evaluateRuleSchema(dto)).rejects.toThrow( + new HttpException('Invalid request data', HttpStatus.BAD_REQUEST), + ); + }); + + it('should handle internal server errors', async () => { + const mockTraceObject: TraceObject = { '1': { id: '1', name: 'Test Rule', input: {}, output: {} } }; + const dto: EvaluateRuleRunSchemaDto = { trace: mockTraceObject }; + + jest.spyOn(service, 'evaluateRuleSchema').mockImplementation(() => { + throw new Error('Unexpected error'); + }); + + await expect(controller.evaluateRuleSchema(dto)).rejects.toThrow( + new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR), + ); + }); + }); + describe('generateWithoutInputOutputNodes', () => { + it('should set header and send response with rule schema', async () => { + const ruleContent = { nodes: [], edges: [] }; + const rulemap = { inputs: [], resultOutputs: [] }; + jest.spyOn(service, 'ruleSchema').mockResolvedValue(rulemap); + + const mockResponse = { + setHeader: jest.fn(), + send: jest.fn(), + } as unknown as Response; + + await controller.generateWithoutInputOutputNodes(ruleContent, mockResponse); + + expect(service.ruleSchema).toHaveBeenCalledWith(ruleContent); + expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(mockResponse.send).toHaveBeenCalledWith(rulemap); + }); + + it('should throw an error for invalid rule content in generateWithoutInputOutputNodes', async () => { + const mockResponse = { setHeader: jest.fn(), send: jest.fn() } as unknown as Response; + const ruleContent = { nodes: [], edges: [] }; + + jest.spyOn(service, 'ruleSchema').mockImplementation(() => { + throw new InvalidRuleContent('Invalid rule content'); + }); + + await expect(controller.generateWithoutInputOutputNodes(ruleContent, mockResponse)).rejects.toThrow( + new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST), + ); + }); + it('should send rule schema in response in generateWithoutInputOutputNodes', async () => { + const ruleContent = { nodes: [], edges: [] }; + const ruleSchema = { inputs: [], resultOutputs: [] }; + const mockResponse = { setHeader: jest.fn(), send: jest.fn() } as unknown as Response; + + jest.spyOn(service, 'ruleSchema').mockResolvedValue(ruleSchema); + + await controller.generateWithoutInputOutputNodes(ruleContent, mockResponse); + + expect(mockResponse.send).toHaveBeenCalledWith(ruleSchema); + }); + it('should throw a BAD_REQUEST exception if InvalidRuleContent is encountered in generateWithoutInputOutputNodes', async () => { + const ruleContent = { nodes: [], edges: [] }; + const mockResponse = { setHeader: jest.fn(), send: jest.fn() } as unknown as Response; + + jest.spyOn(service, 'ruleSchema').mockImplementation(() => { + throw new InvalidRuleContent('Invalid rule content'); + }); + + await expect(controller.generateWithoutInputOutputNodes(ruleContent, mockResponse)).rejects.toThrow( + new HttpException('Invalid rule content', HttpStatus.BAD_REQUEST), + ); + }); }); }); diff --git a/src/api/scenarioData/scenarioData.service.spec.ts b/src/api/scenarioData/scenarioData.service.spec.ts index 200a5dd..1edfa45 100644 --- a/src/api/scenarioData/scenarioData.service.spec.ts +++ b/src/api/scenarioData/scenarioData.service.spec.ts @@ -119,6 +119,26 @@ describe('ScenarioDataService', () => { expect(MockScenarioDataModel.findOne).toHaveBeenCalledWith({ _id: scenarioId }); }); + it('should return scenarios by rule ID', async () => { + const mockScenarios = [mockScenarioData]; + MockScenarioDataModel.find = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockScenarios), + }); + + const result = await service.getScenariosByRuleId('testRuleId'); + expect(result).toEqual(mockScenarios); + }); + + it('should handle errors when getting scenarios by rule ID', async () => { + MockScenarioDataModel.find = jest.fn().mockReturnValue({ + exec: jest.fn().mockRejectedValue(new Error('DB Error')), + }); + + await expect(service.getScenariosByRuleId('testRuleId')).rejects.toThrow( + 'Error getting scenarios by rule ID: DB Error', + ); + }); + it('should throw an error if scenario data is not found', async () => { const scenarioId = testObjectId.toString(); MockScenarioDataModel.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); @@ -787,6 +807,71 @@ describe('ScenarioDataService', () => { expect(array[0]).toHaveProperty('dateOfBirth'); }); }); + + it('should handle object-array type', () => { + const input = { + type: 'object-array', + childFields: [{ field: 'testField', type: 'text-input' }], + }; + const result = service.generatePossibleValues(input); + expect(Array.isArray(result)).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle number-input type with different validation types', () => { + const testCases = [ + { validationType: '>=', validationCriteria: '0,10' }, + { validationType: '<=', validationCriteria: '0,10' }, + { validationType: '>', validationCriteria: '0,10' }, + { validationType: '<', validationCriteria: '0,10' }, + { validationType: '(num)', validationCriteria: '0,10' }, + { validationType: '[num]', validationCriteria: '0,10' }, + { validationType: '[=num]', validationCriteria: '1,2,3' }, + { validationType: '[=nums]', validationCriteria: '1,2,3' }, + ]; + + testCases.forEach(({ validationType, validationCriteria }) => { + const input = { + type: 'number-input', + validationType, + validationCriteria, + }; + const result = service.generatePossibleValues(input); + expect(Array.isArray(result)).toBeTruthy(); + }); + }); + + it('should handle date type with different validation types', () => { + const testCases = [ + { validationType: '>=', validationCriteria: '2023-01-01,2023-12-31' }, + { validationType: '<=', validationCriteria: '2023-01-01,2023-12-31' }, + { validationType: '>', validationCriteria: '2023-01-01,2023-12-31' }, + { validationType: '<', validationCriteria: '2023-01-01,2023-12-31' }, + { validationType: '(date)', validationCriteria: '2023-01-01,2023-12-31' }, + { validationType: '[date]', validationCriteria: '2023-01-01,2023-12-31' }, + { validationType: '[=date]', validationCriteria: '2023-01-01,2023-12-31' }, + { validationType: '[=dates]', validationCriteria: '2023-01-01,2023-12-31' }, + ]; + + testCases.forEach(({ validationType, validationCriteria }) => { + const input = { + type: 'date', + validationType, + validationCriteria, + }; + const result = service.generatePossibleValues(input); + expect(Array.isArray(result)).toBeTruthy(); + }); + }); + + it('should handle true-false type', () => { + const input = { type: 'true-false' }; + const result = service.generatePossibleValues(input); + expect(Array.isArray(result)).toBeTruthy(); + expect(result.length).toBe(2); + expect(result).toContain(true); + expect(result).toContain(false); + }); }); describe('generateCombinations', () => { @@ -818,7 +903,6 @@ describe('ScenarioDataService', () => { ]; (complexCartesianProduct as jest.Mock).mockReturnValue(mockProduct); const result = service.generateCombinations(data, undefined); - // expect(result.length).toBe(3); result.forEach((item) => { expect(item).toHaveProperty('field1'); expect(item).toHaveProperty('field2'); @@ -1022,4 +1106,35 @@ describe('ScenarioDataService', () => { expect(result.testCase1.error).toBe('Test error'); }); }); + + describe('generateTestCSVScenarios', () => { + const mockRuleContent = { nodes: [], edges: [] }; + const mockSimulationContext = {}; + + it('should generate CSV scenarios successfully', async () => { + jest.spyOn(service, 'generateTestScenarios').mockResolvedValue({ + testCase1: { + inputs: { field1: 'value1' }, + outputs: { field2: 'value2' }, + expectedResults: {}, + result: {}, + resultMatch: true, + }, + }); + + const result = await service.generateTestCSVScenarios('test.json', mockRuleContent, mockSimulationContext); + + expect(typeof result).toBe('string'); + expect(result).toContain('Scenario'); + expect(result).toContain('Results Match Expected (Pass/Fail)'); + }); + + it('should handle errors in CSV generation', async () => { + jest.spyOn(service, 'generateTestScenarios').mockRejectedValue(new Error('Test error')); + + await expect( + service.generateTestCSVScenarios('test.json', mockRuleContent, mockSimulationContext), + ).rejects.toThrow('Error in generating test scenarios CSV: Test error'); + }); + }); }); diff --git a/src/utils/csv.spec.ts b/src/utils/csv.spec.ts index 3706cfc..b662936 100644 --- a/src/utils/csv.spec.ts +++ b/src/utils/csv.spec.ts @@ -1,4 +1,11 @@ -import { complexCartesianProduct, generateCombinationsWithLimit } from './csv'; +import { + complexCartesianProduct, + generateCombinationsWithLimit, + parseCSV, + extractKeys, + formatVariables, + cartesianProduct, +} from './csv'; describe('CSV Utility Functions', () => { describe('complexCartesianProduct', () => { @@ -84,4 +91,93 @@ describe('CSV Utility Functions', () => { expect(result.length).toBe(1000000); }); }); + + describe('parseCSV', () => { + it('should parse CSV file to array of arrays', async () => { + const file = { buffer: Buffer.from('a,b,c\n1,2,3\n4,5,6') } as Express.Multer.File; + const result = await parseCSV(file); + expect(result).toEqual([ + ['a', 'b', 'c'], + ['1', '2', '3'], + ['4', '5', '6'], + ]); + }); + }); + describe('parseCSV', () => { + it('should parse CSV file to array of arrays', async () => { + const file = { buffer: Buffer.from('a,b,c\n1,2,3\n4,5,6') } as Express.Multer.File; + const result = await parseCSV(file); + expect(result).toEqual([ + ['a', 'b', 'c'], + ['1', '2', '3'], + ['4', '5', '6'], + ]); + }); + }); + describe('extractKeys', () => { + it('should extract keys based on prefix', () => { + const headers = ['prefix_key1', 'prefix_key2', 'other_key']; + const prefix = 'prefix_'; + const result = extractKeys(headers, prefix); + expect(result).toEqual(['key1', 'key2']); + }); + }); + describe('formatVariables', () => { + it('should format variables from a CSV row', () => { + const row = ['value1', 'value2']; + const keys = ['key1', 'key2']; + const startIndex = 0; + const result = formatVariables(row, keys, startIndex); + expect(result).toEqual([ + { name: 'key1', value: 'value1', type: 'string' }, + { name: 'key2', value: 'value2', type: 'string' }, + ]); + }); + it('should format array values correctly', () => { + const row = ['[1, 2, 3]', 'value2']; + const keys = ['arrayKey', 'key2']; + const startIndex = 0; + const result = formatVariables(row, keys, startIndex); + expect(result).toEqual([ + { name: 'arrayKey', value: ['1', '2', '3'], type: 'array' }, + { name: 'key2', value: 'value2', type: 'string' }, + ]); + }); + it('should handle nested array indices', () => { + const row = ['value1', 'value2', 'value3']; + const keys = ['key1[1]', 'key2[2]', 'key3']; + const startIndex = 0; + const result = formatVariables(row, keys, startIndex); + expect(result).toEqual([ + { name: 'key1', value: ['value1'], type: 'array' }, + { name: 'key2', value: ['value2'], type: 'array' }, + { name: 'key3', value: 'value3', type: 'string' }, + ]); + }); + it('should filter out empty values when filterEmpty is true', () => { + const row = ['value1', '', 'value2']; + const keys = ['key1', 'key2', 'key3']; + const startIndex = 0; + const result = formatVariables(row, keys, startIndex, true); + expect(result).toEqual([ + { name: 'key1', value: 'value1', type: 'string' }, + { name: 'key3', value: 'value2', type: 'string' }, + ]); + }); + }); + describe('cartesianProduct', () => { + it('should generate cartesian product of arrays', () => { + const input = [ + [1, 2], + ['a', 'b'], + ]; + const result = cartesianProduct(input); + expect(result).toEqual([ + [1, 'a'], + [1, 'b'], + [2, 'a'], + [2, 'b'], + ]); + }); + }); }); diff --git a/src/utils/handleTrace.spec.ts b/src/utils/handleTrace.spec.ts new file mode 100644 index 0000000..8a96cf8 --- /dev/null +++ b/src/utils/handleTrace.spec.ts @@ -0,0 +1,266 @@ +import { getPropertyById, mapTraceToResult, mapTraces } from './handleTrace'; +import { RuleSchema } from '../api/scenarioData/scenarioData.interface'; +import { TraceObject, TraceObjectEntry } from '../api/ruleMapping/ruleMapping.interface'; + +describe('Rule Utility Functions', () => { + const mockRuleSchema: RuleSchema = { + inputs: [ + { id: 'input1', field: 'Input Field 1' }, + { id: 'input2', field: 'Input Field 2' }, + ], + resultOutputs: [ + { id: 'output1', field: 'Output Field 1' }, + { id: 'output2', field: 'Output Field 2' }, + ], + }; + + describe('getPropertyById', () => { + it('should return the property name for input', () => { + const result = getPropertyById('input1', mockRuleSchema, 'input'); + expect(result).toBe('Input Field 1'); + }); + + it('should return the property name for output', () => { + const result = getPropertyById('output1', mockRuleSchema, 'output'); + expect(result).toBe('Output Field 1'); + }); + + it('should return null for non-existing id', () => { + const result = getPropertyById('nonExistingId', mockRuleSchema, 'input'); + expect(result).toBeNull(); + }); + }); + + describe('mapTraceToResult', () => { + it('should map trace to result for input', () => { + const trace: TraceObjectEntry = { + id: 'trace1', + name: 'trace1', + input: { input1: 'value1', input2: 'value2' }, + output: {}, + }; + const result = mapTraceToResult(trace.input, mockRuleSchema, 'input'); + expect(result).toEqual({ + 'Input Field 1': 'value1', + 'Input Field 2': 'value2', + }); + }); + + it('should map trace to result for output', () => { + const trace: TraceObjectEntry = { + id: 'trace1', + name: 'trace1', + input: {}, + output: { output1: 'value1', output2: 'value2' }, + }; + const result = mapTraceToResult(trace.output, mockRuleSchema, 'output'); + expect(result).toEqual({ + 'Output Field 1': 'value1', + 'Output Field 2': 'value2', + }); + }); + + it('should map nested objects in trace', () => { + const trace: TraceObjectEntry = { + id: 'trace1', + name: 'trace1', + input: { input1: { nestedKey: 'nestedValue' }, input2: 'value2' }, + output: {}, + }; + const result = mapTraceToResult(trace.input, mockRuleSchema, 'input'); + expect(result).toEqual({ + 'Input Field 1': { nestedKey: 'nestedValue' }, + 'Input Field 2': 'value2', + }); + }); + + it('should map array values in trace', () => { + const trace: TraceObjectEntry = { + id: 'trace1', + name: 'trace1', + input: { + input1: ['value1', 'value2'], + input2: 'value3', + }, + output: {}, + }; + const result = mapTraceToResult(trace.input, mockRuleSchema, 'input'); + expect(result).toEqual({ + 'Input Field 1': ['value1', 'value2'], + 'Input Field 2': 'value3', + }); + }); + + it('should handle object entries correctly', () => { + const trace: TraceObjectEntry = { + id: 'trace1', + name: 'trace1', + input: { + input1: { key1: 'value1', key2: 'value2' }, + input2: 'value3', + }, + output: {}, + }; + const result = mapTraceToResult(trace.input, mockRuleSchema, 'input'); + expect(result).toEqual({ + 'Input Field 1': { key1: 'value1', key2: 'value2' }, + 'Input Field 2': 'value3', + }); + }); + }); + + describe('mapTraces', () => { + it('should map multiple traces for input', () => { + const traces: TraceObject = { + trace1: { id: 'trace1', name: 'trace1', input: { input1: 'value1' }, output: {} }, + trace2: { id: 'trace2', name: 'trace2', input: { input2: 'value2' }, output: {} }, + }; + const result = mapTraces(traces, mockRuleSchema, 'input'); + expect(result).toEqual({ + 'Input Field 1': 'value1', + 'Input Field 2': 'value2', + }); + }); + + it('should map multiple traces for output', () => { + const traces: TraceObject = { + trace1: { id: 'trace1', name: 'trace1', input: {}, output: { output1: 'value1' } }, + trace2: { id: 'trace2', name: 'trace2', input: {}, output: { output2: 'value2' } }, + }; + const result = mapTraces(traces, mockRuleSchema, 'output'); + expect(result).toEqual({ + 'Output Field 1': 'value1', + 'Output Field 2': 'value2', + }); + }); + }); + + describe('mapTraceToResult - object handling in trace', () => { + const mockSchema: RuleSchema = { + inputs: [ + { field: 'nestedObjectKey', id: '1' }, + { field: 'validKey', id: '5' }, + { field: 'emptyObjectKey', id: '2' }, + ], + resultOutputs: [{ field: 'outputField', id: '2' }], + }; + + it('should correctly map nested objects in trace to result with appropriate keys', () => { + const trace: TraceObject = { + nestedObjectKey: { + id: '1', + name: 'Test Entry', + input: {}, + output: {}, + traceData: { + first: { nestedField: 'value1' }, + second: { nestedField: 'value2' }, + }, + }, + }; + + const result = mapTraceToResult(trace, mockSchema, 'input'); + console.log('Test Result (nested objects):', result); + + expect(result).toEqual({ + nestedObjectKey: { + name: 'Test Entry', + id: '1', + input: {}, + output: {}, + traceData: { first: { nestedField: 'value1' }, second: { nestedField: 'value2' } }, + }, + 'nestedObjectKey[3]nestedObjectKey': {}, + 'nestedObjectKey[4]nestedObjectKey': {}, + 'nestedObjectKey[5]first': { + nestedField: 'value1', + }, + 'nestedObjectKey[5]second': { nestedField: 'value2' }, + }); + }); + + it('should map an empty object in trace correctly with the appropriate key format', () => { + const trace: TraceObject = { + emptyObjectKey: { + id: '2', + name: 'Empty Object Entry', + input: {}, + output: {}, + traceData: { + first: {}, + }, + }, + }; + + const result = mapTraceToResult(trace, mockSchema, 'output'); + console.log('Test Result (empty object):', result); + + expect(result).toEqual({}); + }); + + it('should ignore keys $ and $nodes even if they exist in trace', () => { + const trace: TraceObject = { + $: { + id: '3', + name: 'Dollar Entry', + input: {}, + output: {}, + }, + $nodes: { + id: '4', + name: 'Nodes Entry', + input: {}, + output: {}, + }, + validKey: { + id: '5', + name: 'Valid Entry', + input: {}, + output: {}, + traceData: { + first: { someField: 'validValue' }, + }, + }, + }; + + const result = mapTraceToResult(trace, mockSchema, 'input'); + console.log('Test Result (ignore $ and $nodes):', result); + + expect(result).toEqual({ + validKey: { + id: '5', + name: 'Valid Entry', + input: {}, + output: {}, + traceData: { + first: { someField: 'validValue' }, + }, + }, + 'validKey[3]validKey': {}, + 'validKey[4]validKey': {}, + 'validKey[5]first': { + someField: 'validValue', + }, + }); + }); + + it('should not map a trace key if it does not exist on schema', () => { + const trace: TraceObject = { + unrelatedKey: { + id: '6', + name: 'Unrelated Entry', + input: {}, + output: {}, + traceData: { + first: { unrelatedField: 'shouldNotMap' }, + }, + }, + }; + + const result = mapTraceToResult(trace, mockSchema, 'input'); + console.log('Test Result (unrelated key):', result); + + expect(result).toEqual({}); + }); + }); +}); diff --git a/src/utils/helpers.spec.ts b/src/utils/helpers.spec.ts new file mode 100644 index 0000000..a98ca73 --- /dev/null +++ b/src/utils/helpers.spec.ts @@ -0,0 +1,105 @@ +import { + replaceSpecialCharacters, + isEqual, + reduceToCleanObj, + extractUniqueKeys, + formatValue, + deriveNameFromFilepath, +} from './helpers'; + +describe('Utility Functions', () => { + describe('replaceSpecialCharacters', () => { + it('should replace special characters with the replacement string', () => { + const input = 'Hello,world!'; + const replacement = '-'; + const result = replaceSpecialCharacters(input, replacement); + expect(result).toBe('Hello-world!'); + }); + + it('should replace commas with a dash', () => { + const input = 'Hello, world!'; + const replacement = ''; + const result = replaceSpecialCharacters(input, replacement); + expect(result).toBe('Hello- world!'); + }); + }); + + describe('isEqual', () => { + it('should return true for equal objects', () => { + const obj1 = { a: 1, b: 2 }; + const obj2 = { a: 1, b: 2 }; + const result = isEqual(obj1, obj2); + expect(result).toBe(true); + }); + + it('should return false for non-equal objects', () => { + const obj1 = { a: 1, b: 2 }; + const obj2 = { a: 1, b: 3 }; + const result = isEqual(obj1, obj2); + expect(result).toBe(false); + }); + }); + + describe('reduceToCleanObj', () => { + it('should reduce array to cleaned object', () => { + const arr = [ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' }, + ]; + const result = reduceToCleanObj(arr, 'key', 'value', '-'); + expect(result).toEqual({ + key1: 'value1', + key2: 'value2', + }); + }); + + it('should handle null or undefined input array', () => { + const result = reduceToCleanObj(null, 'key', 'value', '-'); + expect(result).toEqual({}); + }); + }); + + describe('extractUniqueKeys', () => { + it('should extract unique keys from specified property', () => { + const object = { + a: { inputs: { key1: 'value1', key2: 'value2' } }, + b: { inputs: { key3: 'value3' } }, + }; + const result = extractUniqueKeys(object, 'inputs'); + expect(result).toEqual(['key1', 'key2', 'key3']); + }); + }); + + describe('formatValue', () => { + it('should format true/false strings to boolean', () => { + expect(formatValue('true')).toBe(true); + expect(formatValue('false')).toBe(false); + }); + + it('should return the value if it matches the date format', () => { + const date = '2021-09-15'; + expect(formatValue(date)).toBe(date); + }); + + it('should format numeric strings to numbers', () => { + expect(formatValue('42')).toBe(42); + }); + + it('should return null for empty strings', () => { + expect(formatValue('')).toBeNull(); + }); + + it('should return the original string if no conditions match', () => { + const str = 'hello'; + expect(formatValue(str)).toBe(str); + }); + }); + + describe('deriveNameFromFilepath', () => { + it('should derive the name from the filepath', () => { + const filepath = '/path/to/file.json'; + const result = deriveNameFromFilepath(filepath); + expect(result).toBe('file'); + }); + }); +}); diff --git a/src/utils/readFile.spec.ts b/src/utils/readFile.spec.ts new file mode 100644 index 0000000..5fe5c55 --- /dev/null +++ b/src/utils/readFile.spec.ts @@ -0,0 +1,31 @@ +import { readFileSafely, FileNotFoundError } from './readFile'; +import * as fs from 'fs'; + +jest.mock('fs'); +const mockedFs = fs as jest.Mocked; + +const RULES_DIRECTORY = '../../../brms-rules/rules'; + +describe('File Utility Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('readFileSafely', () => { + it('should throw FileNotFoundError if path traversal detected', async () => { + const mockInvalidFileName = '../traversal.txt'; + + await expect(readFileSafely(RULES_DIRECTORY, mockInvalidFileName)).rejects.toThrow( + new FileNotFoundError('Path traversal detected'), + ); + }); + + it('should throw FileNotFoundError if file does not exist', async () => { + mockedFs.existsSync.mockReturnValue(false); + + await expect(readFileSafely(RULES_DIRECTORY, 'nonexistentFile.txt')).rejects.toThrow( + new FileNotFoundError('File not found'), + ); + }); + }); +}); From f79a32d694364ae4fa64b1434505e5ba1abc3da8 Mon Sep 17 00:00:00 2001 From: brysonjbest <103070659+brysonjbest@users.noreply.github.com> Date: Tue, 5 Nov 2024 13:39:19 -0800 Subject: [PATCH 3/3] Update test for new error handling. --- src/api/klamm/klamm.service.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/api/klamm/klamm.service.spec.ts b/src/api/klamm/klamm.service.spec.ts index bb50b62..140b8e7 100644 --- a/src/api/klamm/klamm.service.spec.ts +++ b/src/api/klamm/klamm.service.spec.ts @@ -367,18 +367,23 @@ describe('KlammService', () => { }); it('should initialize axiosGithubInstance with auth header when GITHUB_TOKEN is set', () => { process.env.GITHUB_TOKEN = 'test-token'; - const newService = new KlammService(ruleDataService, ruleMappingService, documentsService, klammSyncMetadata); + const newService = new KlammService( + ruleDataService, + ruleMappingService, + documentsService, + klammSyncMetadata, + new Logger(), + ); expect(newService.axiosGithubInstance.defaults.headers).toHaveProperty('Authorization', 'Bearer test-token'); delete process.env.GITHUB_TOKEN; }); it('should handle case when no rule is found for changed file', async () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const loggerSpy = jest.spyOn(service['logger'], 'warn'); jest.spyOn(ruleDataService, 'getRuleDataByFilepath').mockResolvedValue(null); await service['_syncRules'](['nonexistent-file.js']); - expect(consoleSpy).toHaveBeenCalledWith('No rule found for changed file: nonexistent-file.js'); - consoleSpy.mockRestore(); + expect(loggerSpy).toHaveBeenCalledWith('No rule found for changed file: nonexistent-file.js'); }); describe('_getAllKlammFields', () => {