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

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import { CustomError } from '@/application/Common/CustomError';
import { wrapErrorWithAdditionalContext } from '@/application/Parser/Common/ContextualError';
describe('wrapErrorWithAdditionalContext', () => {
it('preserves the original error when wrapped', () => {
// arrange
const expectedError = new Error();
const context = new TestContext()
.withError(expectedError);
// act
const error = context.wrap();
// assert
const actualError = extractInnerErrorFromContextualError(error);
expect(actualError).to.equal(expectedError);
});
it('maintains the original error when re-wrapped', () => {
// arrange
const expectedError = new Error();
// act
const firstError = new TestContext()
.withError(expectedError)
.withAdditionalContext('first error')
.wrap();
const secondError = new TestContext()
.withError(firstError)
.withAdditionalContext('second error')
.wrap();
// assert
const actualError = extractInnerErrorFromContextualError(secondError);
expect(actualError).to.equal(expectedError);
});
it(`the object extends ${CustomError.name}`, () => {
// arrange
const expected = CustomError;
// act
const error = new TestContext()
.wrap();
// assert
expect(error).to.be.an.instanceof(expected);
});
describe('error message construction', () => {
it('includes the message from the original error', () => {
// arrange
const expectedOriginalErrorMessage = 'Message from the inner error';
// act
const error = new TestContext()
.withError(new Error(expectedOriginalErrorMessage))
.wrap();
// assert
expect(error.message).contains(expectedOriginalErrorMessage);
});
it('appends provided additional context to the error message', () => {
// arrange
const expectedAdditionalContext = 'Expected additional context message';
// act
const error = new TestContext()
.withAdditionalContext(expectedAdditionalContext)
.wrap();
// assert
expect(error.message).contains(expectedAdditionalContext);
});
it('appends multiple contexts to the error message in sequential order', () => {
// arrange
const expectedFirstContext = 'First context';
const expectedSecondContext = 'Second context';
// act
const firstError = new TestContext()
.withAdditionalContext(expectedFirstContext)
.wrap();
const secondError = new TestContext()
.withError(firstError)
.withAdditionalContext(expectedSecondContext)
.wrap();
// assert
const messageLines = secondError.message.split('\n');
expect(messageLines).to.contain(`1: ${expectedFirstContext}`);
expect(messageLines).to.contain(`2: ${expectedSecondContext}`);
});
});
});
class TestContext {
private error: Error = new Error();
private additionalContext = `[${TestContext.name}] additional context`;
public withError(error: Error) {
this.error = error;
return this;
}
public withAdditionalContext(additionalContext: string) {
this.additionalContext = additionalContext;
return this;
}
public wrap(): ReturnType<typeof wrapErrorWithAdditionalContext> {
return wrapErrorWithAdditionalContext(
this.error,
this.additionalContext,
);
}
}
function extractInnerErrorFromContextualError(error: Error): Error {
const innerErrorProperty = 'innerError';
if (!(innerErrorProperty in error)) {
throw new Error(`${innerErrorProperty} property is missing`);
}
const actualError = error[innerErrorProperty];
return actualError as Error;
}

View File

@@ -0,0 +1,53 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
interface ContextualErrorTestScenario {
readonly throwingAction: (wrapError: ErrorWithContextWrapper) => void;
readonly expectedWrappedError: Error;
readonly expectedContextMessage: string;
}
export function itThrowsContextualError(
testScenario: ContextualErrorTestScenario,
) {
it('throws wrapped error', () => {
// arrange
const expectedError = new Error();
const wrapperStub = new ErrorWrapperStub()
.withError(expectedError);
// act
const act = () => testScenario.throwingAction(wrapperStub.get());
// assert
expect(act).to.throw(expectedError);
});
it('wraps internal error', () => {
// arrange
const expectedInternalError = testScenario.expectedWrappedError;
const wrapperStub = new ErrorWrapperStub();
// act
try {
testScenario.throwingAction(wrapperStub.get());
} catch { /* Swallow */ }
// assert
expect(wrapperStub.lastError).to.deep.equal(expectedInternalError);
});
it('includes expected context', () => {
// arrange
const { expectedContextMessage: expectedContext } = testScenario;
const wrapperStub = new ErrorWrapperStub();
// act
try {
testScenario.throwingAction(wrapperStub.get());
} catch { /* Swallow */ }
// assert
expectExists(wrapperStub.lastContext);
expect(wrapperStub.lastContext).to.equal(expectedContext, formatAssertionMessage([
'Unexpected additional context (additional message added to the wrapped error).',
`Actual additional context:\n${indentText(wrapperStub.lastContext)}`,
`Expected additional context:\n${indentText(expectedContext)}`,
]));
});
}

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import { createTypeValidator } from '@/application/Parser/Common/TypeValidator';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
describe('createTypeValidator', () => {
describe('assertObject', () => {
describe('with valid object', () => {
it('accepts object with allowed properties', () => {
// arrange
const expectedProperties = ['expected1', 'expected2'];
const validValue = createObjectWithProperties(expectedProperties);
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({
value: validValue,
valueName: 'unimportant name',
allowedProperties: expectedProperties,
});
// assert
expect(act).to.not.throw();
});
it('accepts object with extra unspecified properties', () => {
// arrange
const validValue = createObjectWithProperties(['unevaluated property']);
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({
value: validValue,
valueName: 'unimportant name',
});
// assert
expect(act).to.not.throw();
});
});
describe('with invalid object', () => {
describe('throws error for missing object', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const valueName = 'absent object value';
const expectedMessage = `'${valueName}' is missing.`;
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({ value: absentValue, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
});
it('throws error for object without properties', () => {
// arrange
const emptyObjectValue: object = {};
const valueName = 'empty object without properties.';
const expectedMessage = `'${valueName}' is an empty object without properties.`;
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({ value: emptyObjectValue, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
describe('incorrect data type', () => {
// arrange
const testScenarios: readonly {
readonly value: unknown;
readonly valueName: string;
}[] = [
{
value: ['1', '2'],
valueName: 'array of strings',
},
{
value: true,
valueName: 'true boolean',
},
{
value: 35,
valueName: 'number',
},
];
testScenarios.forEach(({ value, valueName }) => {
it(`throws error for ${valueName} passed as object`, () => {
// arrange
const expectedMessage = `'${valueName}' is not an object.`;
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({ value, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
});
});
it('throws error for object with disallowed properties', () => {
// arrange
const valueName = 'value with unexpected properties';
const unexpectedProperties = ['unexpected-property-1', 'unexpected-property-2'];
const expectedError = `'${valueName}' has disallowed properties: ${unexpectedProperties.join(', ')}.`;
const expectedProperties = ['expected1', 'expected2'];
const value = createObjectWithProperties(
[...expectedProperties, ...unexpectedProperties],
);
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({
value,
valueName,
allowedProperties: expectedProperties,
});
// assert
expect(act).to.throw(expectedError);
});
});
});
describe('assertNonEmptyCollection', () => {
describe('with valid collection', () => {
it('accepts non-empty collection', () => {
// arrange
const validValue = ['array', 'of', 'strings'];
const { assertNonEmptyCollection } = createTypeValidator();
// act
const act = () => assertNonEmptyCollection({ value: validValue, valueName: 'unimportant name' });
// assert
expect(act).to.not.throw();
});
});
describe('with invalid collection', () => {
describe('throws error for missing collection', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const valueName = 'absent collection value';
const expectedMessage = `'${valueName}' is missing.`;
const { assertNonEmptyCollection } = createTypeValidator();
// act
const act = () => assertNonEmptyCollection({ value: absentValue, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
});
it('throws error for empty collection', () => {
// arrange
const emptyArrayValue = [];
const valueName = 'empty collection value';
const expectedMessage = `'${valueName}' cannot be an empty array.`;
const { assertNonEmptyCollection } = createTypeValidator();
// act
const act = () => assertNonEmptyCollection({ value: emptyArrayValue, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
});
});
});
function createObjectWithProperties(properties: readonly string[]): object {
const object = {};
properties.forEach((propertyName) => {
object[propertyName] = 'arbitrary value';
});
return object;
}