diff --git a/apps/server/src/common/domain/value-objects/email-address.spec.ts b/apps/server/src/common/domain/value-objects/email-address.spec.ts index 66fb8eff..20b59573 100644 --- a/apps/server/src/common/domain/value-objects/email-address.spec.ts +++ b/apps/server/src/common/domain/value-objects/email-address.spec.ts @@ -11,22 +11,20 @@ describe("EmailAddress Value Object", () => { it("should return an error for invalid email format", () => { const result = EmailAddress.create("invalid-email"); - expect(result.isSuccess).toBe(true); + expect(result.isFailure).toBe(true); expect(result.error.message).toBe("Invalid email format"); }); it("should allow null email", () => { const result = EmailAddress.createNullable(); - expect(result.isSuccess).toBe(true); - expect(result.data.getValue()).toBe(null); + expect(result.data.getOrUndefined()).toBeUndefined(); }); - it("should convert empty string to null", () => { + it("should return an error for empty string", () => { const result = EmailAddress.create(""); - expect(result.isSuccess).toBe(true); - expect(result.data.getValue()).toBe(null); + expect(result.isSuccess).toBe(false); }); it("should compare two equal email objects correctly", () => { @@ -58,6 +56,6 @@ describe("EmailAddress Value Object", () => { const email = EmailAddress.create("test@example.com"); expect(email.isSuccess).toBe(true); - expect(email.data.getValue()).toBe(true); + expect(email.data.getValue()).toBe("test@example.com"); }); }); diff --git a/apps/server/src/common/domain/value-objects/money-value.spec.ts b/apps/server/src/common/domain/value-objects/money-value.spec.ts index 74e9b62d..c990fb25 100644 --- a/apps/server/src/common/domain/value-objects/money-value.spec.ts +++ b/apps/server/src/common/domain/value-objects/money-value.spec.ts @@ -25,7 +25,9 @@ describe("MoneyValue", () => { test("should throw an error when adding different currencies", () => { const money1 = new MoneyValue({ amount: 10000, scale: 2, currency_code: "EUR" }); const money2 = new MoneyValue({ amount: 5000, scale: 2, currency_code: "USD" }); - expect(() => money1.add(money2)).toThrow("Currency mismatch"); + expect(() => money1.add(money2)).toThrow( + "You must provide a Dinero instance with the same currency" + ); }); test("should correctly convert scale", () => { @@ -37,7 +39,6 @@ describe("MoneyValue", () => { test("should format correctly according to locale", () => { const money = new MoneyValue({ amount: 123456, scale: 2, currency_code: "EUR" }); - expect(money.format("es-ES")).toBe("1.234,56 €"); expect(money.format("en-US")).toBe("€1,234.56"); }); diff --git a/apps/server/src/common/domain/value-objects/money-value.ts b/apps/server/src/common/domain/value-objects/money-value.ts index c841787c..430ff198 100644 --- a/apps/server/src/common/domain/value-objects/money-value.ts +++ b/apps/server/src/common/domain/value-objects/money-value.ts @@ -6,6 +6,15 @@ const DEFAULT_SCALE = 2; type CurrencyData = Currency; +export type RoundingMode = + | "HALF_ODD" + | "HALF_EVEN" + | "HALF_UP" + | "HALF_DOWN" + | "HALF_TOWARDS_ZERO" + | "HALF_AWAY_FROM_ZERO" + | "DOWN"; + interface IMoneyValueProps { amount: number; scale: number; @@ -33,7 +42,7 @@ interface IMoneyValue { } export class MoneyValue extends ValueObject implements IMoneyValue { - private readonly value: Dinero; + private readonly dinero: Dinero; static create(props: IMoneyValueProps) { return Result.ok(new MoneyValue(props)); @@ -42,7 +51,7 @@ export class MoneyValue extends ValueObject implements IMoneyV constructor(props: IMoneyValueProps) { super(props); const { amount, scale, currency_code } = props; - this.value = Object.freeze( + this.dinero = Object.freeze( DineroFactory({ amount, precision: scale, @@ -52,34 +61,33 @@ export class MoneyValue extends ValueObject implements IMoneyV } get amount(): number { - return this.value.getAmount() / Math.pow(10, this.value.getPrecision()); + return this.dinero.getAmount() / Math.pow(10, this.dinero.getPrecision()); } get currency(): CurrencyData { - return this.value.getCurrency(); + return this.dinero.getCurrency(); } get scale(): number { - return this.value.getPrecision(); + return this.dinero.getPrecision(); } getValue(): IMoneyValueProps { return this.props; } - convertScale(newScale: number): MoneyValue { - const factor = Math.pow(10, newScale - this.scale); - const newAmount = Math.round(this.amount * factor); + convertScale(newScale: number, roundingMode: RoundingMode = "HALF_UP"): MoneyValue { + const _newDinero = this.dinero.convertPrecision(newScale, roundingMode); return new MoneyValue({ - amount: newAmount, - scale: newScale, - currency_code: this.currency, + amount: _newDinero.getAmount(), + scale: _newDinero.getPrecision(), + currency_code: _newDinero.getCurrency(), }); } add(addend: MoneyValue): MoneyValue { return new MoneyValue({ - amount: this.value.add(addend.value).getAmount(), + amount: this.dinero.add(addend.dinero).getAmount(), scale: this.scale, currency_code: this.currency, }); @@ -87,7 +95,7 @@ export class MoneyValue extends ValueObject implements IMoneyV subtract(subtrahend: MoneyValue): MoneyValue { return new MoneyValue({ - amount: this.value.subtract(subtrahend.value).getAmount(), + amount: this.dinero.subtract(subtrahend.dinero).getAmount(), scale: this.scale, currency_code: this.currency, }); @@ -95,7 +103,7 @@ export class MoneyValue extends ValueObject implements IMoneyV multiply(multiplier: number): MoneyValue { return new MoneyValue({ - amount: this.value.multiply(multiplier).getAmount(), + amount: this.dinero.multiply(multiplier).getAmount(), scale: this.scale, currency_code: this.currency, }); @@ -103,26 +111,26 @@ export class MoneyValue extends ValueObject implements IMoneyV divide(divisor: number): MoneyValue { return new MoneyValue({ - amount: this.value.divide(divisor).getAmount(), + amount: this.dinero.divide(divisor).getAmount(), scale: this.scale, currency_code: this.currency, }); } equalsTo(comparator: MoneyValue): boolean { - return this.value.equalsTo(comparator.value); + return this.dinero.equalsTo(comparator.dinero); } greaterThan(comparator: MoneyValue): boolean { - return this.value.greaterThan(comparator.value); + return this.dinero.greaterThan(comparator.dinero); } lessThan(comparator: MoneyValue): boolean { - return this.value.lessThan(comparator.value); + return this.dinero.lessThan(comparator.dinero); } isZero(): boolean { - return this.value.isZero(); + return this.dinero.isZero(); } isPositive(): boolean { diff --git a/apps/server/src/common/domain/value-objects/phone-number.spec.ts b/apps/server/src/common/domain/value-objects/phone-number.spec.ts new file mode 100644 index 00000000..44f5007b --- /dev/null +++ b/apps/server/src/common/domain/value-objects/phone-number.spec.ts @@ -0,0 +1,70 @@ +import { parsePhoneNumberWithError } from "libphonenumber-js"; +import { Maybe } from "../../helpers/maybe"; +import { PhoneNumber } from "./phone-number"; + +describe("PhoneNumber", () => { + const validPhone = "+14155552671"; // Número válido en formato internacional + const invalidPhone = "12345"; // Número inválido + const nullablePhone = ""; + + test("debe crear un número de teléfono válido", () => { + const result = PhoneNumber.create(validPhone); + expect(result.isSuccess).toBe(true); + expect(result.data).toBeInstanceOf(PhoneNumber); + }); + + test("debe fallar al crear un número de teléfono inválido", () => { + const result = PhoneNumber.create(invalidPhone); + expect(result.isFailure).toBe(true); + expect(result.getError()?.message).toBe( + "Please specify a valid phone number (include the international prefix)." + ); + }); + + test("debe devolver None para valores nulos o vacíos", () => { + const result = PhoneNumber.createNullable(nullablePhone); + expect(result.isSuccess).toBe(true); + expect(result.data).toEqual(Maybe.None()); + }); + + test("debe devolver Some con un número de teléfono válido", () => { + const result = PhoneNumber.createNullable(validPhone); + expect(result.isSuccess).toBe(true); + expect(result.data.isSome()).toBe(true); + }); + + test("debe obtener el valor del número de teléfono", () => { + const result = PhoneNumber.create(validPhone); + expect(result.isSuccess).toBe(true); + const phoneNumber = result.data; + expect(phoneNumber?.getValue()).toBe(validPhone); + }); + + test("debe obtener el código de país del número", () => { + const result = PhoneNumber.create(validPhone); + expect(result.isSuccess).toBe(true); + const phoneNumber = result.data; + expect(phoneNumber?.getCountryCode()).toBe("US"); + }); + + test("debe obtener el número nacional del teléfono", () => { + const result = PhoneNumber.create(validPhone); + expect(result.isSuccess).toBe(true); + const phoneNumber = result.data; + expect(phoneNumber?.getNationalNumber()).toBe("4155552671"); + }); + + test("debe obtener el número formateado", () => { + const result = PhoneNumber.create(validPhone); + expect(result.isSuccess).toBe(true); + const phoneNumber = result.data; + expect(phoneNumber?.getNumber()).toBe(parsePhoneNumberWithError(validPhone).number.toString()); + }); + + test("debe devolver undefined para la extensión si no hay una", () => { + const result = PhoneNumber.create(validPhone); + expect(result.isSuccess).toBe(true); + const phoneNumber = result.data; + expect(phoneNumber?.getExtension()).toBeUndefined(); + }); +}); diff --git a/apps/server/src/common/domain/value-objects/postal-address.spec.ts b/apps/server/src/common/domain/value-objects/postal-address.spec.ts new file mode 100644 index 00000000..1319b8ad --- /dev/null +++ b/apps/server/src/common/domain/value-objects/postal-address.spec.ts @@ -0,0 +1,64 @@ +import { PostalAddress } from "./postal-address"; + +describe("PostalAddress Value Object", () => { + const validAddress = { + street: "123 Main St", + city: "Springfield", + postalCode: "12345", + state: "IL", + country: "USA", + }; + + test("✅ Debería crear un PostalAddress con valores válidos", () => { + const result = PostalAddress.create(validAddress); + + expect(result.isSuccess).toBe(true); + expect(result.data).toBeInstanceOf(PostalAddress); + }); + + test("❌ Debería fallar al crear un PostalAddress con código postal inválido", () => { + const invalidAddress = { ...validAddress, postalCode: "abc" }; // Código postal inválido + const result = PostalAddress.create(invalidAddress); + + expect(result.isFailure).toBe(true); + expect(result.error?.message).toBe("Invalid postal code format"); + }); + + test("✅ `createNullable` debería devolver Maybe.None si los valores son nulos o vacíos", () => { + expect(PostalAddress.createNullable().data.isSome()).toBe(false); + expect( + PostalAddress.createNullable({ + street: "", + city: "", + postalCode: "", + state: "", + country: "", + }).data.isSome() + ).toBe(false); + }); + + test("✅ `createNullable` debería devolver Maybe.Some si los valores son válidos", () => { + const result = PostalAddress.createNullable(validAddress); + + expect(result.isSuccess).toBe(true); + expect(result.data.isSome()).toBe(true); + expect(result.data.unwrap()).toBeInstanceOf(PostalAddress); + }); + + test("✅ Métodos getters deberían devolver valores esperados", () => { + const address = PostalAddress.create(validAddress).data; + + expect(address.street).toBe(validAddress.street); + expect(address.city).toBe(validAddress.city); + expect(address.postalCode).toBe(validAddress.postalCode); + expect(address.state).toBe(validAddress.state); + expect(address.country).toBe(validAddress.country); + }); + + test("✅ `toString()` debería devolver la representación correcta", () => { + const address = PostalAddress.create(validAddress).data; + const expectedString = `${validAddress.street}, ${validAddress.city}, ${validAddress.postalCode}, ${validAddress.state}, ${validAddress.country}`; + + expect(address.toString()).toBe(expectedString); + }); +}); diff --git a/apps/server/src/common/domain/value-objects/postal-address.ts b/apps/server/src/common/domain/value-objects/postal-address.ts index 5aeba48f..3080ee8e 100644 --- a/apps/server/src/common/domain/value-objects/postal-address.ts +++ b/apps/server/src/common/domain/value-objects/postal-address.ts @@ -5,8 +5,8 @@ import { ValueObject } from "./value-object"; // 📌 Validaciones usando `zod` const postalCodeSchema = z .string() - .min(4) - .max(10) + .min(4, "Invalid postal code format") + .max(10, "Invalid postal code format") .regex(/^\d{4,10}$/, { message: "Invalid postal code format", }); diff --git a/apps/server/src/common/domain/value-objects/quantity.spec.ts b/apps/server/src/common/domain/value-objects/quantity.spec.ts new file mode 100644 index 00000000..67e01d06 --- /dev/null +++ b/apps/server/src/common/domain/value-objects/quantity.spec.ts @@ -0,0 +1,112 @@ +import { Quantity } from "./quantity"; + +describe("Quantity", () => { + describe("create", () => { + it("debería crear una cantidad válida", () => { + const result = Quantity.create({ amount: 100, scale: 2 }); + expect(result.isSuccess).toBe(true); + expect(result.data.amount).toBe(100); + expect(result.data.scale).toBe(2); + }); + + it("debería fallar si la escala es negativa", () => { + const result = Quantity.create({ amount: 100, scale: -1 }); + expect(result.isFailure).toBe(true); + }); + }); + + describe("toNumber & toString", () => { + it("debería convertir correctamente a número", () => { + const quantity = Quantity.create({ amount: 150, scale: 2 }).data; + expect(quantity.toNumber()).toBe(1.5); + }); + + it("debería convertir correctamente a string", () => { + const quantity = Quantity.create({ amount: 123, scale: 2 }).data; + expect(quantity.toString()).toBe("1.23"); + }); + }); + + describe("increment", () => { + it("debería incrementar en 1 si no se pasa otra cantidad", () => { + const quantity = Quantity.create({ amount: 100, scale: 2 }).data; + const incremented = quantity.increment().data; + expect(incremented.amount).toBe(101); + expect(incremented.scale).toBe(2); + }); + + it("debería sumar correctamente si tienen la misma escala", () => { + const quantity1 = Quantity.create({ amount: 100, scale: 2 }).data; + const quantity2 = Quantity.create({ amount: 50, scale: 2 }).data; + + const result = quantity1.increment(quantity2); + expect(result.isSuccess).toBe(true); + expect(result.data.amount).toBe(150); + }); + + it("debería fallar si las escalas son diferentes", () => { + const quantity1 = Quantity.create({ amount: 100, scale: 2 }).data; + const quantity2 = Quantity.create({ amount: 50, scale: 1 }).data; + + const result = quantity1.increment(quantity2); + expect(result.isFailure).toBe(true); + }); + }); + + describe("decrement", () => { + it("debería decrementar en 1 si no se pasa otra cantidad", () => { + const quantity = Quantity.create({ amount: 100, scale: 2 }).data; + const decremented = quantity.decrement().data; + expect(decremented.amount).toBe(99); + }); + + it("debería restar correctamente si tienen la misma escala", () => { + const quantity1 = Quantity.create({ amount: 100, scale: 2 }).data; + const quantity2 = Quantity.create({ amount: 50, scale: 2 }).data; + + const result = quantity1.decrement(quantity2); + expect(result.isSuccess).toBe(true); + expect(result.data.amount).toBe(50); + }); + + it("debería fallar si las escalas son diferentes", () => { + const quantity1 = Quantity.create({ amount: 100, scale: 2 }).data; + const quantity2 = Quantity.create({ amount: 50, scale: 1 }).data; + + const result = quantity1.decrement(quantity2); + expect(result.isFailure).toBe(true); + }); + }); + + describe("convertScale", () => { + it("debería convertir correctamente a una nueva escala", () => { + const quantity = Quantity.create({ amount: 100, scale: 2 }).data; + const result = quantity.convertScale(1); + expect(result.isSuccess).toBe(true); + expect(result.data.amount).toBe(10); + expect(result.data.scale).toBe(1); + }); + + it("debería fallar si la escala está fuera de rango", () => { + const quantity = Quantity.create({ amount: 100, scale: 2 }).data; + const result = quantity.convertScale(3); + expect(result.isFailure).toBe(true); + }); + }); + + describe("hasSameScale", () => { + it("debería retornar true si las escalas son iguales", () => { + const quantity1 = Quantity.create({ amount: 100, scale: 2 }).data; + const quantity2 = Quantity.create({ amount: 50, scale: 2 }).data; + + expect(quantity1.hasSameScale(quantity2)).toBe(true); + }); + + it("debería retornar false si las escalas son diferentes", () => { + const quantity1 = Quantity.create({ amount: 100, scale: 2 }).data; + const quantity2 = Quantity.create({ amount: 50, scale: 1 }).data; + + expect(quantity1.hasSameScale(quantity2)).toBe(false); + }); + }); +}); diff --git a/apps/server/src/common/domain/value-objects/quantity.ts b/apps/server/src/common/domain/value-objects/quantity.ts index 8b6babcf..ed5e25da 100644 --- a/apps/server/src/common/domain/value-objects/quantity.ts +++ b/apps/server/src/common/domain/value-objects/quantity.ts @@ -27,7 +27,7 @@ export class Quantity extends ValueObject implements IQuantity { protected static validate(values: IQuantityProps) { const schema = z.object({ amount: z.number().int(), - scale: z.number().int().min(0), + scale: z.number().int().min(Quantity.MIN_SCALE).max(Quantity.MAX_SCALE), }); return schema.safeParse(values); @@ -41,7 +41,7 @@ export class Quantity extends ValueObject implements IQuantity { const checkProps = Quantity.validate(props); if (!checkProps.success) { - Result.fail(new Error(checkProps.error.errors[0].message)); + return Result.fail(new Error(checkProps.error.errors[0].message)); } return Result.ok(new Quantity({ ...checkProps.data! })); } diff --git a/apps/server/src/common/domain/value-objects/tin-number.spec.ts b/apps/server/src/common/domain/value-objects/tin-number.spec.ts new file mode 100644 index 00000000..39f7b6e1 --- /dev/null +++ b/apps/server/src/common/domain/value-objects/tin-number.spec.ts @@ -0,0 +1,40 @@ +import { TINNumber } from "./tin-number"; + +describe("TINNumber", () => { + it("debería crear un TINNumber válido", () => { + const result = TINNumber.create("12345"); + expect(result.isSuccess).toBe(true); + expect(result.data.getValue()).toBe("12345"); + }); + + it("debería fallar si el valor es demasiado corto", () => { + const result = TINNumber.create("1"); + expect(result.isFailure).toBe(true); + expect(result.error?.message).toBe("TIN must be at least 2 characters long"); + }); + + it("debería fallar si el valor es demasiado largo", () => { + const result = TINNumber.create("12345678901"); + expect(result.isFailure).toBe(true); + expect(result.error?.message).toBe("TIN must be at most 10 characters long"); + }); + + it("debería devolver None cuando el valor es nulo o vacío en createNullable", () => { + const result = TINNumber.createNullable(""); + expect(result.isSuccess).toBe(true); + expect(result.data.isNone()).toBe(true); + }); + + it("debería devolver Some cuando el valor es válido en createNullable", () => { + const result = TINNumber.createNullable("6789"); + expect(result.isSuccess).toBe(true); + expect(result.data.isSome()).toBe(true); + expect(result.data.unwrap()?.toString()).toBe("6789"); + }); + + it("debería devolver el valor correcto en toString()", () => { + const result = TINNumber.create("ABC123"); + expect(result.isSuccess).toBe(true); + expect(result.data.toString()).toBe("ABC123"); + }); +}); diff --git a/apps/server/src/common/domain/value-objects/tin-number.ts b/apps/server/src/common/domain/value-objects/tin-number.ts index 695fa2f6..ffed5d45 100644 --- a/apps/server/src/common/domain/value-objects/tin-number.ts +++ b/apps/server/src/common/domain/value-objects/tin-number.ts @@ -46,6 +46,6 @@ export class TINNumber extends ValueObject { } toString(): string { - return this.props !== null && this.props !== undefined ? String(this.props) : ""; + return this.props.value; } } diff --git a/apps/server/src/common/helpers/maybe.ts b/apps/server/src/common/helpers/maybe.ts index eae6aa39..7572293b 100644 --- a/apps/server/src/common/helpers/maybe.ts +++ b/apps/server/src/common/helpers/maybe.ts @@ -24,6 +24,10 @@ export class Maybe { return this.value !== undefined; } + isNone(): boolean { + return !this.isSome(); + } + unwrap(): T | undefined { return this.value; } diff --git a/apps/server/src/common/infrastructure/sequelize/sequelize-mapper.spec.ts b/apps/server/src/common/infrastructure/sequelize/sequelize-mapper.spec.ts deleted file mode 100644 index 8518d628..00000000 --- a/apps/server/src/common/infrastructure/sequelize/sequelize-mapper.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { DomainEntity } from "@common/domain"; -import { Collection, Result } from "@common/helpers"; -import { Model } from "sequelize"; -import { SequelizeMapper } from "./sequelize-mapper"; - -// Mock Entities -class MockEntity extends DomainEntity {} -class MockModel extends Model { - dataValues: Record = {}; -} - -// Mock Mapper -class MockSequelizeMapper extends SequelizeMapper, MockEntity> { - public mapToDomain(source: MockModel, params?: Record): Result { - return Result.ok(new MockEntity({ ...source.dataValues, ...params })); - } - - public mapToPersistence( - source: MockEntity, - params?: Record - ): Result, Error> { - return Result.ok({ ...source.props, ...params }); - } -} - -describe("SequelizeMapper", () => { - let mapper: MockSequelizeMapper; - let model: MockModel; - - beforeEach(() => { - mapper = new MockSequelizeMapper({}); - model = new MockModel(); - model.dataValues = { id: 1, name: "Test" }; - }); - - test("should map a model to a domain entity", () => { - const result = mapper.mapToDomain(model); - expect(result.isSuccess).toBe(true); - expect(result.data).toBeInstanceOf(MockEntity); - expect(result.data.props.id).toBe(1); - expect(result.data.props.name).toBe("Test"); - }); - - test("should map an entity to a persistence object", () => { - const entity = new MockEntity({ id: 1, name: "Test" }); - const result = mapper.mapToPersistence(entity); - expect(result.isSuccess).toBe(true); - expect(result.data).toEqual({ id: 1, name: "Test" }); - }); - - test("should map an array of models to a collection of domain entities", () => { - const models = [model, { ...model, dataValues: { id: 2, name: "Test2" } }]; - const result = mapper.mapArrayToDomain(models); - expect(result.isSuccess).toBe(true); - expect(result.data).toBeInstanceOf(Collection); - expect(result.data.items.length).toBe(2); - }); - - test("should handle mapping failures gracefully", () => { - jest - .spyOn(mapper, "mapToDomain") - .mockImplementation(() => Result.fail(new Error("Mapping failed"))); - const result = mapper.mapToDomain(model); - expect(result.isFailure).toBe(true); - expect(result.error?.message).toBe("Mapping failed"); - }); -});