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

@@ -1,119 +1,313 @@
import { describe, it, expect } from 'vitest';
import type { IEntity } from '@/infrastructure/Entity/IEntity';
import { parseCategoryCollection } from '@/application/Parser/CategoryCollectionParser';
import { parseCategory } from '@/application/Parser/Executable/CategoryParser';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import { parseCategoryCollection, type CategoryCollectionFactory } from '@/application/Parser/CategoryCollectionParser';
import { type CategoryParser } from '@/application/Parser/Executable/CategoryParser';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser';
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub';
import { getCategoryStub, CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub';
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { CategoryData } from '@/application/collections/';
import type { CollectionData, ScriptingDefinitionData, FunctionData } from '@/application/collections/';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import type { NonEmptyCollectionAssertion, ObjectAssertion, TypeValidator } from '@/application/Parser/Common/TypeValidator';
import type { EnumParser } from '@/application/Common/Enum';
import type { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
import type { CategoryCollectionSpecificUtilitiesFactory } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities';
import { ScriptingDefinitionDataStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionDataStub';
import { CategoryParserStub } from '@tests/unit/shared/Stubs/CategoryParserStub';
import { createCategoryCollectionFactorySpy } from '@tests/unit/shared/Stubs/CategoryCollectionFactoryStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
describe('CategoryCollectionParser', () => {
describe('parseCategoryCollection', () => {
it('validates object', () => {
// arrange
const data = new CollectionDataStub();
const expectedAssertion: ObjectAssertion<CollectionData> = {
value: data,
valueName: 'collection',
allowedProperties: [
'os', 'scripting', 'actions', 'functions',
],
};
const validator = new TypeValidatorStub();
const context = new TestContext()
.withData(data)
.withTypeValidator(validator);
// act
context.parseCategoryCollection();
// assert
validator.expectObjectAssertion(expectedAssertion);
});
describe('actions', () => {
describe('throws with absent actions', () => {
itEachAbsentCollectionValue<CategoryData>((absentValue) => {
// arrange
const expectedError = 'content does not define any action';
const collection = new CollectionDataStub()
.withActions(absentValue);
const projectDetails = new ProjectDetailsStub();
// act
const act = () => parseCategoryCollection(collection, projectDetails);
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true, excludeNull: true });
});
it('parses actions', () => {
it('validates non-empty collection', () => {
// arrange
const actions = [getCategoryStub('test1'), getCategoryStub('test2')];
const context = new CategoryCollectionSpecificUtilitiesStub();
const expected = [parseCategory(actions[0], context), parseCategory(actions[1], context)];
const collection = new CollectionDataStub()
.withActions(actions);
const projectDetails = new ProjectDetailsStub();
const expectedAssertion: NonEmptyCollectionAssertion = {
value: actions,
valueName: '"actions" in collection',
};
const validator = new TypeValidatorStub();
const context = new TestContext()
.withData(new CollectionDataStub().withActions(actions))
.withTypeValidator(validator);
// act
const actual = parseCategoryCollection(collection, projectDetails).actions;
context.parseCategoryCollection();
// assert
expect(excludingId(actual)).to.be.deep.equal(excludingId(expected));
function excludingId<TId>(array: ReadonlyArray<IEntity<TId>>) {
return array.map((obj) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id: omitted, ...rest } = obj;
return rest;
validator.expectNonEmptyCollectionAssertion(expectedAssertion);
});
it('parses actions correctly', () => {
// arrange
const {
categoryCollectionFactorySpy,
getInitParameters,
} = createCategoryCollectionFactorySpy();
const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')];
const expectedActions = [new CategoryStub(1), new CategoryStub(2)];
const categoryParserStub = new CategoryParserStub()
.withConfiguredParseResult(actionsData[0], expectedActions[0])
.withConfiguredParseResult(actionsData[1], expectedActions[1]);
const collectionData = new CollectionDataStub()
.withActions(actionsData);
const context = new TestContext()
.withData(collectionData)
.withCategoryParser(categoryParserStub.get())
.withCategoryCollectionFactory(categoryCollectionFactorySpy);
// act
const actualCollection = context.parseCategoryCollection();
// assert
const actualActions = getInitParameters(actualCollection)?.actions;
expect(actualActions).to.have.lengthOf(expectedActions.length);
expect(actualActions).to.have.members(expectedActions);
});
describe('utilities', () => {
it('parses actions with correct utilities', () => {
// arrange
const expectedUtilities = new CategoryCollectionSpecificUtilitiesStub();
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = () => {
return expectedUtilities;
};
const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')];
const collectionData = new CollectionDataStub()
.withActions(actionsData);
const categoryParserStub = new CategoryParserStub();
const context = new TestContext()
.withData(collectionData)
.withCollectionUtilitiesFactory(utilitiesFactory)
.withCategoryParser(categoryParserStub.get());
// act
context.parseCategoryCollection();
// assert
const usedUtilities = categoryParserStub.getUsedUtilities();
expect(usedUtilities).to.have.lengthOf(2);
expect(usedUtilities[0]).to.equal(expectedUtilities);
expect(usedUtilities[1]).to.equal(expectedUtilities);
});
describe('construction', () => {
it('creates utilities with correct functions data', () => {
// arrange
const expectedFunctionsData = [createFunctionDataWithCode()];
const collectionData = new CollectionDataStub()
.withFunctions(expectedFunctionsData);
let actualFunctionsData: ReadonlyArray<FunctionData> | undefined;
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = (data) => {
actualFunctionsData = data;
return new CategoryCollectionSpecificUtilitiesStub();
};
const context = new TestContext()
.withData(collectionData)
.withCollectionUtilitiesFactory(utilitiesFactory);
// act
context.parseCategoryCollection();
// assert
expect(actualFunctionsData).to.equal(expectedFunctionsData);
});
}
it('creates utilities with correct scripting definition', () => {
// arrange
const expectedScripting = new ScriptingDefinitionStub();
const scriptingDefinitionParser: ScriptingDefinitionParser = () => {
return expectedScripting;
};
let actualScripting: IScriptingDefinition | undefined;
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = (_, scripting) => {
actualScripting = scripting;
return new CategoryCollectionSpecificUtilitiesStub();
};
const context = new TestContext()
.withCollectionUtilitiesFactory(utilitiesFactory)
.withScriptDefinitionParser(scriptingDefinitionParser);
// act
context.parseCategoryCollection();
// assert
expect(actualScripting).to.equal(expectedScripting);
});
});
});
});
describe('scripting definition', () => {
it('parses scripting definition as expected', () => {
it('parses correctly', () => {
// arrange
const collection = new CollectionDataStub();
const projectDetails = new ProjectDetailsStub();
const expected = new ScriptingDefinitionParser()
.parse(collection.scripting, projectDetails);
const {
categoryCollectionFactorySpy,
getInitParameters,
} = createCategoryCollectionFactorySpy();
const expected = new ScriptingDefinitionStub();
const scriptingDefinitionParser: ScriptingDefinitionParser = () => {
return expected;
};
const context = new TestContext()
.withCategoryCollectionFactory(categoryCollectionFactorySpy)
.withScriptDefinitionParser(scriptingDefinitionParser);
// act
const actual = parseCategoryCollection(collection, projectDetails).scripting;
const actualCategoryCollection = context.parseCategoryCollection();
// assert
expect(expected).to.deep.equal(actual);
const actualScripting = getInitParameters(actualCategoryCollection)?.scripting;
expect(expected).to.equal(actualScripting);
});
it('parses expected data', () => {
// arrange
const expectedData = new ScriptingDefinitionDataStub();
const collection = new CollectionDataStub()
.withScripting(expectedData);
let actualData: ScriptingDefinitionData | undefined;
const scriptingDefinitionParser
: ScriptingDefinitionParser = (data: ScriptingDefinitionData) => {
actualData = data;
return new ScriptingDefinitionStub();
};
const context = new TestContext()
.withScriptDefinitionParser(scriptingDefinitionParser)
.withData(collection);
// act
context.parseCategoryCollection();
// assert
expect(actualData).to.equal(expectedData);
});
it('parses with correct project details', () => {
// arrange
const expectedProjectDetails = new ProjectDetailsStub();
let actualDetails: ProjectDetails | undefined;
const scriptingDefinitionParser
: ScriptingDefinitionParser = (_, details: ProjectDetails) => {
actualDetails = details;
return new ScriptingDefinitionStub();
};
const context = new TestContext()
.withProjectDetails(expectedProjectDetails)
.withScriptDefinitionParser(scriptingDefinitionParser);
// act
context.parseCategoryCollection();
// assert
expect(actualDetails).to.equal(expectedProjectDetails);
});
});
describe('os', () => {
it('parses as expected', () => {
it('parses correctly', () => {
// arrange
const {
categoryCollectionFactorySpy,
getInitParameters,
} = createCategoryCollectionFactorySpy();
const expectedOs = OperatingSystem.macOS;
const osText = 'macos';
const expectedName = 'os';
const collection = new CollectionDataStub()
const collectionData = new CollectionDataStub()
.withOs(osText);
const parserMock = new EnumParserStub<OperatingSystem>()
.setup(expectedName, osText, expectedOs);
const projectDetails = new ProjectDetailsStub();
const context = new TestContext()
.withOsParser(parserMock)
.withCategoryCollectionFactory(categoryCollectionFactorySpy)
.withData(collectionData);
// act
const actual = parseCategoryCollection(collection, projectDetails, parserMock);
const actualCollection = context.parseCategoryCollection();
// assert
expect(actual.os).to.equal(expectedOs);
});
});
describe('functions', () => {
it('compiles script call with given function', () => {
// arrange
const expectedCode = 'code-from-the-function';
const functionName = 'function-name';
const scriptName = 'script-name';
const script = createScriptDataWithCall()
.withCall(new FunctionCallDataStub().withName(functionName).withParameters({}))
.withName(scriptName);
const func = createFunctionDataWithCode()
.withParametersObject([])
.withName(functionName)
.withCode(expectedCode);
const category = new CategoryDataStub()
.withChildren([script,
createScriptDataWithCode().withName('2')
.withRecommendationLevel(RecommendationLevel.Standard),
createScriptDataWithCode()
.withName('3').withRecommendationLevel(RecommendationLevel.Strict),
]);
const collection = new CollectionDataStub()
.withActions([category])
.withFunctions([func]);
const projectDetails = new ProjectDetailsStub();
// act
const actual = parseCategoryCollection(collection, projectDetails);
// assert
const actualScript = actual.getScript(scriptName);
const actualCode = actualScript.code.execute;
expect(actualCode).to.equal(expectedCode);
const actualOs = getInitParameters(actualCollection)?.os;
expect(actualOs).to.equal(expectedOs);
});
});
});
});
class TestContext {
private data: CollectionData = new CollectionDataStub();
private projectDetails: ProjectDetails = new ProjectDetailsStub();
private validator: TypeValidator = new TypeValidatorStub();
private osParser: EnumParser<OperatingSystem> = new EnumParserStub<OperatingSystem>()
.setupDefaultValue(OperatingSystem.Android);
private collectionUtilitiesFactory
: CategoryCollectionSpecificUtilitiesFactory = () => {
return new CategoryCollectionSpecificUtilitiesStub();
};
private scriptDefinitionParser: ScriptingDefinitionParser = () => new ScriptingDefinitionStub();
private categoryParser: CategoryParser = new CategoryParserStub().get();
private categoryCollectionFactory
: CategoryCollectionFactory = createCategoryCollectionFactorySpy().categoryCollectionFactorySpy;
public withData(data: CollectionData): this {
this.data = data;
return this;
}
public withCategoryParser(categoryParser: CategoryParser): this {
this.categoryParser = categoryParser;
return this;
}
public withCategoryCollectionFactory(categoryCollectionFactory: CategoryCollectionFactory): this {
this.categoryCollectionFactory = categoryCollectionFactory;
return this;
}
public withProjectDetails(projectDetails: ProjectDetails): this {
this.projectDetails = projectDetails;
return this;
}
public withOsParser(osParser: EnumParser<OperatingSystem>): this {
this.osParser = osParser;
return this;
}
public withScriptDefinitionParser(scriptDefinitionParser: ScriptingDefinitionParser): this {
this.scriptDefinitionParser = scriptDefinitionParser;
return this;
}
public withTypeValidator(typeValidator: TypeValidator): this {
this.validator = typeValidator;
return this;
}
public withCollectionUtilitiesFactory(
collectionUtilitiesFactory: CategoryCollectionSpecificUtilitiesFactory,
): this {
this.collectionUtilitiesFactory = collectionUtilitiesFactory;
return this;
}
public parseCategoryCollection(): ReturnType<typeof parseCategoryCollection> {
return parseCategoryCollection(
this.data,
this.projectDetails,
{
osParser: this.osParser,
validator: this.validator,
parseScriptingDefinition: this.scriptDefinitionParser,
createUtilities: this.collectionUtilitiesFactory,
parseCategory: this.categoryParser,
createCategoryCollection: this.categoryCollectionFactory,
},
);
}
}