This commit is contained in:
David Arranz 2025-02-25 11:16:34 +01:00
parent 1f666a1a5d
commit d3fac62898
12 changed files with 330 additions and 100 deletions

View File

@ -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");
});
});

View File

@ -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");
});

View File

@ -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<IMoneyValueProps> 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<IMoneyValueProps> 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<IMoneyValueProps> 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<IMoneyValueProps> 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<IMoneyValueProps> 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<IMoneyValueProps> 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 {

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

@ -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",
});

View File

@ -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);
});
});
});

View File

@ -27,7 +27,7 @@ export class Quantity extends ValueObject<IQuantityProps> 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<IQuantityProps> 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! }));
}

View File

@ -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");
});
});

View File

@ -46,6 +46,6 @@ export class TINNumber extends ValueObject<TINNumberProps> {
}
toString(): string {
return this.props !== null && this.props !== undefined ? String(this.props) : "";
return this.props.value;
}
}

View File

@ -24,6 +24,10 @@ export class Maybe<T> {
return this.value !== undefined;
}
isNone(): boolean {
return !this.isSome();
}
unwrap(): T | undefined {
return this.value;
}

View File

@ -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<any> {}
class MockModel extends Model {
dataValues: Record<string, any> = {};
}
// Mock Mapper
class MockSequelizeMapper extends SequelizeMapper<MockModel, Record<string, any>, MockEntity> {
public mapToDomain(source: MockModel, params?: Record<string, any>): Result<MockEntity, Error> {
return Result.ok(new MockEntity({ ...source.dataValues, ...params }));
}
public mapToPersistence(
source: MockEntity,
params?: Record<string, any>
): Result<Record<string, any>, 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");
});
});