diff --git a/apps/server/src/shared/common/guards/index.ts b/apps/server/src/shared/common/guards/index.ts index 077ed22f605..150be43573f 100644 --- a/apps/server/src/shared/common/guards/index.ts +++ b/apps/server/src/shared/common/guards/index.ts @@ -1,4 +1,4 @@ -export { TypeGuard } from './type.guard'; +export { TypeGuard, PrimitiveType, PrimitiveTypeArray, ObjectType } from './type.guard'; // Guards at different places exists insdide the modules, as validation as utils. // Please consolidate it and make it explicit as guard. diff --git a/apps/server/src/shared/common/guards/type.guard.ts b/apps/server/src/shared/common/guards/type.guard.ts index b850095568f..ce4a35e8aa4 100644 --- a/apps/server/src/shared/common/guards/type.guard.ts +++ b/apps/server/src/shared/common/guards/type.guard.ts @@ -1,3 +1,12 @@ +import { shallowEqual, shallowEqualArrays, shallowEqualObjects } from 'shallow-equal'; + +export type PrimitiveType = number | string | boolean | undefined | symbol | bigint; + +/** Note that undefined is also added to match our usage. */ +export type ObjectType = Record | unknown[] | null | undefined; + +export type PrimitiveTypeArray = PrimitiveType[]; + export class TypeGuard { static isError(value: unknown): value is Error { const isError = value instanceof Error; @@ -150,4 +159,49 @@ export class TypeGuard { return value; } + + static isBoolean(value: unknown): boolean { + const isBoolean = typeof value === 'boolean'; + + return isBoolean; + } + + static isBigInt(value: unknown): boolean { + const isBigInt = typeof value === 'bigint'; + + return isBigInt; + } + + static isSymbol(value: unknown): boolean { + const isSymbol = typeof value === 'symbol'; + + return isSymbol; + } + + static isPrimitiveType(value: unknown): boolean { + const isPrimitiveType = + TypeGuard.isNumber(value) || + TypeGuard.isString(value) || + TypeGuard.isBoolean(value) || + TypeGuard.isUndefined(value) || + TypeGuard.isSymbol(value) || + TypeGuard.isBigInt(value); + + return isPrimitiveType; + } + + static isSameClassTyp(value1: unknown, value2: unknown): boolean { + const isSameClassTyp = + TypeGuard.isDefinedObject(value1) && + TypeGuard.isDefinedObject(value2) && + value1.constructor.name === value2.constructor.name; + + return isSameClassTyp; + } + + static isShallowEqualArray = shallowEqualArrays; + + static isShallowEqualObject = shallowEqualObjects; + + static shallowEqualObjectOrArray = shallowEqual; } diff --git a/apps/server/src/shared/domain/value-object.spec.ts b/apps/server/src/shared/domain/value-object.spec.ts new file mode 100644 index 00000000000..040097bebdc --- /dev/null +++ b/apps/server/src/shared/domain/value-object.spec.ts @@ -0,0 +1,187 @@ +import { TypeGuard } from '@shared/common'; +import { ValueObject } from './value-object'; + +describe('ValueObject', () => { + describe('By passing valid values', () => { + const setup = () => { + class Name extends ValueObject {} + + const nameThor1 = new Name('Thor'); + const nameThor2 = new Name('Thor'); + + return { nameThor1, nameThor2 }; + }; + + it('should be return true by same reference', () => { + const { nameThor1 } = setup(); + + expect(nameThor1.equals(nameThor1)).toBe(true); + }); + + it('should be return true by different references', () => { + const { nameThor1, nameThor2 } = setup(); + + expect(nameThor1.equals(nameThor2)).toBe(true); + }); + }); + + describe('By usage of primitive array as value object', () => { + const setup = () => { + class Names extends ValueObject {} + + const nameThors1 = new Names(['Thor1', 'Thor2', 'Thor3']); + const nameThors2 = new Names(['Thor1', 'Thor2', 'Thor3']); + const nameThorsDifferent = new Names(['Thor1', 'Thor2', 'Thor4']); + const nameThorsDifferentCount = new Names(['Thor1', 'Thor2']); + + return { nameThors1, nameThors2, nameThorsDifferent, nameThorsDifferentCount }; + }; + + it('should be return true by same reference', () => { + const { nameThors1 } = setup(); + + expect(nameThors1.equals(nameThors1)).toBe(true); + }); + + it('should be return true by different references', () => { + const { nameThors1, nameThors2 } = setup(); + + expect(nameThors1.equals(nameThors2)).toBe(true); + }); + + it('should be return false by different values inside the array', () => { + const { nameThors1, nameThorsDifferentCount } = setup(); + + expect(nameThors1.equals(nameThorsDifferentCount)).toBe(false); + }); + + it('should be return false by different values inside the array', () => { + const { nameThors1, nameThorsDifferent } = setup(); + + expect(nameThors1.equals(nameThorsDifferent)).toBe(false); + }); + }); + + describe('When value object with validation is created.', () => { + const setup = () => { + class Name extends ValueObject { + protected validation(value: unknown): boolean { + let isValid = false; + + if (TypeGuard.isString(value) && this.isValidLength(value)) { + isValid = true; + } + + return isValid; + } + + private isValidLength(value: string): boolean { + let isValid = false; + + if (value.length > 0 && value.length <= 5) { + isValid = true; + } + + return isValid; + } + } + + return { Name }; + }; + + it('validation should be execute and throw if not valid', () => { + const { Name } = setup(); + + const expectedError = new Error('ValueObject Name validation is failed for input ""'); + expect(() => new Name('')).toThrowError(expectedError); + }); + + it('should execute but not throw if is valid value is passsed', () => { + const { Name } = setup(); + + expect(new Name('Thor')).toBeDefined(); + }); + /* + it('should execute but not throw if is valid value is passsed', () => { + const { Name } = setup(); + + const myName = new Name('Thor'); + + expect(myName.isValidValue('123456')).toBe(false); + expect(myName.isValidValue('')).toBe(false); + expect(myName.isValidValue('ABC')).toBe(true); + }); + */ + }); + + describe('When value object with modification is created.', () => { + const setup = () => { + class Name extends ValueObject { + protected modified(value: string): string { + let modifedValue = value; + + modifedValue = this.removeWithspacesAtBeginning(value); + modifedValue = this.truncatedToLength4(modifedValue); + + return modifedValue; + } + + private removeWithspacesAtBeginning(value: string): string { + return value.trimStart(); + } + + private truncatedToLength4(value: string): string { + return value.substring(0, 4); + } + } + + return { Name }; + }; + + it('should be execute all modifications by creating a new instance', () => { + const { Name } = setup(); + + const myName = new Name(' Thor the God of Thunder!'); + + expect(myName.value).toEqual('Thor'); + }); + }); + + describe('By passing invalid values', () => { + const setup = () => { + class Name extends ValueObject {} + class OtherString extends ValueObject {} + // class InvalidType extends ValueObject<{}> {} --> object and functions do not work + // TODO: Test for array + + const nameThor1 = new Name('Thor'); + const nameUnhappy = new Name('Unhappy'); + const otherThor = new OtherString('Thor'); + const fakeThor = { + value: 'Thor', + equals: (vo: ValueObject): boolean => vo === this, + }; + + return { nameThor1, nameUnhappy, otherThor, fakeThor }; + }; + + it('should be return false by different value', () => { + const { nameThor1, nameUnhappy } = setup(); + + expect(nameThor1.equals(nameUnhappy)).toBe(false); + }); + + it('should be return false if value object is passed ', () => { + const { nameThor1, fakeThor } = setup(); + + // @ts-expect-error Test case + expect(nameThor1.equals(fakeThor)).toBe(false); + }); + + it('should be return false by different ValueObjects and same value', () => { + const { nameThor1, otherThor } = setup(); + + expect(nameThor1.equals(otherThor)).toBe(false); + }); + }); +}); diff --git a/apps/server/src/shared/domain/value-object.ts b/apps/server/src/shared/domain/value-object.ts new file mode 100644 index 00000000000..aa6f0650a3e --- /dev/null +++ b/apps/server/src/shared/domain/value-object.ts @@ -0,0 +1,73 @@ +import { TypeGuard, PrimitiveType, PrimitiveTypeArray } from '@shared/common'; + +type ValueObjectTyp = PrimitiveType | PrimitiveTypeArray; + +export abstract class ValueObject { + public readonly value: T; + + constructor(value: T) { + // TODO: No Test for the execution order exists for now, but we must clarify if we want first the modifcation, or first the validation + // For operations with truncat before make more sense. Adding before/after modifications are also possible, but it can be overload the interface + const modifiedValue = this.modified(value); + this.checkValue(modifiedValue); + this.value = Object.freeze(modifiedValue); + } + + /** Use this method with override for add validations. */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected validation(value: unknown): boolean { + return true; + } + + /** Use this method with override for add modifications, before execute the validation. */ + protected modified(value: T): T { + // TODO: Why eslint think that T is from type any is unlear for me. + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value; + } + + // TODO: make this method sense if we not can add this as static MyValueObject.isValidValue() ? + // Should it be optional by adding in extended class, or as abstract that it must be implemented? + /* + public isValidValue(value: unknown): boolean { + return this.validation(value); + } + */ + // TODO: Same questions make it sense without static to set it public? + // If not than we can change it to private for a less overloaded interface at the value object + private checkValue(value: unknown): void { + if (!this.validation(value)) { + throw new Error(`ValueObject ${this.constructor.name} validation is failed for input ${JSON.stringify(value)}`); + } + } + + /** The equal methode of ValueObjects check the value is equal not the reference. */ + public equals(vo: ValueObject): boolean { + if (!TypeGuard.isSameClassTyp(this, vo)) { + return false; + } + + if (TypeGuard.isPrimitiveType(vo.value)) { + return vo.value === this.value; + } + + if (TypeGuard.isArray(vo.value) && TypeGuard.isArray(this.value)) { + return TypeGuard.isShallowEqualArray(this.value, vo.value); + } + + return false; + + /* + VS + + + let isEqual = false; + if (TypeGuard.isPrimitiveType(vo.value)) { + isEqual = vo.value === this.value; + } else if (TypeGuard.isArray(vo.value) && TypeGuard.isArray(this.value)) { + isEqual = TypeGuard.isShallowEqualArray(this.value, vo.value); + } + + return isEqual; */ + } +} diff --git a/package-lock.json b/package-lock.json index 79952875444..8c904a151dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -131,6 +131,7 @@ "sanitize-html": "^2.13.0", "serve-favicon": "^2.3.2", "service": "^0.1.4", + "shallow-equal": "^3.1.0", "socket.io": "^4.7.5", "socketio-file-upload": "^0.7.0", "source-map-support": "^0.5.19", @@ -23623,6 +23624,11 @@ "version": "0.0.1", "license": "MIT" }, + "node_modules/shallow-equal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", + "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==" + }, "node_modules/shebang-command": { "version": "2.0.0", "license": "MIT", diff --git a/package.json b/package.json index c71342f0d6d..230fe6b0a55 100644 --- a/package.json +++ b/package.json @@ -247,6 +247,7 @@ "sanitize-html": "^2.13.0", "serve-favicon": "^2.3.2", "service": "^0.1.4", + "shallow-equal": "^3.1.0", "socket.io": "^4.7.5", "socketio-file-upload": "^0.7.0", "source-map-support": "^0.5.19",