Add object property validation in parser #369

This commit introduces stricter type validation across the application
to reject objects with unexpected properties, enhancing the robustness
and predictability of data handling.

Changes include:

- Implement a common utility to validate object types.
- Refactor across various parsers and data handlers to utilize the new
  validations.
- Update error messages for better clarity and troubleshooting.
This commit is contained in:
undergroundwires
2024-06-13 22:26:57 +02:00
parent c138f74460
commit 6ecfa9b954
43 changed files with 1215 additions and 466 deletions

View File

@@ -3,10 +3,11 @@ import type { ExecutableValidator, ExecutableValidatorFactory } from '@/applicat
import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
import { ExecutableValidatorStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import type { ExecutableData } from '@/application/collections/';
import type { FunctionKeys } from '@/TypeHelpers';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import { expectDeepIncludes } from '@tests/shared/Assertions/ExpectDeepIncludes';
type ValidationTestFunction<TExpectation> = (
factory: ExecutableValidatorFactory,
@@ -52,39 +53,41 @@ export function itValidatesName(
});
}
interface ValidDataExpectation {
readonly expectedDataToValidate: ExecutableData;
interface TypeAssertionExpectation {
readonly expectedErrorContext: ExecutableErrorContext;
readonly assertValidation: (validator: TypeValidatorStub) => void;
}
export function itValidatesDefinedData(
test: ValidationTestFunction<ValidDataExpectation>,
export function itValidatesType(
test: ValidationTestFunction<TypeAssertionExpectation>,
) {
it('validates data', () => {
it('validates type', () => {
// arrange
const validator = new ExecutableValidatorStub();
const factoryStub: ExecutableValidatorFactory = () => validator;
// act
test(factoryStub);
// assert
const call = validator.callHistory.find((c) => c.methodName === 'assertDefined');
const call = validator.callHistory.find((c) => c.methodName === 'assertType');
expectExists(call);
});
it('validates data with correct data', () => {
it('validates type using specified validator', () => {
// arrange
const typeValidator = new TypeValidatorStub();
const validator = new ExecutableValidatorStub();
const factoryStub: ExecutableValidatorFactory = () => validator;
// act
const expectation = test(factoryStub);
// assert
const expectedData = expectation.expectedDataToValidate;
const calls = validator.callHistory.filter((c) => c.methodName === 'assertDefined');
const names = calls.flatMap((c) => c.args[0]);
expect(names).to.include(expectedData);
const calls = validator.callHistory.filter((c) => c.methodName === 'assertType');
const args = calls.map((c) => c.args as Parameters<ExecutableValidator['assertType']>);
const validateFunctions = args.flatMap((c) => c[0]);
validateFunctions.forEach((validate) => validate(typeValidator));
expectation.assertValidation(typeValidator);
});
it('validates data with correct context', () => {
it('validates type with correct context', () => {
expectCorrectContextForFunctionCall({
methodName: 'assertDefined',
methodName: 'assertType',
act: test,
expectContext: (expectation) => expectation.expectedErrorContext,
});
@@ -185,34 +188,5 @@ function expectCorrectContextForFunctionCall<T>(testScenario: {
const providedContexts = createdValidators
.filter((v) => v.validator.callHistory.find((c) => c.methodName === methodName))
.map((v) => v.context);
expectDeepIncludes( // to.deep.include is not working
providedContexts,
expectedContext,
formatAssertionMessage([
'Error context mismatch.',
'Provided contexts do not include the expected context.',
'Expected context:',
indentText(JSON.stringify(expectedContext, undefined, 2)),
'Provided contexts:',
indentText(JSON.stringify(providedContexts, undefined, 2)),
]),
);
}
function expectDeepIncludes<T>(
array: readonly T[],
item: T,
message: string,
) {
const serializeItem = (c) => JSON.stringify(c);
const serializedContexts = array.map((c) => serializeItem(c));
const serializedExpectedContext = serializeItem(item);
expect(serializedContexts).to.include(serializedExpectedContext, formatAssertionMessage([
'Error context mismatch.',
'Provided contexts do not include the expected context.',
'Expected context:',
indentText(JSON.stringify(message, undefined, 2)),
'Provided contexts:',
indentText(JSON.stringify(message, undefined, 2)),
]));
expectDeepIncludes(providedContexts, expectedContext);
}

View File

@@ -1,12 +1,12 @@
import { describe, it, expect } from 'vitest';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import type { ExecutableData } from '@/application/collections/';
import { createExecutableErrorContextStub } from '@tests/unit/shared/Stubs/ExecutableErrorContextStub';
import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { ContextualExecutableValidator, createExecutableDataValidator, type ExecutableValidator } from '@/application/Parser/Executable/Validation/ExecutableValidator';
import type { ExecutableContextErrorMessageCreator } from '@/application/Parser/Executable/Validation/ExecutableErrorContextMessage';
import { getAbsentObjectTestCases, getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import type { TypeValidator } from '@/application/Parser/Common/TypeValidator';
describe('createExecutableDataValidator', () => {
it(`returns an instance of ${ContextualExecutableValidator.name}`, () => {
@@ -63,43 +63,41 @@ describe('ContextualExecutableValidator', () => {
expect(act).to.not.throw();
});
});
describe('assertDefined', () => {
describe('throws when data is missing', () => {
describe('assertType', () => {
describe('rethrows when action throws', () => {
// arrange
const testScenarios: readonly {
readonly description: string;
readonly invalidData: unknown;
}[] = [
...getAbsentObjectTestCases().map((testCase) => ({
description: `absent object (${testCase.valueName})`,
invalidData: testCase.absentValue,
})),
{
description: 'empty object',
invalidData: {},
},
];
testScenarios.forEach(({ description, invalidData }) => {
describe(`given "${description}"`, () => {
const expectedMessage = 'missing executable data';
itThrowsCorrectly({
// act
throwingAction: (sut: ExecutableValidator) => {
sut.assertDefined(invalidData as ExecutableData);
},
// assert
expectedMessage,
const expectedMessage = 'Error thrown by action';
itThrowsCorrectly({
// act
throwingAction: (sut: ExecutableValidator) => {
sut.assertType(() => {
throw new Error(expectedMessage);
});
});
},
// assert
expectedMessage,
});
});
it('does not throw if data is defined', () => {
it('provides correct validator', () => {
// arrange
const expectedValidator = new TypeValidatorStub();
const sut = new ValidatorBuilder()
.withTypeValidator(expectedValidator)
.build();
let actualValidator: TypeValidator | undefined;
// act
sut.assertType((validator) => {
actualValidator = validator;
});
// assert
expect(expectedValidator).to.equal(actualValidator);
});
it('does not throw if action does not throw', () => {
// arrange
const data = new CategoryDataStub();
const sut = new ValidatorBuilder()
.build();
// act
const act = () => sut.assertDefined(data);
const act = () => sut.assertType(() => { /* Does not throw */ });
// assert
expect(act).to.not.throw();
});
@@ -223,6 +221,8 @@ class ValidatorBuilder {
private errorMessageCreator: ExecutableContextErrorMessageCreator = () => `[${ValidatorBuilder.name}] stub error message`;
private typeValidator: TypeValidator = new TypeValidatorStub();
public withErrorMessageCreator(errorMessageCreator: ExecutableContextErrorMessageCreator): this {
this.errorMessageCreator = errorMessageCreator;
return this;
@@ -233,10 +233,16 @@ class ValidatorBuilder {
return this;
}
public withTypeValidator(typeValidator: TypeValidator): this {
this.typeValidator = typeValidator;
return this;
}
public build(): ContextualExecutableValidator {
return new ContextualExecutableValidator(
this.errorContext,
this.errorMessageCreator,
this.typeValidator,
);
}
}