Skip to content

Commit

Permalink
Merge pull request #51 from bcgov/feature/additional-testing-coverage
Browse files Browse the repository at this point in the history
Feature/additional testing coverage
  • Loading branch information
brysonjbest authored Nov 6, 2024
2 parents 405a845 + f79a32d commit ef04ffc
Show file tree
Hide file tree
Showing 15 changed files with 1,491 additions and 32 deletions.
17 changes: 16 additions & 1 deletion src/api/decisions/decisions.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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';
Expand Down
55 changes: 54 additions & 1 deletion src/api/decisions/decisions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { Logger } from '@nestjs/common';
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(),
Expand Down Expand Up @@ -62,6 +65,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', () => {
Expand All @@ -83,6 +102,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', () => {
Expand All @@ -108,4 +150,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),
);
});
});
128 changes: 128 additions & 0 deletions src/api/decisions/validations/validations.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -19,27 +46,114 @@ 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' };
const context = { birthDate: '2000-01-01' };
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 };
Expand Down Expand Up @@ -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();
});
});
});
12 changes: 9 additions & 3 deletions src/api/decisions/validations/validations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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}`);
}
}

Expand Down
Loading

0 comments on commit ef04ffc

Please sign in to comment.