feat: Add initial company creator and onboarding repository
- Implemented InitialCompanyCreator service for creating new companies with necessary details and validations. - Added IInitialOnboardingRepository interface for onboarding data persistence. - Created SequelizeInitialOnboardingRepository for managing onboarding data with Sequelize ORM. - Enhanced InitialOnboarding aggregate to include createdAt and updatedAt timestamps. - Updated tests for initial company creation and onboarding provisioning to ensure proper functionality and error handling. - Introduced new tests for the Sequelize repository to validate data retrieval and persistence logic.
This commit is contained in:
parent
38fb6299b7
commit
0cbb6fc175
@ -1,2 +1,3 @@
|
||||
export * from "./company-public-finder";
|
||||
export * from "./company-public-services";
|
||||
export * from "./initial-company-creator";
|
||||
|
||||
@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DuplicateEntityError } from "@erp/core/api";
|
||||
import type { IAudit } from "@repo/rdx-audit";
|
||||
import {
|
||||
City,
|
||||
CountryCode,
|
||||
Name,
|
||||
PostalAddress,
|
||||
PostalCode,
|
||||
Province,
|
||||
Street,
|
||||
TextValue,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Company } from "../../../../domain";
|
||||
import { CompanySlug } from "../../../../domain";
|
||||
import type { ICompanyRepository } from "../../contracts";
|
||||
import { CompanyCreator } from "../../services/company-creator";
|
||||
|
||||
import { InitialCompanyCreator } from "./initial-company-creator";
|
||||
|
||||
const address = () =>
|
||||
PostalAddress.create({
|
||||
street: Maybe.some(Street.create("Calle Mayor 1").data),
|
||||
street2: Maybe.none(),
|
||||
city: Maybe.some(City.create("Madrid").data),
|
||||
postalCode: Maybe.some(PostalCode.create("28001").data),
|
||||
province: Maybe.some(Province.create("Madrid").data),
|
||||
countryCode: Maybe.some(CountryCode.create("ES").data),
|
||||
}).data;
|
||||
const input = () => ({
|
||||
legalName: Name.create("Rodax").data,
|
||||
tin: TextValue.create("B12345678").data,
|
||||
address: address(),
|
||||
});
|
||||
|
||||
function setup(error?: Error, duplicate = false) {
|
||||
const saved: Company[] = [];
|
||||
const transactions: unknown[] = [];
|
||||
const repository: ICompanyRepository = {
|
||||
save: async (company, transaction) => {
|
||||
transactions.push(transaction);
|
||||
if (error) return Result.fail(error);
|
||||
saved.push(company);
|
||||
return Result.ok();
|
||||
},
|
||||
update: async () => Result.ok(),
|
||||
findById: async () => Result.ok(Maybe.none()),
|
||||
findBySlug: async (_slug, transaction) => {
|
||||
transactions.push(transaction);
|
||||
return Result.ok(duplicate ? Maybe.some(saved[0]) : Maybe.none());
|
||||
},
|
||||
existsActiveById: async () => Result.ok(false),
|
||||
findActiveByIds: async () => Result.ok(new Collection([])),
|
||||
};
|
||||
const audit: IAudit = {
|
||||
record: async () => Result.ok(),
|
||||
recordEvent: async () => Result.ok(),
|
||||
recordMutation: async () => Result.ok(),
|
||||
};
|
||||
return {
|
||||
capability: new InitialCompanyCreator(new CompanyCreator(repository, audit)),
|
||||
saved,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
test("crea con id, slug, status y fechas internos y persiste dirección completa", async (t) => {
|
||||
const now = UtcDate.createFromISO("2026-09-07").data;
|
||||
t.mock.method(UtcDate, "today", () => now);
|
||||
const { capability, saved } = setup();
|
||||
const params = input();
|
||||
const result = await capability.create(params);
|
||||
assert.equal(result.isSuccess, true);
|
||||
const company = saved[0];
|
||||
assert.equal(UniqueID.create(result.data.companyId).isSuccess, true);
|
||||
assert.equal(result.data.companyId, company.id.toPrimitive());
|
||||
assert.equal(result.data.slug, `company-${company.id.toPrimitive()}`);
|
||||
assert.equal(CompanySlug.create(result.data.slug).isSuccess, true);
|
||||
assert.equal(company.status.isActive(), true);
|
||||
assert.equal(company.createdAt, now);
|
||||
assert.equal(company.updatedAt, now);
|
||||
assert.equal(company.address.unwrap(), params.address);
|
||||
assert.equal(company.tin.unwrap(), params.tin);
|
||||
assert.deepEqual(Object.keys(result.data).sort(), ["companyId", "slug"]);
|
||||
});
|
||||
|
||||
test("nombres iguales generan IDs y slugs distintos", async () => {
|
||||
const { capability } = setup();
|
||||
const first = await capability.create(input());
|
||||
const second = await capability.create(input());
|
||||
assert.equal(first.isSuccess, true);
|
||||
assert.equal(second.isSuccess, true);
|
||||
assert.notEqual(first.data.companyId, second.data.companyId);
|
||||
assert.notEqual(first.data.slug, second.data.slug);
|
||||
});
|
||||
|
||||
for (const field of ["street", "city", "postalCode", "province", "countryCode"] as const) {
|
||||
test(`rechaza dirección sin ${field} antes de persistir`, async () => {
|
||||
const { capability, saved, transactions } = setup();
|
||||
const incomplete = PostalAddress.create({
|
||||
...address().getProps(),
|
||||
[field]: Maybe.none(),
|
||||
}).data;
|
||||
const result = await capability.create({ ...input(), address: incomplete });
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(saved.length, 0);
|
||||
assert.equal(transactions.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("propaga transaction al chequeo de slug y save", async () => {
|
||||
const { capability, transactions } = setup();
|
||||
const transaction = {};
|
||||
const result = await capability.create({ ...input(), transaction });
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.deepEqual(transactions, [transaction, transaction]);
|
||||
});
|
||||
|
||||
for (const error of [
|
||||
new Error("persistence failed"),
|
||||
new DuplicateEntityError("Company", "slug", "duplicate"),
|
||||
]) {
|
||||
test(`propaga error repository ${error.name}`, async () => {
|
||||
const { capability } = setup(error);
|
||||
const result = await capability.create(input());
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(result.error, error);
|
||||
});
|
||||
}
|
||||
|
||||
test("conflicto de slug conserva el rechazo de CompanyCreator", async () => {
|
||||
const { capability, saved } = setup(undefined, true);
|
||||
const first = setup();
|
||||
await first.capability.create(input());
|
||||
saved.push(first.saved[0]);
|
||||
const result = await capability.create(input());
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(saved.length, 1);
|
||||
});
|
||||
@ -0,0 +1,84 @@
|
||||
import type {
|
||||
EmailAddress,
|
||||
Name,
|
||||
PhoneNumber,
|
||||
PostalAddress,
|
||||
TextValue,
|
||||
URLAddress,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { CompanySlug, CompanyStatus } from "../../../../domain";
|
||||
import type { ICompanyCreator } from "../../services/company-creator";
|
||||
|
||||
export interface CreateInitialCompanyParams {
|
||||
legalName: Name;
|
||||
tradeName?: Name;
|
||||
tin: TextValue;
|
||||
email?: EmailAddress;
|
||||
phone?: PhoneNumber;
|
||||
website?: URLAddress;
|
||||
address: PostalAddress;
|
||||
transaction?: unknown;
|
||||
}
|
||||
|
||||
export interface InitialCompanyPublicModel {
|
||||
companyId: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface IInitialCompanyCreator {
|
||||
create(params: CreateInitialCompanyParams): Promise<Result<InitialCompanyPublicModel, Error>>;
|
||||
}
|
||||
|
||||
export class InitialCompanyCreator implements IInitialCompanyCreator {
|
||||
public constructor(private readonly creator: ICompanyCreator) {}
|
||||
|
||||
public async create(
|
||||
params: CreateInitialCompanyParams
|
||||
): Promise<Result<InitialCompanyPublicModel, Error>> {
|
||||
const address = params.address;
|
||||
if (
|
||||
!address ||
|
||||
[
|
||||
address.street,
|
||||
address.city,
|
||||
address.postalCode,
|
||||
address.province,
|
||||
address.countryCode,
|
||||
].some((part) => part.isNone())
|
||||
) {
|
||||
return Result.fail(new Error("Initial company requires a complete postal address."));
|
||||
}
|
||||
|
||||
const id = UniqueID.generateNewID();
|
||||
// El ID interno mantiene el slug estable y distingue empresas con el mismo nombre.
|
||||
const slug = CompanySlug.create(`company-${id.toPrimitive()}`);
|
||||
if (slug.isFailure) return Result.fail(slug.error);
|
||||
const now = UtcDate.today();
|
||||
const created = await this.creator.create({
|
||||
id,
|
||||
transaction: params.transaction,
|
||||
props: {
|
||||
legalName: params.legalName,
|
||||
tradeName: Maybe.fromNullable(params.tradeName),
|
||||
tin: Maybe.some(params.tin),
|
||||
email: Maybe.fromNullable(params.email),
|
||||
phone: Maybe.fromNullable(params.phone),
|
||||
website: Maybe.fromNullable(params.website),
|
||||
address: Maybe.some(address),
|
||||
slug: slug.data,
|
||||
status: CompanyStatus.createActive(),
|
||||
defaultPaymentTermId: Maybe.none(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
if (created.isFailure) return Result.fail(created.error);
|
||||
return Result.ok({
|
||||
companyId: created.data.id.toPrimitive(),
|
||||
slug: created.data.slug.toPrimitive(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { InitialOnboarding } from "../../domain";
|
||||
|
||||
export interface IInitialOnboardingRepository {
|
||||
/** Devuelve ausencia explícita si la cuenta no tiene onboarding; no es un error. */
|
||||
findByAccountId(
|
||||
accountId: UniqueID,
|
||||
transaction?: unknown
|
||||
): Promise<Result<Maybe<InitialOnboarding>, Error>>;
|
||||
|
||||
/** Persiste el estado actual del agregado, tanto pending como completed. */
|
||||
save(initialOnboarding: InitialOnboarding, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
}
|
||||
@ -0,0 +1,193 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DuplicateEntityError } from "@erp/core/api";
|
||||
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { InitialOnboarding } from "../../domain";
|
||||
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
|
||||
|
||||
import { InitialOnboardingProvisioner } from "./initial-onboarding-provisioner";
|
||||
|
||||
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
||||
const duplicate = () =>
|
||||
new DuplicateEntityError("InitialOnboarding", "accountId", accountId.toPrimitive());
|
||||
|
||||
class FakeRepository implements IInitialOnboardingRepository {
|
||||
public stored: InitialOnboarding | undefined;
|
||||
public reads = 0;
|
||||
public writes = 0;
|
||||
public readError: Error | undefined;
|
||||
public writeError: Error | undefined;
|
||||
public winner: InitialOnboarding | undefined;
|
||||
public rereadError: Error | undefined;
|
||||
public async findByAccountId(id: UniqueID): Promise<Result<Maybe<InitialOnboarding>, Error>> {
|
||||
assert.ok(id.equals(accountId));
|
||||
this.reads++;
|
||||
if (this.readError) return Result.fail(this.readError);
|
||||
if (this.reads > 1 && this.rereadError) return Result.fail(this.rereadError);
|
||||
return Result.ok(Maybe.fromNullable(this.stored));
|
||||
}
|
||||
public async save(value: InitialOnboarding): Promise<Result<void, Error>> {
|
||||
this.writes++;
|
||||
if (this.winner) this.stored = this.winner;
|
||||
if (this.writeError) return Result.fail(this.writeError);
|
||||
this.stored = value;
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
function existing(completed = false) {
|
||||
const value = InitialOnboarding.createPending(accountId).data;
|
||||
if (completed) {
|
||||
const timestamp = UtcDateTime.createFromISO("2026-01-02T10:30:00.123Z").data;
|
||||
assert.equal(
|
||||
value.complete({
|
||||
companyId: UniqueID.generateNewID(),
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}).isSuccess,
|
||||
true
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
test("inexistente crea y guarda pending mediante el agregado", async () => {
|
||||
const repository = new FakeRepository();
|
||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(result.data, repository.stored);
|
||||
assert.equal(result.data.status, "pending");
|
||||
assert.ok(result.data.accountId.equals(accountId));
|
||||
assert.ok(result.data.createdAt.equals(result.data.updatedAt));
|
||||
assert.ok(result.data.companyId.isNone());
|
||||
assert.ok(result.data.completedAt.isNone());
|
||||
assert.equal(repository.writes, 1);
|
||||
});
|
||||
|
||||
for (const completed of [false, true]) {
|
||||
test(`existente ${completed ? "completed" : "pending"} conserva identidad y estado sin save`, async () => {
|
||||
const repository = new FakeRepository();
|
||||
const value = existing(completed);
|
||||
repository.stored = value;
|
||||
const before = [value.id, value.createdAt, value.updatedAt, value.companyId, value.completedAt];
|
||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
||||
assert.equal(result.data, value);
|
||||
assert.equal(result.data.status, completed ? "completed" : "pending");
|
||||
assert.deepEqual(
|
||||
[value.id, value.createdAt, value.updatedAt, value.companyId, value.completedAt],
|
||||
before
|
||||
);
|
||||
assert.equal(repository.writes, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("error de find inicial se propaga sin save", async () => {
|
||||
const repository = new FakeRepository();
|
||||
repository.readError = new Error("read failed");
|
||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(result.error, repository.readError);
|
||||
assert.equal(repository.writes, 0);
|
||||
});
|
||||
|
||||
test("error de save no duplicate se propaga sin releer", async () => {
|
||||
const repository = new FakeRepository();
|
||||
repository.writeError = new Error("write failed");
|
||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(result.error, repository.writeError);
|
||||
assert.equal(repository.reads, 1);
|
||||
});
|
||||
|
||||
for (const completed of [false, true]) {
|
||||
test(`duplicate devuelve ganador ${completed ? "completed" : "pending"} tras una relectura`, async () => {
|
||||
const repository = new FakeRepository();
|
||||
repository.writeError = duplicate();
|
||||
repository.winner = existing(completed);
|
||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(result.data, repository.winner);
|
||||
assert.equal(repository.reads, 2);
|
||||
assert.equal(repository.writes, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("duplicate y relectura fallida propaga el error de lectura", async () => {
|
||||
const repository = new FakeRepository();
|
||||
repository.writeError = duplicate();
|
||||
repository.rereadError = new Error("reread failed");
|
||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(result.error, repository.rereadError);
|
||||
assert.equal(repository.reads, 2);
|
||||
});
|
||||
|
||||
test("duplicate y relectura none retorna error explícito con causa", async () => {
|
||||
const repository = new FakeRepository();
|
||||
repository.writeError = duplicate();
|
||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(
|
||||
result.error.message,
|
||||
"Initial onboarding duplicate without a persisted onboarding for the account."
|
||||
);
|
||||
assert.equal(result.error.cause, repository.writeError);
|
||||
assert.equal(repository.reads, 2);
|
||||
assert.equal(repository.writes, 1);
|
||||
});
|
||||
|
||||
test("provision repetido devuelve el mismo agregado y timestamps", async () => {
|
||||
const repository = new FakeRepository();
|
||||
const provisioner = new InitialOnboardingProvisioner(repository);
|
||||
const first = await provisioner.provision(accountId);
|
||||
const second = await provisioner.provision(accountId);
|
||||
const third = await provisioner.provision(accountId);
|
||||
assert.equal(first.isSuccess, true);
|
||||
assert.equal(second.data, first.data);
|
||||
assert.equal(third.data, first.data);
|
||||
assert.equal(repository.writes, 1);
|
||||
});
|
||||
|
||||
test("dos provision simultáneos con barrera devuelven un único ganador", async () => {
|
||||
let release: () => void = () => {
|
||||
throw new Error("Barrier not initialized");
|
||||
};
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let reads = 0;
|
||||
let writes = 0;
|
||||
let stored: InitialOnboarding | undefined;
|
||||
const repository: IInitialOnboardingRepository = {
|
||||
async findByAccountId(id) {
|
||||
assert.ok(id.equals(accountId));
|
||||
reads++;
|
||||
if (reads <= 2) {
|
||||
if (reads === 2) release();
|
||||
await barrier;
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
return Result.ok(Maybe.fromNullable(stored));
|
||||
},
|
||||
async save(value) {
|
||||
writes++;
|
||||
if (stored) return Result.fail(duplicate());
|
||||
stored = value;
|
||||
return Result.ok();
|
||||
},
|
||||
};
|
||||
const provisioner = new InitialOnboardingProvisioner(repository);
|
||||
const [first, second] = await Promise.all([
|
||||
provisioner.provision(accountId),
|
||||
provisioner.provision(accountId),
|
||||
]);
|
||||
assert.equal(first.isSuccess, true);
|
||||
assert.equal(second.isSuccess, true);
|
||||
assert.equal(first.data, stored);
|
||||
assert.equal(second.data, stored);
|
||||
assert.ok(first.data.id.equals(second.data.id));
|
||||
assert.equal(writes, 2);
|
||||
assert.equal(reads, 3);
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import { isDuplicateEntityError } from "@erp/core/api";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { InitialOnboarding } from "../../domain";
|
||||
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
|
||||
|
||||
export class InitialOnboardingProvisioner {
|
||||
public constructor(private readonly repository: IInitialOnboardingRepository) {}
|
||||
|
||||
/** Asegura que existe onboarding; conserva los estados pending y completed existentes. */
|
||||
public async provision(accountId: UniqueID): Promise<Result<InitialOnboarding, Error>> {
|
||||
const current = await this.repository.findByAccountId(accountId);
|
||||
if (current.isFailure) return Result.fail(current.error);
|
||||
if (current.data.isSome()) return Result.ok(current.data.unwrap());
|
||||
|
||||
const created = InitialOnboarding.createPending(accountId);
|
||||
if (created.isFailure) return Result.fail(created.error);
|
||||
const saved = await this.repository.save(created.data);
|
||||
if (saved.isSuccess) return Result.ok(created.data);
|
||||
if (!isDuplicateEntityError(saved.error)) return Result.fail(saved.error);
|
||||
|
||||
// UNIQUE es la garantía entre procesos. Solo recuperamos el ganador confirmado por cuenta.
|
||||
const winner = await this.repository.findByAccountId(accountId);
|
||||
if (winner.isFailure) return Result.fail(winner.error);
|
||||
if (winner.data.isSome()) return Result.ok(winner.data.unwrap());
|
||||
return Result.fail(
|
||||
new Error("Initial onboarding duplicate without a persisted onboarding for the account.", {
|
||||
cause: saved.error,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,8 @@ test("creates pending", () => {
|
||||
check(r.data.companyId.isNone());
|
||||
check(r.data.completedAt.isNone());
|
||||
check(r.data.accountId.equals(accountId));
|
||||
check(r.data.createdAt instanceof UtcDateTime);
|
||||
check(r.data.updatedAt.equals(r.data.createdAt));
|
||||
});
|
||||
test("rehydrates valid states", () => {
|
||||
check(InitialOnboarding.rehydrate(props("pending"), UniqueID.generateNewID()).isSuccess);
|
||||
@ -64,7 +66,11 @@ test("rejects invalid state combinations", () => {
|
||||
test("completes once", () => {
|
||||
const o = InitialOnboarding.createPending(UniqueID.generateNewID()).data;
|
||||
const id = UniqueID.generateNewID();
|
||||
check(o.complete({ companyId: id, completedAt: date(), updatedAt: date() }).isSuccess);
|
||||
const createdAt = o.createdAt;
|
||||
const updatedAt = UtcDateTime.createFromISO("2026-01-02T11:00:00.456Z").data;
|
||||
check(o.complete({ companyId: id, completedAt: date(), updatedAt }).isSuccess);
|
||||
check(o.createdAt.equals(createdAt));
|
||||
check(o.updatedAt.equals(updatedAt));
|
||||
check(o.status === "completed");
|
||||
check(o.companyId.unwrap().equals(id));
|
||||
check(o.completedAt.isSome());
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AggregateRoot, UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { AggregateRoot, UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
export type InitialOnboardingStatus = "pending" | "completed";
|
||||
@ -6,6 +6,8 @@ export interface InitialOnboardingProps {
|
||||
accountId: UniqueID;
|
||||
status: InitialOnboardingStatus;
|
||||
companyId: Maybe<UniqueID>;
|
||||
createdAt: UtcDateTime;
|
||||
updatedAt: UtcDateTime;
|
||||
completedAt: Maybe<UtcDateTime>;
|
||||
}
|
||||
export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
||||
@ -20,11 +22,14 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
||||
}
|
||||
|
||||
static createPending(accountId: UniqueID): Result<InitialOnboarding, Error> {
|
||||
const now = UtcDateTime.now();
|
||||
return InitialOnboarding.rehydrate(
|
||||
{
|
||||
accountId,
|
||||
status: "pending",
|
||||
companyId: Maybe.none(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completedAt: Maybe.none(),
|
||||
},
|
||||
UniqueID.generateNewID()
|
||||
@ -61,6 +66,14 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
get createdAt(): UtcDateTime {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): UtcDateTime {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
get accountId() {
|
||||
return this.props.accountId;
|
||||
}
|
||||
|
||||
@ -2,5 +2,6 @@ import initialOnboardingModelInit from "./models/sequelize-initial-onboarding.mo
|
||||
|
||||
export * from "./mappers";
|
||||
export * from "./models";
|
||||
export * from "./repositories";
|
||||
|
||||
export const models = [initialOnboardingModelInit];
|
||||
|
||||
@ -20,6 +20,8 @@ const mapper = new SequelizeInitialOnboardingDomainMapper();
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const accountId = "22222222-2222-4222-8222-222222222222";
|
||||
const companyId = "33333333-3333-4333-8333-333333333333";
|
||||
const createdAt = "2026-01-01T10:00:00.123Z";
|
||||
const updatedAt = "2026-01-02T11:00:00.456Z";
|
||||
const completedAt = "2026-01-02T10:30:00.789Z";
|
||||
|
||||
function aggregate(status: InitialOnboardingStatus) {
|
||||
@ -30,6 +32,8 @@ function aggregate(status: InitialOnboardingStatus) {
|
||||
companyId:
|
||||
status === "completed" ? Maybe.some(UniqueID.create(companyId).data) : Maybe.none(),
|
||||
|
||||
createdAt: UtcDateTime.createFromISO(createdAt).data,
|
||||
updatedAt: UtcDateTime.createFromISO(updatedAt).data,
|
||||
completedAt:
|
||||
status === "completed"
|
||||
? Maybe.some(UtcDateTime.createFromISO(completedAt).data)
|
||||
@ -48,6 +52,8 @@ function row(patch: Partial<InferAttributes<InitialOnboardingModel>> = {}) {
|
||||
account_id: accountId,
|
||||
status: "pending",
|
||||
company_id: null,
|
||||
created_at: new Date(createdAt),
|
||||
updated_at: new Date(updatedAt),
|
||||
completed_at: null,
|
||||
...patch,
|
||||
},
|
||||
@ -57,13 +63,18 @@ function row(patch: Partial<InferAttributes<InitialOnboardingModel>> = {}) {
|
||||
|
||||
for (const status of ["pending", "completed"] as const) {
|
||||
test(`toPersistence ${status}`, () => {
|
||||
const result = mapper.mapToPersistence(aggregate(status));
|
||||
const source = aggregate(status);
|
||||
const result = mapper.mapToPersistence(source);
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.deepEqual(result.data.created_at, source.createdAt.toDate());
|
||||
assert.deepEqual(result.data.updated_at, source.updatedAt.toDate());
|
||||
assert.deepEqual(result.data, {
|
||||
id,
|
||||
account_id: accountId,
|
||||
status,
|
||||
company_id: status === "completed" ? companyId : null,
|
||||
created_at: new Date(createdAt),
|
||||
updated_at: new Date(updatedAt),
|
||||
completed_at: status === "completed" ? completedAt : null,
|
||||
});
|
||||
});
|
||||
@ -73,6 +84,8 @@ for (const status of ["pending", "completed"] as const) {
|
||||
row({
|
||||
status,
|
||||
company_id: status === "completed" ? companyId : null,
|
||||
created_at: new Date(createdAt),
|
||||
updated_at: new Date(updatedAt),
|
||||
completed_at: status === "completed" ? completedAt : null,
|
||||
})
|
||||
);
|
||||
@ -83,6 +96,8 @@ for (const status of ["pending", "completed"] as const) {
|
||||
assert.equal(value.id.toPrimitive(), id);
|
||||
assert.equal(value.accountId.toPrimitive(), accountId);
|
||||
assert.equal(value.status, status);
|
||||
assert.equal(value.createdAt.toISOString(), createdAt);
|
||||
assert.equal(value.updatedAt.toISOString(), updatedAt);
|
||||
assert.equal(value.companyId.isSome(), status === "completed");
|
||||
assert.equal(value.completedAt.isSome(), status === "completed");
|
||||
if (status === "completed") {
|
||||
@ -113,10 +128,14 @@ test("toDomain accepts Sequelize Date timestamps and preserves UTC milliseconds"
|
||||
row({
|
||||
status: "completed",
|
||||
company_id: companyId,
|
||||
created_at: new Date(createdAt),
|
||||
updated_at: new Date(updatedAt),
|
||||
completed_at: new Date(completedAt),
|
||||
})
|
||||
);
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(result.data.createdAt.toISOString(), createdAt);
|
||||
assert.equal(result.data.updatedAt.toISOString(), updatedAt);
|
||||
assert.equal(result.data.completedAt.unwrap().toISOString(), completedAt);
|
||||
});
|
||||
|
||||
@ -133,8 +152,8 @@ test("toDomain propagates invalid identifiers and timestamps as failures", () =>
|
||||
{ id: "invalid" },
|
||||
{ account_id: "invalid" },
|
||||
{ status: "completed", company_id: "invalid", completed_at: completedAt },
|
||||
{ created_at: "invalid" },
|
||||
{ updated_at: "invalid" },
|
||||
{ created_at: new Date(Number.NaN) },
|
||||
{ updated_at: new Date(Number.NaN) },
|
||||
{ status: "completed", company_id: companyId, completed_at: "invalid" },
|
||||
]) {
|
||||
assert.equal(mapper.mapToDomain(row(patch)).isFailure, true);
|
||||
|
||||
@ -32,6 +32,10 @@ export class SequelizeInitialOnboardingDomainMapper {
|
||||
if (accountId.isFailure) return Result.fail(accountId.error);
|
||||
const companyId = maybeFromNullableResult(source.company_id, (value) => UniqueID.create(value));
|
||||
if (companyId.isFailure) return Result.fail(companyId.error);
|
||||
const createdAt = toUtcDateTime(source.created_at);
|
||||
if (createdAt.isFailure) return Result.fail(createdAt.error);
|
||||
const updatedAt = toUtcDateTime(source.updated_at);
|
||||
if (updatedAt.isFailure) return Result.fail(updatedAt.error);
|
||||
const completedAt = maybeFromNullableResult(source.completed_at, (value) =>
|
||||
toUtcDateTime(value)
|
||||
);
|
||||
@ -42,6 +46,8 @@ export class SequelizeInitialOnboardingDomainMapper {
|
||||
accountId: accountId.data,
|
||||
status: source.status,
|
||||
companyId: companyId.data,
|
||||
createdAt: createdAt.data,
|
||||
updatedAt: updatedAt.data,
|
||||
completedAt: completedAt.data,
|
||||
},
|
||||
id.data
|
||||
@ -56,6 +62,8 @@ export class SequelizeInitialOnboardingDomainMapper {
|
||||
account_id: source.accountId.toPrimitive(),
|
||||
status: source.status,
|
||||
company_id: maybeToNullable(source.companyId, (value) => value.toPrimitive()),
|
||||
created_at: source.createdAt.toDate(),
|
||||
updated_at: source.updatedAt.toDate(),
|
||||
completed_at: maybeToNullable(source.completedAt, (value) => value.toISOString()),
|
||||
});
|
||||
}
|
||||
|
||||
@ -12,7 +12,9 @@ initialOnboardingModelInit(database);
|
||||
|
||||
test("model declares table, nullability, UUIDs, timestamps and unique account without cross-module FKs", () => {
|
||||
assert.equal(InitialOnboardingModel.getTableName(), "initial_onboardings");
|
||||
assert.equal(InitialOnboardingModel.options.timestamps, false);
|
||||
assert.equal(InitialOnboardingModel.options.timestamps, true);
|
||||
assert.equal(InitialOnboardingModel.options.createdAt, "created_at");
|
||||
assert.equal(InitialOnboardingModel.options.updatedAt, "updated_at");
|
||||
const attributes = InitialOnboardingModel.getAttributes();
|
||||
assert.deepEqual(Object.keys(attributes).sort(), [
|
||||
"account_id",
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
|
||||
import type {
|
||||
CreationOptional,
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
Sequelize,
|
||||
} from "sequelize";
|
||||
import { DataTypes, Model } from "sequelize";
|
||||
|
||||
export type InitialOnboardingCreationAttributes = InferCreationAttributes<InitialOnboardingModel>;
|
||||
@ -11,6 +16,8 @@ export class InitialOnboardingModel extends Model<
|
||||
declare account_id: string;
|
||||
declare status: string;
|
||||
declare company_id: string | null;
|
||||
declare created_at: CreationOptional<Date>;
|
||||
declare updated_at: CreationOptional<Date>;
|
||||
declare completed_at: Date | string | null;
|
||||
}
|
||||
|
||||
@ -26,6 +33,8 @@ export default (database: Sequelize) => {
|
||||
validate: { isIn: [["pending", "completed"]] },
|
||||
},
|
||||
company_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null },
|
||||
created_at: { type: DataTypes.DATE, allowNull: false },
|
||||
updated_at: { type: DataTypes.DATE, allowNull: false },
|
||||
completed_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
|
||||
},
|
||||
{
|
||||
@ -36,6 +45,8 @@ export default (database: Sequelize) => {
|
||||
collate: "utf8mb4_unicode_ci",
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
indexes: [
|
||||
{
|
||||
name: "uq_initial_onboardings_account_id",
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./sequelize-initial-onboarding.repository";
|
||||
@ -0,0 +1,169 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DuplicateEntityError } from "@erp/core/api";
|
||||
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import { Sequelize, UniqueConstraintError } from "sequelize";
|
||||
|
||||
import { InitialOnboarding } from "../../../../domain";
|
||||
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
|
||||
import initialOnboardingModelInit, {
|
||||
InitialOnboardingModel,
|
||||
} from "../models/sequelize-initial-onboarding.model";
|
||||
|
||||
import { SequelizeInitialOnboardingRepository } from "./sequelize-initial-onboarding.repository";
|
||||
|
||||
initialOnboardingModelInit(
|
||||
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
|
||||
);
|
||||
const mapper = new SequelizeInitialOnboardingDomainMapper();
|
||||
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
||||
function aggregate(completed = false) {
|
||||
return InitialOnboarding.rehydrate(
|
||||
{
|
||||
accountId,
|
||||
status: completed ? "completed" : "pending",
|
||||
companyId: completed
|
||||
? Maybe.some(UniqueID.create("33333333-3333-4333-8333-333333333333").data)
|
||||
: Maybe.none(),
|
||||
createdAt: UtcDateTime.createFromISO("2026-01-01T10:00:00.123Z").data,
|
||||
updatedAt: UtcDateTime.createFromISO("2026-01-02T11:00:00.456Z").data,
|
||||
completedAt: completed
|
||||
? Maybe.some(UtcDateTime.createFromISO("2026-01-02T10:30:00.789Z").data)
|
||||
: Maybe.none(),
|
||||
},
|
||||
UniqueID.create("11111111-1111-4111-8111-111111111111").data
|
||||
).data;
|
||||
}
|
||||
function row(completed = false) {
|
||||
return InitialOnboardingModel.build(mapper.mapToPersistence(aggregate(completed)).data, {
|
||||
raw: true,
|
||||
});
|
||||
}
|
||||
|
||||
test("find existente usa filtro account_id, mapper y transaction", async (t) => {
|
||||
const transaction = {};
|
||||
const model = row();
|
||||
const find = t.mock.method(InitialOnboardingModel, "findOne", async () => model);
|
||||
const map = t.mock.method(mapper, "mapToDomain");
|
||||
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(
|
||||
accountId,
|
||||
transaction
|
||||
);
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(result.data.isSome(), true);
|
||||
assert.equal(result.data.unwrap().id.toPrimitive(), model.id);
|
||||
assert.deepEqual(find.mock.calls[0].arguments[0], {
|
||||
where: { account_id: accountId.toPrimitive() },
|
||||
transaction,
|
||||
});
|
||||
assert.equal(map.mock.calls[0].arguments[0], model);
|
||||
});
|
||||
|
||||
test("find ausente devuelve Maybe.none sin llamar al mapper", async (t) => {
|
||||
t.mock.method(InitialOnboardingModel, "findOne", async () => null);
|
||||
const map = t.mock.method(mapper, "mapToDomain");
|
||||
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(accountId);
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(result.data.isNone(), true);
|
||||
assert.equal(map.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
for (const completed of [false, true]) {
|
||||
test(`save ${completed ? "completed" : "pending"} crea usando mapper y conserva timestamps y transaction`, async (t) => {
|
||||
const source = aggregate(completed);
|
||||
const expected = mapper.mapToPersistence(source).data;
|
||||
const transaction = {};
|
||||
const find = t.mock.method(InitialOnboardingModel, "findByPk", async () => null);
|
||||
const create = t.mock.method(InitialOnboardingModel, "create", async () => row(completed));
|
||||
const map = t.mock.method(mapper, "mapToPersistence");
|
||||
const result = await new SequelizeInitialOnboardingRepository(mapper).save(source, transaction);
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(map.mock.calls[0].arguments[0], source);
|
||||
assert.deepEqual(find.mock.calls[0].arguments, [source.id.toPrimitive(), { transaction }]);
|
||||
assert.deepEqual(create.mock.calls[0].arguments, [expected, { transaction, silent: true }]);
|
||||
});
|
||||
}
|
||||
|
||||
test("save repetido actualiza por id sin volver a insertar", async (t) => {
|
||||
const source = aggregate(true);
|
||||
const transaction = {};
|
||||
let stored: InitialOnboardingModel | null = null;
|
||||
t.mock.method(InitialOnboardingModel, "findByPk", async () => stored);
|
||||
const create = t.mock.method(InitialOnboardingModel, "create", async () => {
|
||||
stored = row(true);
|
||||
return stored;
|
||||
});
|
||||
const update = t.mock.method(InitialOnboardingModel, "update", async () => [1]);
|
||||
const repository = new SequelizeInitialOnboardingRepository();
|
||||
assert.equal((await repository.save(source, transaction)).isSuccess, true);
|
||||
assert.equal((await repository.save(source, transaction)).isSuccess, true);
|
||||
assert.equal(create.mock.callCount(), 1);
|
||||
assert.equal(update.mock.callCount(), 1);
|
||||
const { id, ...values } = mapper.mapToPersistence(source).data;
|
||||
assert.deepEqual(update.mock.calls[0].arguments, [
|
||||
values,
|
||||
{ transaction, silent: true, where: { id } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("read error retorna Result.fail", async (t) => {
|
||||
const error = new Error("read failed");
|
||||
t.mock.method(InitialOnboardingModel, "findOne", async () => {
|
||||
throw error;
|
||||
});
|
||||
const result = await new SequelizeInitialOnboardingRepository().findByAccountId(accountId);
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(result.error, error);
|
||||
});
|
||||
|
||||
for (const existing of [false, true]) {
|
||||
test(`write error en ${existing ? "update" : "create"} retorna Result.fail`, async (t) => {
|
||||
const error = new Error("write failed");
|
||||
t.mock.method(InitialOnboardingModel, "findByPk", async () => (existing ? row() : null));
|
||||
t.mock.method(InitialOnboardingModel, existing ? "update" : "create", async () => {
|
||||
throw error;
|
||||
});
|
||||
const result = await new SequelizeInitialOnboardingRepository().save(aggregate());
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(result.error, error);
|
||||
});
|
||||
}
|
||||
|
||||
test("unique account_id retorna fallo y no reintenta ni relee", async (t) => {
|
||||
const error = new UniqueConstraintError({ message: "duplicate account_id" });
|
||||
const find = t.mock.method(InitialOnboardingModel, "findByPk", async () => null);
|
||||
const reread = t.mock.method(InitialOnboardingModel, "findOne", async () => row());
|
||||
const create = t.mock.method(InitialOnboardingModel, "create", async () => {
|
||||
throw error;
|
||||
});
|
||||
const result = await new SequelizeInitialOnboardingRepository().save(aggregate());
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.ok(result.error instanceof DuplicateEntityError);
|
||||
assert.equal(result.error.cause, error);
|
||||
assert.equal(find.mock.callCount(), 1);
|
||||
assert.equal(create.mock.callCount(), 1);
|
||||
assert.equal(reread.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("fallo de mapper en lectura se propaga", async (t) => {
|
||||
const error = new Error("mapping failed");
|
||||
t.mock.method(InitialOnboardingModel, "findOne", async () => row());
|
||||
t.mock.method(mapper, "mapToDomain", () => Result.fail(error));
|
||||
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(accountId);
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(result.error, error);
|
||||
});
|
||||
|
||||
test("fallo de mapper en escritura evita consultar o escribir", async (t) => {
|
||||
const error = new Error("mapping failed");
|
||||
t.mock.method(mapper, "mapToPersistence", () => Result.fail(error));
|
||||
const find = t.mock.method(InitialOnboardingModel, "findByPk");
|
||||
const create = t.mock.method(InitialOnboardingModel, "create");
|
||||
const result = await new SequelizeInitialOnboardingRepository(mapper).save(aggregate());
|
||||
assert.equal(result.isFailure, true);
|
||||
assert.equal(result.error, error);
|
||||
assert.equal(find.mock.callCount(), 0);
|
||||
assert.equal(create.mock.callCount(), 0);
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
import { translateSequelizeError } from "@erp/core/api";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import type { Transaction } from "sequelize";
|
||||
|
||||
import type { IInitialOnboardingRepository } from "../../../../application/repositories/initial-onboarding-repository.interface";
|
||||
import type { InitialOnboarding } from "../../../../domain";
|
||||
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
|
||||
import { InitialOnboardingModel } from "../models";
|
||||
|
||||
export class SequelizeInitialOnboardingRepository implements IInitialOnboardingRepository {
|
||||
public constructor(
|
||||
private readonly domainMapper = new SequelizeInitialOnboardingDomainMapper()
|
||||
) {}
|
||||
|
||||
public async findByAccountId(
|
||||
accountId: UniqueID,
|
||||
transaction?: unknown
|
||||
): Promise<Result<Maybe<InitialOnboarding>, Error>> {
|
||||
try {
|
||||
const row = await InitialOnboardingModel.findOne({
|
||||
where: { account_id: accountId.toPrimitive() },
|
||||
transaction: transaction as Transaction | undefined,
|
||||
});
|
||||
if (!row) return Result.ok(Maybe.none<InitialOnboarding>());
|
||||
return this.domainMapper.mapToDomain(row).map((value) => Maybe.some(value));
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(translateSequelizeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
public async save(
|
||||
initialOnboarding: InitialOnboarding,
|
||||
transaction?: unknown
|
||||
): Promise<Result<void, Error>> {
|
||||
try {
|
||||
const persistence = this.domainMapper.mapToPersistence(initialOnboarding);
|
||||
if (persistence.isFailure) return Result.fail(persistence.error);
|
||||
|
||||
const sqlTransaction = transaction as Transaction | undefined;
|
||||
const { id, ...values } = persistence.data;
|
||||
// Comprobar por id evita que un upsert MySQL absorba una colisión de account_id.
|
||||
const existing = await InitialOnboardingModel.findByPk(id, {
|
||||
transaction: sqlTransaction,
|
||||
});
|
||||
// El agregado proporciona los timestamps; Sequelize no debe reemplazar updated_at.
|
||||
const options = { transaction: sqlTransaction, silent: true };
|
||||
if (existing) {
|
||||
await InitialOnboardingModel.update(values, { ...options, where: { id } });
|
||||
} else {
|
||||
await InitialOnboardingModel.create(persistence.data, options);
|
||||
}
|
||||
return Result.ok();
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(translateSequelizeError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user