Compare commits

..

2 Commits

Author SHA1 Message Date
dd64a01622 Formateo de codigo fuente 2026-10-10 12:01:58 +02:00
2c827b8a7d feat: add ESLint configuration for vertical spacing and stylistic rules
- Introduced `.lintrc` for managing vertical spacing rules in the codebase.
- Updated `eslint.config.mjs` to integrate the new spacing rules with ESLint.
- Created documentation on code formatting and spacing guidelines in `formato-y-espaciado-del-codigo.md`.
- Implemented tests in `spacing.test.mjs` to ensure spacing rules are enforced correctly without altering syntax.
2026-10-10 12:00:17 +02:00
1011 changed files with 7943 additions and 57 deletions

124
.lintrc Normal file
View File

@ -0,0 +1,124 @@
{
"rules": {
"@stylistic/padding-line-between-statements": [
"error",
{
"blankLine": "always",
"prev": "*",
"next": [
"function",
"class",
"interface",
"type",
"enum",
"export",
{
"selector": "VariableDeclaration:has(VariableDeclarator > :matches(ArrowFunctionExpression, FunctionExpression))"
}
]
},
{
"blankLine": "always",
"prev": [
"function",
"class",
"interface",
"type",
"enum",
"export",
{
"selector": "VariableDeclaration:has(VariableDeclarator > :matches(ArrowFunctionExpression, FunctionExpression))"
}
],
"next": "*"
},
{
"blankLine": "always",
"prev": "import",
"next": "*"
},
{
"blankLine": "any",
"prev": "import",
"next": "import"
},
{
"blankLine": "always",
"prev": [
"const",
"let",
"var"
],
"next": "*"
},
{
"blankLine": "any",
"prev": [
"const",
"let",
"var"
],
"next": [
"const",
"let",
"var"
]
},
{
"blankLine": "always",
"prev": "*",
"next": "return"
},
{
"blankLine": "always",
"prev": {
"selector": "VariableDeclaration:has(VariableDeclarator > :matches(ArrowFunctionExpression, FunctionExpression))"
},
"next": "*"
},
{
"blankLine": "always",
"prev": "*",
"next": {
"selector": "VariableDeclaration:has(VariableDeclarator > :matches(ArrowFunctionExpression, FunctionExpression))"
}
},
{
"blankLine": "any",
"prev": "function-overload",
"next": [
"function-overload",
"function"
]
}
],
"@stylistic/lines-between-class-members": [
"error",
{
"enforce": [
{
"blankLine": "always",
"prev": "method",
"next": "*"
},
{
"blankLine": "always",
"prev": "*",
"next": "method"
}
]
},
{
"exceptAfterOverload": true
}
],
"@stylistic/no-multiple-empty-lines": [
"error",
{
"max": 1,
"maxBOF": 0,
"maxEOF": 0
}
]
}
}

View File

@ -13,6 +13,8 @@ No imprimir, copiar ni versionar .env locales o secretos.
## Convenciones ## Convenciones
El espaciado vertical se define en `.lintrc`, cargado por `eslint.config.mjs`. Separar funciones, clases y métodos con una línea vacía; mantener agrupadas propiedades y firmas de sobrecarga. `pnpm format` combina Biome con estas reglas. Véase [formato y espaciado](docs/formato-y-espaciado-del-codigo.md).
- Código e identificadores en inglés; comentarios, TSDoc y documentación en castellano. - Código e identificadores en inglés; comentarios, TSDoc y documentación en castellano.
- Ficheros y directorios en kebab-case. Clases, tipos e interfaces en PascalCase. - Ficheros y directorios en kebab-case. Clases, tipos e interfaces en PascalCase.
- Variables, funciones y métodos en camelCase; constantes globales en UPPER_SNAKE_CASE. - Variables, funciones y métodos en camelCase; constantes globales en UPPER_SNAKE_CASE.

View File

@ -2,28 +2,37 @@ import assert from "node:assert/strict";
import type { AccessFieldsDTO, AccessItemDTO, CompanyDTO } from "@metis/companies/common"; import type { AccessFieldsDTO, AccessItemDTO, CompanyDTO } from "@metis/companies/common";
import { AccessResponseSchema } from "@metis/companies/common"; import { AccessResponseSchema } from "@metis/companies/common";
export async function verifyAccess( export async function verifyAccess(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>, request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
company: CompanyDTO, company: CompanyDTO,
other: CompanyDTO other: CompanyDTO
) { ) {
const base = `/access/${company.id}`; const base = `/access/${company.id}`;
async function create(scope: string, values: AccessFieldsDTO) { async function create(scope: string, values: AccessFieldsDTO) {
const response = await request(`/access/${scope}`, { values }); const response = await request(`/access/${scope}`, { values });
assert.equal(response.status, 201, JSON.stringify(await response.clone().json())); assert.equal(response.status, 201, JSON.stringify(await response.clone().json()));
const result = AccessResponseSchema.parse(await response.json()); const result = AccessResponseSchema.parse(await response.json());
const item = result.items.at(-1); const item = result.items.at(-1);
assert.ok(item); assert.ok(item);
assert.equal(item.values.kind, values.kind); assert.equal(item.values.kind, values.kind);
assert.equal(item.id[14], "7"); assert.equal(item.id[14], "7");
assert.equal(item.history.length, 1); assert.equal(item.history.length, 1);
return item; return item;
} }
async function update(item: AccessItemDTO, values: AccessFieldsDTO) { async function update(item: AccessItemDTO, values: AccessFieldsDTO) {
const response = await request(`${base}/${item.id}/update`, { values }); const response = await request(`${base}/${item.id}/update`, { values });
assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); assert.equal(response.status, 200, JSON.stringify(await response.clone().json()));
return AccessResponseSchema.parse(await response.json()); return AccessResponseSchema.parse(await response.json());
} }
const system = await create("global", { kind: "system", name: "Directorio", active: true }); const system = await create("global", { kind: "system", name: "Directorio", active: true });
const resourceType = await create("global", { const resourceType = await create("global", {
kind: "resource_type", kind: "resource_type",
@ -44,6 +53,7 @@ export async function verifyAccess(
systemId: system.id, systemId: system.id,
active: true, active: true,
}); });
assert.equal( assert.equal(
(await request(base, { values: { kind: "system", name: " DIRECTORIO ", active: false } })) (await request(base, { values: { kind: "system", name: " DIRECTORIO ", active: false } }))
.status, .status,
@ -110,6 +120,7 @@ export async function verifyAccess(
notes: null, notes: null,
}; };
const account = await create(company.id, accountValues); const account = await create(company.id, accountValues);
assert.equal((await request(base, { values: accountValues })).status, 409); assert.equal((await request(base, { values: accountValues })).status, 409);
assert.equal( assert.equal(
( (
@ -141,10 +152,13 @@ export async function verifyAccess(
); );
const renamed = { ...accountValues, login: "ana.garcia" }; const renamed = { ...accountValues, login: "ana.garcia" };
const changed = await update(account, renamed); const changed = await update(account, renamed);
assert.equal(changed.items.find((item) => item.id === account.id)?.history.length, 2); assert.equal(changed.items.find((item) => item.id === account.id)?.history.length, 2);
const repeated = await update(account, renamed); const repeated = await update(account, renamed);
assert.equal(repeated.items.find((item) => item.id === account.id)?.history.length, 2); assert.equal(repeated.items.find((item) => item.id === account.id)?.history.length, 2);
const person = company.people.find((item) => item.active); const person = company.people.find((item) => item.active);
assert.ok(person); assert.ok(person);
const relationValues: AccessFieldsDTO = { const relationValues: AccessFieldsDTO = {
kind: "account_person", kind: "account_person",
@ -155,8 +169,10 @@ export async function verifyAccess(
configuration: "configured", configuration: "configured",
}; };
const relation = await create(company.id, relationValues); const relation = await create(company.id, relationValues);
assert.equal((await request(base, { values: relationValues })).status, 409); assert.equal((await request(base, { values: relationValues })).status, 409);
const foreignPerson = other.people[0]; const foreignPerson = other.people[0];
if (foreignPerson) { if (foreignPerson) {
assert.equal( assert.equal(
(await request(base, { values: { ...relationValues, personId: foreignPerson.id } })).status, (await request(base, { values: { ...relationValues, personId: foreignPerson.id } })).status,
@ -215,6 +231,7 @@ export async function verifyAccess(
}; };
const need = await create(company.id, needValues); const need = await create(company.id, needValues);
let response = AccessResponseSchema.parse(await (await request(base)).json()); let response = AccessResponseSchema.parse(await (await request(base)).json());
assert.equal(response.coverage.find((item) => item.needId === need.id)?.covered, false); assert.equal(response.coverage.find((item) => item.needId === need.id)?.covered, false);
assert.ok( assert.ok(
response.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified") response.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified")
@ -238,6 +255,7 @@ export async function verifyAccess(
...relationValues, ...relationValues,
configuration: "pending", configuration: "pending",
}); });
assert.equal( assert.equal(
awaitingConfiguration.coverage.find((item) => item.needId === need.id)?.covered, awaitingConfiguration.coverage.find((item) => item.needId === need.id)?.covered,
false false
@ -257,6 +275,7 @@ export async function verifyAccess(
active: true, active: true,
}); });
const differentNeed = await update(need, { ...needValues, permissionIds: [readPermission.id] }); const differentNeed = await update(need, { ...needValues, permissionIds: [readPermission.id] });
assert.equal(differentNeed.coverage.find((item) => item.needId === need.id)?.covered, false); assert.equal(differentNeed.coverage.find((item) => item.needId === need.id)?.covered, false);
assert.ok( assert.ok(
differentNeed.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified") differentNeed.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified")
@ -267,6 +286,7 @@ export async function verifyAccess(
endDate: "2026-10-03", endDate: "2026-10-03",
configuration: "removal_pending", configuration: "removal_pending",
}); });
assert.equal(closed.coverage.find((item) => item.needId === need.id)?.covered, false); assert.equal(closed.coverage.find((item) => item.needId === need.id)?.covered, false);
assert.ok( assert.ok(
closed.reviews.some( closed.reviews.some(
@ -278,6 +298,7 @@ export async function verifyAccess(
endDate: "2026-10-03", endDate: "2026-10-03",
configuration: "removed", configuration: "removed",
}); });
assert.equal( assert.equal(
removed.reviews.some( removed.reviews.some(
(item) => item.itemId === membership.id && item.reason === "pending_removal" (item) => item.itemId === membership.id && item.reason === "pending_removal"
@ -292,6 +313,7 @@ export async function verifyAccess(
true true
); );
const expired = await update(account, { ...renamed, expiresOn: "2000-01-01" }); const expired = await update(account, { ...renamed, expiresOn: "2000-01-01" });
assert.ok( assert.ok(
expired.reviews.some((item) => item.itemId === account.id && item.reason === "expired") expired.reviews.some((item) => item.itemId === account.id && item.reason === "expired")
); );
@ -306,6 +328,7 @@ export async function verifyAccess(
active: false, active: false,
expiresOn: "2000-01-01", expiresOn: "2000-01-01",
}); });
await create(company.id, { ...relationValues, accountId: technical.id, configuration: null }); await create(company.id, { ...relationValues, accountId: technical.id, configuration: null });
const otherTechnical = await create(company.id, { const otherTechnical = await create(company.id, {
...accountValues, ...accountValues,
@ -313,6 +336,7 @@ export async function verifyAccess(
usage: "technical", usage: "technical",
active: true, active: true,
}); });
assert.equal( assert.equal(
( (
await request(base, { await request(base, {
@ -323,6 +347,7 @@ export async function verifyAccess(
); );
const shared = await create(company.id, { ...accountValues, login: "shared", usage: "shared" }); const shared = await create(company.id, { ...accountValues, login: "shared", usage: "shared" });
const sharedResponse = AccessResponseSchema.parse(await (await request(base)).json()); const sharedResponse = AccessResponseSchema.parse(await (await request(base)).json());
assert.ok( assert.ok(
sharedResponse.reviews.some((item) => item.itemId === shared.id && item.reason === "incomplete") sharedResponse.reviews.some((item) => item.itemId === shared.id && item.reason === "incomplete")
); );
@ -330,10 +355,12 @@ export async function verifyAccess(
request(base, { values: { ...accountValues, login: "concurrent" } }), request(base, { values: { ...accountValues, login: "concurrent" } }),
request(base, { values: { ...accountValues, login: "concurrent" } }), request(base, { values: { ...accountValues, login: "concurrent" } }),
]); ]);
assert.deepEqual(concurrent.map((item) => item.status).sort(), [201, 409]); assert.deepEqual(concurrent.map((item) => item.status).sort(), [201, 409]);
const otherScope = AccessResponseSchema.parse( const otherScope = AccessResponseSchema.parse(
await (await request(`/access/${other.id}`)).json() await (await request(`/access/${other.id}`)).json()
); );
assert.equal( assert.equal(
otherScope.items.some((item) => item.id === account.id), otherScope.items.some((item) => item.id === account.id),
false false
@ -343,6 +370,7 @@ export async function verifyAccess(
true true
); );
const global = AccessResponseSchema.parse(await (await request("/access/global")).json()); const global = AccessResponseSchema.parse(await (await request("/access/global")).json());
assert.equal( assert.equal(
global.items.every((item) => item.companyId === null), global.items.every((item) => item.companyId === null),
true true

View File

@ -10,14 +10,18 @@ import type { HttpLogEntry } from "./infrastructure/http/http-logger";
test("HTTP real: estado, contrato, errores y trazas sin datos sensibles", async () => { test("HTTP real: estado, contrato, errores y trazas sin datos sensibles", async () => {
const entries: HttpLogEntry[] = []; const entries: HttpLogEntry[] = [];
const server = createApp((entry) => entries.push(entry)).listen(0, "127.0.0.1"); const server = createApp((entry) => entries.push(entry)).listen(0, "127.0.0.1");
try { try {
await once(server, "listening"); await once(server, "listening");
const address = server.address(); const address = server.address();
assert.ok(address && typeof address !== "string"); assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}`; const baseUrl = `http://127.0.0.1:${address.port}`;
const response = await fetch(`${baseUrl}/api/__health`); const response = await fetch(`${baseUrl}/api/__health`);
assert.equal(response.status, 200); assert.equal(response.status, 200);
const health = GetHealthResponseSchema.parse(await response.json()); const health = GetHealthResponseSchema.parse(await response.json());
assert.equal(health.status, "ok"); assert.equal(health.status, "ok");
assert.equal(response.headers.get("x-powered-by"), null); assert.equal(response.headers.get("x-powered-by"), null);
assert.equal(response.headers.get("cache-control"), "no-store"); assert.equal(response.headers.get("cache-control"), "no-store");
@ -28,8 +32,10 @@ test("HTTP real: estado, contrato, errores y trazas sin datos sensibles", async
["/api/customers", 404, "NOT_FOUND"], ["/api/customers", 404, "NOT_FOUND"],
] as const) { ] as const) {
const failure = await fetch(`${baseUrl}${path}`); const failure = await fetch(`${baseUrl}${path}`);
assert.equal(failure.status, status); assert.equal(failure.status, status);
const body = HttpErrorResponseSchema.parse(await failure.json()); const body = HttpErrorResponseSchema.parse(await failure.json());
assert.equal(body.code, code); assert.equal(body.code, code);
assert.equal(body.requestId, failure.headers.get("x-request-id")); assert.equal(body.requestId, failure.headers.get("x-request-id"));
} }
@ -55,11 +61,14 @@ test("HTTP real: entrada JSON inválida y errores internos conservan un contrato
}); });
}, },
}).listen(0, "127.0.0.1"); }).listen(0, "127.0.0.1");
try { try {
await once(server, "listening"); await once(server, "listening");
const address = server.address(); const address = server.address();
assert.ok(address && typeof address !== "string"); assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}`; const baseUrl = `http://127.0.0.1:${address.port}`;
for (const [body, contentType, status] of [ for (const [body, contentType, status] of [
['{"token":"secret-test-value",', "application/json", 400], ['{"token":"secret-test-value",', "application/json", 400],
[JSON.stringify({ value: "x".repeat(64 * 1024) }), "application/json", 413], [JSON.stringify({ value: "x".repeat(64 * 1024) }), "application/json", 413],
@ -70,14 +79,18 @@ test("HTTP real: entrada JSON inválida y errores internos conservan un contrato
headers: { "content-type": contentType }, headers: { "content-type": contentType },
body, body,
}); });
assert.equal(response.status, status); assert.equal(response.status, status);
const error = HttpErrorResponseSchema.parse(await response.json()); const error = HttpErrorResponseSchema.parse(await response.json());
assert.equal(error.code, "INVALID_REQUEST"); assert.equal(error.code, "INVALID_REQUEST");
assert.equal(error.requestId, response.headers.get("x-request-id")); assert.equal(error.requestId, response.headers.get("x-request-id"));
} }
const response = await fetch(`${baseUrl}/api/failure`); const response = await fetch(`${baseUrl}/api/failure`);
assert.equal(response.status, 500); assert.equal(response.status, 500);
const error = HttpErrorResponseSchema.parse(await response.json()); const error = HttpErrorResponseSchema.parse(await response.json());
assert.equal(error.code, "INTERNAL_ERROR"); assert.equal(error.code, "INTERNAL_ERROR");
assert.equal(error.requestId, response.headers.get("x-request-id")); assert.equal(error.requestId, response.headers.get("x-request-id"));
assert.equal(entries.length, 4); assert.equal(entries.length, 4);

View File

@ -14,11 +14,13 @@ export function createApp(
identity?: { middleware: RequestHandler; register: (app: Application) => void } identity?: { middleware: RequestHandler; register: (app: Application) => void }
) { ) {
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
app.use(helmet()); app.use(helmet());
app.use((request, response, next) => { app.use((request, response, next) => {
const requestId = randomUUID(); const requestId = randomUUID();
const startedAt = performance.now(); const startedAt = performance.now();
response.setHeader("x-request-id", requestId); response.setHeader("x-request-id", requestId);
response.setHeader("cache-control", "no-store"); response.setHeader("cache-control", "no-store");
response.once("finish", () => { response.once("finish", () => {
@ -45,11 +47,14 @@ export function createApp(
code: "NOT_FOUND", code: "NOT_FOUND",
requestId: response.getHeader("x-request-id")?.toString() ?? "", requestId: response.getHeader("x-request-id")?.toString() ?? "",
}; };
response.status(404).json(error); response.status(404).json(error);
}); });
const errorHandler: ErrorRequestHandler = (failure: unknown, _request, response, next) => { const errorHandler: ErrorRequestHandler = (failure: unknown, _request, response, next) => {
if (response.headersSent) { if (response.headersSent) {
next(failure); next(failure);
return; return;
} }
const status = bodyFailureStatus(failure); const status = bodyFailureStatus(failure);
@ -57,9 +62,12 @@ export function createApp(
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST", code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
requestId: response.getHeader("x-request-id")?.toString() ?? "", requestId: response.getHeader("x-request-id")?.toString() ?? "",
}; };
response.status(status).json(error); response.status(status).json(error);
}; };
app.use(errorHandler); app.use(errorHandler);
return app; return app;
} }

View File

@ -14,6 +14,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyApplications( export async function verifyApplications(
request: Request, request: Request,
companyId: string, companyId: string,
@ -28,11 +29,14 @@ export async function verifyApplications(
active: true, active: true,
isContact: false, isContact: false,
}); });
assert.equal(personResponse.status, 201); assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()); const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json());
const host = assets.assets.find((item) => item.values.status !== "retired"); const host = assets.assets.find((item) => item.values.status !== "retired");
assert.ok(host); assert.ok(host);
const profileValues = { const profileValues = {
name: "Perfil aplicaciones", name: "Perfil aplicaciones",
@ -43,27 +47,35 @@ export async function verifyApplications(
notes: null, notes: null,
}; };
const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues }); const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues });
assert.equal(profileResponse.status, 201); assert.equal(profileResponse.status, 201);
const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile; const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile;
const catalog = CatalogResponseSchema.parse( const catalog = CatalogResponseSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json() await (await request(`/resource-catalog/${companyId}`)).json()
); );
const template = catalog.items.find((item) => item.builtinKey === "template:application"); const template = catalog.items.find((item) => item.builtinKey === "template:application");
assert.ok(template); assert.ok(template);
assert.equal(template.values.kind, "template"); assert.equal(template.values.kind, "template");
const add = async (templateId: string, name: string) => { const add = async (templateId: string, name: string) => {
const response = await request(`/resources/${companyId}`, { const response = await request(`/resources/${companyId}`, {
values: { name, templateId, active: true, notes: "", characteristics: {} }, values: { name, templateId, active: true, notes: "", characteristics: {} },
}); });
assert.equal(response.status, 201); assert.equal(response.status, 201);
return ResourceResponseSchema.parse(await response.json()).resource; return ResourceResponseSchema.parse(await response.json()).resource;
}; };
const app = await add(template.id, "Thunderbird prueba"); const app = await add(template.id, "Thunderbird prueba");
const certificateTemplate = catalog.items.find( const certificateTemplate = catalog.items.find(
(item) => item.builtinKey === "template:certificate" (item) => item.builtinKey === "template:certificate"
); );
assert.ok(certificateTemplate); assert.ok(certificateTemplate);
const certificate = await add(certificateTemplate.id, "Certificado prueba aplicaciones"); const certificate = await add(certificateTemplate.id, "Certificado prueba aplicaciones");
assert.ok(template.values.kind === "template"); assert.ok(template.values.kind === "template");
const customValues = { const customValues = {
...template.values, ...template.values,
@ -71,15 +83,18 @@ export async function verifyApplications(
usage: "application" as const, usage: "application" as const,
}; };
const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues }); const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues });
assert.equal(customResponse.status, 201); assert.equal(customResponse.status, 201);
const custom = CatalogResponseSchema.parse( const custom = CatalogResponseSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json() await (await request(`/resource-catalog/${companyId}`)).json()
).items.find((item) => item.values.name === customValues.name); ).items.find((item) => item.values.name === customValues.name);
assert.ok(custom); assert.ok(custom);
const customApp = await add(custom.id, "ERP navegador prueba"); const customApp = await add(custom.id, "ERP navegador prueba");
const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, { const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, {
values: { ...customValues, usage: "certificate" }, values: { ...customValues, usage: "certificate" },
}); });
assert.equal(immutable.status, 409); assert.equal(immutable.status, 409);
const path = `/application-configurations/${companyId}`; const path = `/application-configurations/${companyId}`;
const input: ApplicationConfigurationInput = { const input: ApplicationConfigurationInput = {
@ -90,16 +105,22 @@ export async function verifyApplications(
state: "pending", state: "pending",
notes: null, notes: null,
}; };
const save = async (values: ApplicationConfigurationInput, id?: string) => { const save = async (values: ApplicationConfigurationInput, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201); assert.equal(response.status, id ? 200 : 201);
return ApplicationConfigurationResponseSchema.parse(await response.json()).configuration; return ApplicationConfigurationResponseSchema.parse(await response.json()).configuration;
}; };
const error = async (values: unknown, code: string, id?: string, status = 409) => { const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
await error( await error(
{ ...input, resourceId: randomUUID() }, { ...input, resourceId: randomUUID() },
@ -120,10 +141,13 @@ export async function verifyApplications(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: input }), request(path, { values: input }),
]); ]);
assert.deepEqual(attempts.map((r) => r.status).sort(), [201, 409]); assert.deepEqual(attempts.map((r) => r.status).sort(), [201, 409]);
const response = attempts.find((r) => r.status === 201); const response = attempts.find((r) => r.status === 201);
assert.ok(response); assert.ok(response);
const created = ApplicationConfigurationResponseSchema.parse(await response.json()).configuration; const created = ApplicationConfigurationResponseSchema.parse(await response.json()).configuration;
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7"); assert.equal(created.history[0]?.id[14], "7");
assert.deepEqual(await save(input, created.id), created); assert.deepEqual(await save(input, created.id), created);
@ -131,6 +155,7 @@ export async function verifyApplications(
{ ...input, state: "configured", version: "128", notes: "Solicitado por Ana" }, { ...input, state: "configured", version: "128", notes: "Solicitado por Ana" },
created.id created.id
); );
assert.equal(configured.history.length, 2); assert.equal(configured.history.length, 2);
assert.equal(configured.history[0]?.values.version, null); assert.equal(configured.history[0]?.values.version, null);
await save({ ...input, resourceId: customApp.id, delivery: "web", state: "configured" }); await save({ ...input, resourceId: customApp.id, delivery: "web", state: "configured" });
@ -149,10 +174,12 @@ export async function verifyApplications(
const deactivate = await request(`/resources/${companyId}/${app.id}/update`, { const deactivate = await request(`/resources/${companyId}/${app.id}/update`, {
values: { ...app.values, active: false }, values: { ...app.values, active: false },
}); });
assert.equal(deactivate.status, 200); assert.equal(deactivate.status, 200);
const list = ApplicationConfigurationsResponseSchema.parse( const list = ApplicationConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations; ).configurations;
assert.equal(list.find((item) => item.id === created.id)?.needsReview, true); assert.equal(list.find((item) => item.id === created.id)?.needsReview, true);
await error( await error(
{ ...configured.values, version: "129" }, { ...configured.values, version: "129" },
@ -160,9 +187,11 @@ export async function verifyApplications(
created.id created.id
); );
const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id); const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id);
assert.equal(noted.history.length, 3); assert.equal(noted.history.length, 3);
const removal = await save({ ...noted.values, state: "removal_pending" }, created.id); const removal = await save({ ...noted.values, state: "removal_pending" }, created.id);
const removed = await save({ ...removal.values, state: "removed" }, created.id); const removed = await save({ ...removal.values, state: "removed" }, created.id);
assert.equal(removed.needsReview, false); assert.equal(removed.needsReview, false);
await error({ ...removed.values, notes: "Otra" }, "INVALID_APPLICATION_TRANSITION", created.id); await error({ ...removed.values, notes: "Otra" }, "INVALID_APPLICATION_TRANSITION", created.id);
assert.equal( assert.equal(
@ -191,12 +220,15 @@ export async function verifyApplications(
const secondPerson = CompanyResponseSchema.parse( const secondPerson = CompanyResponseSchema.parse(
await secondPersonResponse.json() await secondPersonResponse.json()
).company.people.at(-1); ).company.people.at(-1);
assert.ok(secondPerson); assert.ok(secondPerson);
const secondProfile = await request(`/work-profiles/${companyId}`, { const secondProfile = await request(`/work-profiles/${companyId}`, {
values: { ...profileValues, name: "Otro perfil aplicaciones", personId: secondPerson.id }, values: { ...profileValues, name: "Otro perfil aplicaciones", personId: secondPerson.id },
}); });
assert.equal(secondProfile.status, 201); assert.equal(secondProfile.status, 201);
const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id; const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id;
await save({ ...input, profileId: secondId, resourceId: customApp.id, delivery: "web" }); await save({ ...input, profileId: secondId, resourceId: customApp.id, delivery: "web" });
const archive = await request(`/work-profiles/${companyId}/${secondId}/update`, { const archive = await request(`/work-profiles/${companyId}/${secondId}/update`, {
values: { values: {
@ -206,9 +238,11 @@ export async function verifyApplications(
active: false, active: false,
}, },
}); });
assert.equal(archive.status, 200); assert.equal(archive.status, 200);
const reviewed = ApplicationConfigurationsResponseSchema.parse( const reviewed = ApplicationConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations; ).configurations;
assert.equal(reviewed.find((item) => item.values.profileId === secondId)?.needsReview, true); assert.equal(reviewed.find((item) => item.values.profileId === secondId)?.needsReview, true);
} }

View File

@ -23,6 +23,7 @@ export async function verifyAssetNormalization(
companyId: string companyId: string
) { ) {
const fieldIds: string[] = []; const fieldIds: string[] = [];
for (const [type, value] of [ for (const [type, value] of [
["text", "Texto ñ"], ["text", "Texto ñ"],
["number", 0], ["number", 0],
@ -40,6 +41,7 @@ export async function verifyAssetNormalization(
choices: type === "choice" ? ["A", "B"] : [], choices: type === "choice" ? ["A", "B"] : [],
}, },
}); });
assert.equal(response.status, 201); assert.equal(response.status, 201);
fieldIds.push(SaveDefinitionResponseSchema.parse(await response.json()).savedId); fieldIds.push(SaveDefinitionResponseSchema.parse(await response.json()).savedId);
assert.ok(value !== undefined); assert.ok(value !== undefined);
@ -65,6 +67,7 @@ export async function verifyAssetNormalization(
), ),
}, },
}); });
assert.equal(assetResponse.status, 201); assert.equal(assetResponse.status, 201);
const created = AssetResponseSchema.parse(await assetResponse.json()).asset; const created = AssetResponseSchema.parse(await assetResponse.json()).asset;
const catalogRoots = await database.query<{ id: string }>("SELECT id FROM asset_catalogs", { const catalogRoots = await database.query<{ id: string }>("SELECT id FROM asset_catalogs", {
@ -98,16 +101,19 @@ export async function verifyAssetNormalization(
"SELECT id,sourceId,targetId,typeId,name,inverseName,symmetric,notes FROM asset_relationships ORDER BY id", "SELECT id,sourceId,targetId,typeId,name,inverseName,symmetric,notes FROM asset_relationships ORDER BY id",
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
); );
// Recrear el origen anterior solo en la base dedicada de pruebas, conservando sus UUID. // Recrear el origen anterior solo en la base dedicada de pruebas, conservando sus UUID.
assert.equal(database.getDatabaseName(), "metis_desk_test"); assert.equal(database.getDatabaseName(), "metis_desk_test");
for (const table of ["asset_definitions", "assets", "asset_revisions", "asset_relationships"]) { for (const table of ["asset_definitions", "assets", "asset_revisions", "asset_relationships"]) {
await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`); await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`);
} }
await database.query("ALTER TABLE assets ADD COLUMN specification JSON NULL"); await database.query("ALTER TABLE assets ADD COLUMN specification JSON NULL");
const store = async (table: string, id: string, values: unknown) => const store = async (table: string, id: string, values: unknown) =>
database.query(`UPDATE ${table} SET \`values\`=:values WHERE id=:id`, { database.query(`UPDATE ${table} SET \`values\`=:values WHERE id=:id`, {
replacements: { id, values: JSON.stringify(values) }, replacements: { id, values: JSON.stringify(values) },
}); });
for (const definition of before.definitions) { for (const definition of before.definitions) {
await store("asset_definitions", definition.id, definition.values); await store("asset_definitions", definition.id, definition.values);
} }
@ -126,6 +132,7 @@ export async function verifyAssetNormalization(
await store("asset_relationships", id, { ...values, symmetric: Boolean(values.symmetric) }); await store("asset_relationships", id, { ...values, symmetric: Boolean(values.symmetric) });
} }
const first = before.definitions[0]; const first = before.definitions[0];
assert.ok(first); assert.ok(first);
await store("asset_definitions", first.id, { kind: "invalid" }); await store("asset_definitions", first.id, { kind: "invalid" });
await assert.rejects(normalizeAssetCatalog(database)); await assert.rejects(normalizeAssetCatalog(database));
@ -145,6 +152,7 @@ export async function verifyAssetNormalization(
await database.transaction(async (transaction) => { await database.transaction(async (transaction) => {
for (const root of catalogRoots) { for (const root of catalogRoots) {
const actual = await readDefinitions(database, root.id, transaction); const actual = await readDefinitions(database, root.id, transaction);
assert.deepEqual( assert.deepEqual(
actual.sort((a, b) => a.id.localeCompare(b.id)), actual.sort((a, b) => a.id.localeCompare(b.id)),
before.definitions before.definitions
@ -153,8 +161,10 @@ export async function verifyAssetNormalization(
); );
} }
const mapper = new AssetPersistenceMapper(); const mapper = new AssetPersistenceMapper();
for (const expected of before.inventories) { for (const expected of before.inventories) {
const actual = await readInventory(database, expected.companyId, transaction); const actual = await readInventory(database, expected.companyId, transaction);
const project = (inventory: typeof actual) => const project = (inventory: typeof actual) =>
inventory.rows inventory.rows
.map((row) => .map((row) =>
@ -166,6 +176,7 @@ export async function verifyAssetNormalization(
) )
) )
.sort((a, b) => a.asset.id.localeCompare(b.asset.id)); .sort((a, b) => a.asset.id.localeCompare(b.asset.id));
assert.deepEqual(project(actual), project(expected)); assert.deepEqual(project(actual), project(expected));
} }
}); });
@ -184,6 +195,7 @@ export async function verifyAssetNormalization(
} }
await normalizeAssetInventory(database); await normalizeAssetInventory(database);
const after = await request(`/assets/${companyId}`); const after = await request(`/assets/${companyId}`);
assert.equal(after.status, 200); assert.equal(after.status, 200);
assert.deepEqual( assert.deepEqual(
InventoryResponseSchema.parse(await after.json()).assets.find((item) => item.id === created.id), InventoryResponseSchema.parse(await after.json()).assets.find((item) => item.id === created.id),
@ -193,13 +205,16 @@ export async function verifyAssetNormalization(
"SELECT id,sourceId,targetId,typeId,name,inverseName,symmetric,notes FROM asset_relationships ORDER BY id", "SELECT id,sourceId,targetId,typeId,name,inverseName,symmetric,notes FROM asset_relationships ORDER BY id",
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
); );
assert.deepEqual(currentRelationships, relationships); assert.deepEqual(currentRelationships, relationships);
for (const table of ["asset_definitions", "assets", "asset_revisions", "asset_relationships"]) { for (const table of ["asset_definitions", "assets", "asset_revisions", "asset_relationships"]) {
const columns = await database.getQueryInterface().describeTable(table); const columns = await database.getQueryInterface().describeTable(table);
assert.equal(columns.values, undefined); assert.equal(columns.values, undefined);
assert.equal(columns.specification, undefined); assert.equal(columns.specification, undefined);
} }
const booleanField = fieldIds[2]; const booleanField = fieldIds[2];
assert.ok(booleanField); assert.ok(booleanField);
await assert.rejects( await assert.rejects(
database.query( database.query(

View File

@ -6,6 +6,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { UniqueID } from "../../../packages/rdx-ddd/src/index"; import { UniqueID } from "../../../packages/rdx-ddd/src/index";
export async function verifyAssets( export async function verifyAssets(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>, request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
companyId: string, companyId: string,
@ -15,23 +16,34 @@ export async function verifyAssets(
const global = "/asset-catalog/global"; const global = "/asset-catalog/global";
const company = `/asset-catalog/${companyId}`; const company = `/asset-catalog/${companyId}`;
const other = `/asset-catalog/${otherId}`; const other = `/asset-catalog/${otherId}`;
async function read(path: string) { async function read(path: string) {
const response = await request(path); const response = await request(path);
assert.equal(response.status, 200); assert.equal(response.status, 200);
return CatalogResponseSchema.parse(await response.json()); return CatalogResponseSchema.parse(await response.json());
} }
async function save(path: string, values: unknown) { async function save(path: string, values: unknown) {
const response = await request(path, { values }); const response = await request(path, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text()); assert.ok([200, 201].includes(response.status), await response.clone().text());
return SaveDefinitionResponseSchema.parse(await response.json()); return SaveDefinitionResponseSchema.parse(await response.json());
} }
async function error(path: string, body: unknown, code: string, status = 409) { async function error(path: string, body: unknown, code: string, status = 409) {
const response = await request(path, body); const response = await request(path, body);
assert.equal(response.status, status); assert.equal(response.status, status);
const data = z.object({ code: z.string() }).parse(await response.json()); const data = z.object({ code: z.string() }).parse(await response.json());
assert.equal(data.code, code); assert.equal(data.code, code);
} }
const initial = await read(global); const initial = await read(global);
assert.equal(initial.items.length, 47); assert.equal(initial.items.length, 47);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12); assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12);
assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7")); assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7"));
@ -41,6 +53,7 @@ export async function verifyAssets(
) )
); );
const stock = initial.items.find((item) => item.values.name === "Procesador"); const stock = initial.items.find((item) => item.values.name === "Procesador");
assert.ok(stock); assert.ok(stock);
await error( await error(
`${global}/${stock.id}/update`, `${global}/${stock.id}/update`,
@ -60,6 +73,7 @@ export async function verifyAssets(
assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200); assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200);
await createAssetModule(database, (_request, _response, next) => next()); await createAssetModule(database, (_request, _response, next) => next());
const restarted = await read(global); const restarted = await read(global);
assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false); assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false);
assert.deepEqual( assert.deepEqual(
restarted.items.map((item) => item.id).sort(), restarted.items.map((item) => item.id).sort(),
@ -88,6 +102,7 @@ export async function verifyAssets(
}; };
const local = await save(company, localValues); const local = await save(company, localValues);
const field = local.items.find((item) => item.id === local.savedId); const field = local.items.find((item) => item.id === local.savedId);
assert.ok(field); assert.ok(field);
assert.equal(field.companyId, companyId); assert.equal(field.companyId, companyId);
assert.equal(field.builtin, false); assert.equal(field.builtin, false);
@ -115,16 +130,19 @@ export async function verifyAssets(
{ fieldId: stock.id, required: false }, { fieldId: stock.id, required: false },
], ],
}; };
await error(global, { values: templateValues }, "FIELD_UNAVAILABLE"); await error(global, { values: templateValues }, "FIELD_UNAVAILABLE");
await error(other, { values: templateValues }, "FIELD_UNAVAILABLE"); await error(other, { values: templateValues }, "FIELD_UNAVAILABLE");
const template = await save(company, templateValues); const template = await save(company, templateValues);
const saved = template.items.find((item) => item.id === template.savedId); const saved = template.items.find((item) => item.id === template.savedId);
assert.ok(saved && saved.values.kind === "template"); assert.ok(saved && saved.values.kind === "template");
assert.deepEqual(saved.values.sections, [section]); assert.deepEqual(saved.values.sections, [section]);
const edited = await save(`${company}/${saved.id}/update`, { const edited = await save(`${company}/${saved.id}/update`, {
...templateValues, ...templateValues,
name: "Equipo especial actualizado", name: "Equipo especial actualizado",
}); });
assert.equal(edited.savedId, saved.id); assert.equal(edited.savedId, saved.id);
assert.deepEqual(edited.items.find((item) => item.id === saved.id)?.bindings, saved.bindings); assert.deepEqual(edited.items.find((item) => item.id === saved.id)?.bindings, saved.bindings);
await error( await error(
@ -139,6 +157,7 @@ export async function verifyAssets(
"INVALID_FIELDS" "INVALID_FIELDS"
); );
const status = await request(`${company}/${field.id}/status`, { active: false }); const status = await request(`${company}/${field.id}/status`, { active: false });
assert.equal(status.status, 200); assert.equal(status.status, 200);
await error( await error(
company, company,
@ -159,6 +178,7 @@ export async function verifyAssets(
choices: ["Buena", "Sustituir"], choices: ["Buena", "Sustituir"],
}; };
const custom = await save(global, list); const custom = await save(global, list);
await error( await error(
`${global}/${custom.savedId}/update`, `${global}/${custom.savedId}/update`,
{ values: { ...list, choices: ["Buena"] } }, { values: { ...list, choices: ["Buena"] } },
@ -170,16 +190,20 @@ export async function verifyAssets(
}); });
await error(global, { values: { ...list, name: "Sin valores", choices: [] } }, "INVALID_CHOICES"); await error(global, { values: { ...list, name: "Sin valores", choices: [] } }, "INVALID_CHOICES");
const monitor = initial.items.find((item) => item.values.name === "Monitor"); const monitor = initial.items.find((item) => item.values.name === "Monitor");
assert.ok(monitor && monitor.values.kind === "template"); assert.ok(monitor && monitor.values.kind === "template");
const binding = monitor.bindings[0]; const binding = monitor.bindings[0];
assert.ok(binding); assert.ok(binding);
try { try {
await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", { await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", {
replacements: { fieldId: field.id, id: binding.id }, replacements: { fieldId: field.id, id: binding.id },
}); });
const corrupt = await request(global); const corrupt = await request(global);
assert.equal(corrupt.status, 500); assert.equal(corrupt.status, 500);
const hidden = z.object({ code: z.string() }).parse(await corrupt.json()); const hidden = z.object({ code: z.string() }).parse(await corrupt.json());
assert.equal(hidden.code, "INTERNAL_ERROR"); assert.equal(hidden.code, "INTERNAL_ERROR");
} finally { } finally {
await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", { await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", {
@ -190,6 +214,7 @@ export async function verifyAssets(
request(company, { values: { ...localValues, name: "Concurrente" } }), request(company, { values: { ...localValues, name: "Concurrente" } }),
request(company, { values: { ...localValues, name: "Concurrente" } }), request(company, { values: { ...localValues, name: "Concurrente" } }),
]); ]);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
await error( await error(
`/asset-catalog/${"82be74bb-894f-4cca-ac7b-dedc96c15689"}`, `/asset-catalog/${"82be74bb-894f-4cca-ac7b-dedc96c15689"}`,
@ -218,6 +243,7 @@ export async function verifyAssets(
const groupedStored = (await read(global)).items.find( const groupedStored = (await read(global)).items.find(
(item) => item.id === groupedGlobal.savedId (item) => item.id === groupedGlobal.savedId
); );
assert.ok(groupedStored?.values.kind === "template"); assert.ok(groupedStored?.values.kind === "template");
assert.deepEqual(groupedStored.values.sections, [globalSection]); assert.deepEqual(groupedStored.values.sections, [globalSection]);
await error( await error(

View File

@ -17,6 +17,7 @@ import {
} from "../../../modules/assets/src/api/infrastructure/persistence/brand-migration"; } from "../../../modules/assets/src/api/infrastructure/persistence/brand-migration";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyBrands( export async function verifyBrands(
request: Request, request: Request,
companyId: string, companyId: string,
@ -24,26 +25,37 @@ export async function verifyBrands(
database: Sequelize database: Sequelize
) { ) {
const path = "/asset-brands"; const path = "/asset-brands";
async function list() { async function list() {
const response = await request(path); const response = await request(path);
assert.equal(response.status, 200); assert.equal(response.status, 200);
return BrandsResponseSchema.parse(await response.json()).brands; return BrandsResponseSchema.parse(await response.json()).brands;
} }
async function save(values: unknown, id?: string) { async function save(values: unknown, id?: string) {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text()); assert.equal(response.status, id ? 200 : 201, await response.clone().text());
return BrandResponseSchema.parse(await response.json()).brand; return BrandResponseSchema.parse(await response.json()).brand;
} }
async function error(url: string, body: unknown, status: number, code: string) { async function error(url: string, body: unknown, status: number, code: string) {
const response = await request(url, body); const response = await request(url, body);
assert.equal(response.status, status, await response.clone().text()); assert.equal(response.status, status, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
} }
const initial = await list(); const initial = await list();
for (const name of ["Microsoft", "HP", "LG", "Kyocera", "Zebra", "Logitech", "Clónico"]) { for (const name of ["Microsoft", "HP", "LG", "Kyocera", "Zebra", "Logitech", "Clónico"]) {
assert.ok(initial.some((brand) => brand.name === name && brand.builtin)); assert.ok(initial.some((brand) => brand.name === name && brand.builtin));
} }
const builtin = initial.find((brand) => brand.name === "LG"); const builtin = initial.find((brand) => brand.name === "LG");
assert.ok(builtin); assert.ok(builtin);
assert.ok( assert.ok(
initial.every((brand) => !brand.builtin || brand.name === "Clónico" || brand.logoSvg !== null) initial.every((brand) => !brand.builtin || brand.name === "Clónico" || brand.logoSvg !== null)
@ -51,6 +63,7 @@ export async function verifyBrands(
assert.equal(initial.find((brand) => brand.name === "Clónico")?.logoSvg, null); assert.equal(initial.find((brand) => brand.name === "Clónico")?.logoSvg, null);
const logoSvg = const logoSvg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M1 1h20v20H1z"/></svg>'; '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M1 1h20v20H1z"/></svg>';
await error( await error(
path, path,
{ {
@ -94,11 +107,13 @@ export async function verifyBrands(
for (const active of [true, false]) { for (const active of [true, false]) {
const unused = await save({ name: `Eliminar ${randomUUID()}`, active, logoSvg }); const unused = await save({ name: `Eliminar ${randomUUID()}`, active, logoSvg });
const url = `${path}/${unused.id}/delete`; const url = `${path}/${unused.id}/delete`;
await error(url, { unexpected: true }, 400, "INVALID_REQUEST"); await error(url, { unexpected: true }, 400, "INVALID_REQUEST");
await error(`${url}?unexpected=true`, {}, 400, "INVALID_REQUEST"); await error(`${url}?unexpected=true`, {}, 400, "INVALID_REQUEST");
assert.equal((await request(url, {}, undefined, "invalid")).status, 403); assert.equal((await request(url, {}, undefined, "invalid")).status, 403);
assert.ok((await list()).some((brand) => brand.id === unused.id)); assert.ok((await list()).some((brand) => brand.id === unused.id));
const response = await request(url, {}); const response = await request(url, {});
assert.equal(response.status, 200, await response.clone().text()); assert.equal(response.status, 200, await response.clone().text());
assert.deepEqual(BrandResponseSchema.parse(await response.json()).brand, unused); assert.deepEqual(BrandResponseSchema.parse(await response.json()).brand, unused);
await error(url, {}, 404, "BRAND_NOT_FOUND"); await error(url, {}, 404, "BRAND_NOT_FOUND");
@ -109,11 +124,13 @@ export async function verifyBrands(
false false
); );
const recreated = await save({ name: unused.name, active }); const recreated = await save({ name: unused.name, active });
assert.notEqual(recreated.id, unused.id); assert.notEqual(recreated.id, unused.id);
assert.equal((await request(`${path}/${recreated.id}/delete`, {})).status, 200); assert.equal((await request(`${path}/${recreated.id}/delete`, {})).status, 200);
} }
const name = `Marca prueba ${randomUUID()}`; const name = `Marca prueba ${randomUUID()}`;
const created = await save({ name: ` ${name} `, active: true, logoSvg }); const created = await save({ name: ` ${name} `, active: true, logoSvg });
assert.equal(created.name, name); assert.equal(created.name, name);
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.equal(created.builtin, false); assert.equal(created.builtin, false);
@ -124,13 +141,16 @@ export async function verifyBrands(
request(path, { values: { name: concurrentName, active: true } }), request(path, { values: { name: concurrentName, active: true } }),
request(path, { values: { name: concurrentName.toUpperCase(), active: true } }), request(path, { values: { name: concurrentName.toUpperCase(), active: true } }),
]); ]);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
const catalog = CatalogResponseSchema.parse( const catalog = CatalogResponseSchema.parse(
await (await request(`/asset-catalog/${companyId}`)).json() await (await request(`/asset-catalog/${companyId}`)).json()
); );
const template = catalog.items.find((item) => item.values.name === "Portátil"); const template = catalog.items.find((item) => item.values.name === "Portátil");
assert.ok(template); assert.ok(template);
const field = catalog.items.find((item) => item.referenceCatalog === "brands"); const field = catalog.items.find((item) => item.referenceCatalog === "brands");
assert.ok(field); assert.ok(field);
const values = { const values = {
name: "PORT-MARCA", name: "PORT-MARCA",
@ -141,24 +161,32 @@ export async function verifyBrands(
brandId: created.id, brandId: created.id,
characteristics: { [field.id]: "Nombre manipulado" }, characteristics: { [field.id]: "Nombre manipulado" },
}; };
async function asset(company: string, input: unknown, id?: string) { async function asset(company: string, input: unknown, id?: string) {
const response = await request(`/assets/${company}${id ? `/${id}/update` : ""}`, { const response = await request(`/assets/${company}${id ? `/${id}/update` : ""}`, {
values: input, values: input,
}); });
assert.equal(response.status, id ? 200 : 201, await response.clone().text()); assert.equal(response.status, id ? 200 : 201, await response.clone().text());
return AssetResponseSchema.parse(await response.json()).asset; return AssetResponseSchema.parse(await response.json()).asset;
} }
const first = await asset(companyId, values); const first = await asset(companyId, values);
assert.equal(first.values.brandId, created.id); assert.equal(first.values.brandId, created.id);
assert.equal(first.values.characteristics[field.id], name); assert.equal(first.values.characteristics[field.id], name);
const same = await asset(companyId, { ...first.values }, first.id); const same = await asset(companyId, { ...first.values }, first.id);
assert.equal(same.history.length, 1); assert.equal(same.history.length, 1);
const foreignCatalog = CatalogResponseSchema.parse( const foreignCatalog = CatalogResponseSchema.parse(
await (await request(`/asset-catalog/${otherId}`)).json() await (await request(`/asset-catalog/${otherId}`)).json()
); );
const foreignTemplate = foreignCatalog.items.find((item) => item.values.name === "Portátil"); const foreignTemplate = foreignCatalog.items.find((item) => item.values.name === "Portátil");
assert.ok(foreignTemplate); assert.ok(foreignTemplate);
const second = await asset(otherId, { ...values, templateId: foreignTemplate.id }); const second = await asset(otherId, { ...values, templateId: foreignTemplate.id });
assert.equal(second.values.brandId, created.id); assert.equal(second.values.brandId, created.id);
await error(`${path}/${created.id}/delete`, {}, 409, "BRAND_IN_USE"); await error(`${path}/${created.id}/delete`, {}, 409, "BRAND_IN_USE");
assert.ok((await list()).some((brand) => brand.id === created.id)); assert.ok((await list()).some((brand) => brand.id === created.id));
@ -170,9 +198,11 @@ export async function verifyBrands(
{ ...first.values, notes: "Conserva marca inactiva" }, { ...first.values, notes: "Conserva marca inactiva" },
first.id first.id
); );
assert.equal(retained.values.brandId, created.id); assert.equal(retained.values.brandId, created.id);
await save({ name: `${name} renombrada`, active: true }, created.id); await save({ name: `${name} renombrada`, active: true }, created.id);
const renamed = await asset(companyId, retained.values, first.id); const renamed = await asset(companyId, retained.values, first.id);
assert.equal(renamed.values.characteristics[field.id], `${name} renombrada`); assert.equal(renamed.values.characteristics[field.id], `${name} renombrada`);
assert.equal(renamed.history[0]?.values.characteristics[field.id], name); assert.equal(renamed.history[0]?.values.characteristics[field.id], name);
await error( await error(
@ -185,6 +215,7 @@ export async function verifyBrands(
"SELECT brandId FROM asset_revisions WHERE assetId=:id ORDER BY position", "SELECT brandId FROM asset_revisions WHERE assetId=:id ORDER BY position",
{ replacements: { id: first.id }, type: QueryTypes.SELECT } { replacements: { id: first.id }, type: QueryTypes.SELECT }
); );
assert.ok(snapshots.every((row) => row.brandId === created.id)); assert.ok(snapshots.every((row) => row.brandId === created.id));
const legacyName = `Antigua ${randomUUID()}`; const legacyName = `Antigua ${randomUUID()}`;
const legacyValues = { const legacyValues = {
@ -197,9 +228,11 @@ export async function verifyBrands(
"SELECT * FROM asset_revisions WHERE assetId=:id ORDER BY position", "SELECT * FROM asset_revisions WHERE assetId=:id ORDER BY position",
{ replacements: { id: legacy.id }, type: QueryTypes.SELECT } { replacements: { id: legacy.id }, type: QueryTypes.SELECT }
); );
await database.query("UPDATE brand_catalog_lock SET legacyImported=0 WHERE id=1"); await database.query("UPDATE brand_catalog_lock SET legacyImported=0 WHERE id=1");
await importLegacyBrands(database); await importLegacyBrands(database);
const imported = (await list()).find((brand) => brand.name === legacyName); const imported = (await list()).find((brand) => brand.name === legacyName);
assert.ok(imported); assert.ok(imported);
assert.equal(imported.builtin, false); assert.equal(imported.builtin, false);
await save({ name: `${legacyName} editada`, active: false }, imported.id); await save({ name: `${legacyName} editada`, active: false }, imported.id);
@ -216,14 +249,17 @@ export async function verifyBrands(
before before
); );
const linked = await asset(companyId, { ...legacy.values, brandId: builtin.id }, legacy.id); const linked = await asset(companyId, { ...legacy.values, brandId: builtin.id }, legacy.id);
assert.equal(linked.history.length, 2); assert.equal(linked.history.length, 2);
assert.equal(linked.history[0]?.values.characteristics[field.id], legacyName); assert.equal(linked.history[0]?.values.characteristics[field.id], legacyName);
assert.equal(linked.history[0]?.values.brandId, undefined); assert.equal(linked.history[0]?.values.brandId, undefined);
await save({ name: builtin.name, active: false }, builtin.id); await save({ name: builtin.name, active: false }, builtin.id);
const changed = await save({ name: builtin.name, active: false, logoSvg }, builtin.id); const changed = await save({ name: builtin.name, active: false, logoSvg }, builtin.id);
assert.equal(changed.logoSvg, logoSvg); assert.equal(changed.logoSvg, logoSvg);
await migrateBrands(database); await migrateBrands(database);
const after = (await list()).find((brand) => brand.id === builtin.id); const after = (await list()).find((brand) => brand.id === builtin.id);
assert.ok(after); assert.ok(after);
assert.equal(after.active, false); assert.equal(after.active, false);
assert.equal(after.logoSvg, logoSvg); assert.equal(after.logoSvg, logoSvg);
@ -242,6 +278,7 @@ export async function verifyBrands(
request(`/assets/${companyId}`, { values: { ...values, brandId: racing.id } }), request(`/assets/${companyId}`, { values: { ...values, brandId: racing.id } }),
request(`${path}/${racing.id}/delete`, {}), request(`${path}/${racing.id}/delete`, {}),
]); ]);
if (deletion.status === 200) { if (deletion.status === 200) {
assert.equal(association.status, 409, await association.clone().text()); assert.equal(association.status, 409, await association.clone().text());
assert.equal( assert.equal(

View File

@ -14,6 +14,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyCertificates( export async function verifyCertificates(
request: Request, request: Request,
companyId: string, companyId: string,
@ -28,11 +29,14 @@ export async function verifyCertificates(
active: true, active: true,
isContact: false, isContact: false,
}); });
assert.equal(personResponse.status, 201); assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()); const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json());
const host = assets.assets.find((item) => item.values.status !== "retired"); const host = assets.assets.find((item) => item.values.status !== "retired");
assert.ok(host); assert.ok(host);
const profileValues = { const profileValues = {
name: "Perfil certificados", name: "Perfil certificados",
@ -43,27 +47,35 @@ export async function verifyCertificates(
notes: null, notes: null,
}; };
const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues }); const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues });
assert.equal(profileResponse.status, 201); assert.equal(profileResponse.status, 201);
const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile; const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile;
const catalog = CatalogResponseSchema.parse( const catalog = CatalogResponseSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json() await (await request(`/resource-catalog/${companyId}`)).json()
); );
const template = catalog.items.find((item) => item.builtinKey === "template:certificate"); const template = catalog.items.find((item) => item.builtinKey === "template:certificate");
assert.ok(template); assert.ok(template);
assert.equal(template.values.kind, "template"); assert.equal(template.values.kind, "template");
const add = async (templateId: string, name: string) => { const add = async (templateId: string, name: string) => {
const response = await request(`/resources/${companyId}`, { const response = await request(`/resources/${companyId}`, {
values: { name, templateId, active: true, notes: "", characteristics: {} }, values: { name, templateId, active: true, notes: "", characteristics: {} },
}); });
assert.equal(response.status, 201); assert.equal(response.status, 201);
return ResourceResponseSchema.parse(await response.json()).resource; return ResourceResponseSchema.parse(await response.json()).resource;
}; };
const app = await add(template.id, "Certificado empresa prueba"); const app = await add(template.id, "Certificado empresa prueba");
const certificateTemplate = catalog.items.find( const certificateTemplate = catalog.items.find(
(item) => item.builtinKey === "template:application" (item) => item.builtinKey === "template:application"
); );
assert.ok(certificateTemplate); assert.ok(certificateTemplate);
const certificate = await add(certificateTemplate.id, "Aplicación ajena al certificado"); const certificate = await add(certificateTemplate.id, "Aplicación ajena al certificado");
assert.ok(template.values.kind === "template"); assert.ok(template.values.kind === "template");
const customValues = { const customValues = {
...template.values, ...template.values,
@ -71,15 +83,18 @@ export async function verifyCertificates(
usage: "certificate" as const, usage: "certificate" as const,
}; };
const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues }); const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues });
assert.equal(customResponse.status, 201); assert.equal(customResponse.status, 201);
const custom = CatalogResponseSchema.parse( const custom = CatalogResponseSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json() await (await request(`/resource-catalog/${companyId}`)).json()
).items.find((item) => item.values.name === customValues.name); ).items.find((item) => item.values.name === customValues.name);
assert.ok(custom); assert.ok(custom);
const customApp = await add(custom.id, "Certificado colaboración prueba"); const customApp = await add(custom.id, "Certificado colaboración prueba");
const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, { const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, {
values: { ...customValues, usage: "application" }, values: { ...customValues, usage: "application" },
}); });
assert.equal(immutable.status, 409); assert.equal(immutable.status, 409);
const path = `/certificate-configurations/${companyId}`; const path = `/certificate-configurations/${companyId}`;
const input: CertificateConfigurationInput = { const input: CertificateConfigurationInput = {
@ -89,16 +104,22 @@ export async function verifyCertificates(
state: "pending", state: "pending",
notes: null, notes: null,
}; };
const save = async (values: CertificateConfigurationInput, id?: string) => { const save = async (values: CertificateConfigurationInput, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201); assert.equal(response.status, id ? 200 : 201);
return CertificateConfigurationResponseSchema.parse(await response.json()).configuration; return CertificateConfigurationResponseSchema.parse(await response.json()).configuration;
}; };
const error = async (values: unknown, code: string, id?: string, status = 409) => { const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
await error( await error(
{ ...input, resourceId: randomUUID() }, { ...input, resourceId: randomUUID() },
@ -119,10 +140,13 @@ export async function verifyCertificates(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: input }), request(path, { values: input }),
]); ]);
assert.deepEqual(attempts.map((r) => r.status).sort(), [201, 409]); assert.deepEqual(attempts.map((r) => r.status).sort(), [201, 409]);
const response = attempts.find((r) => r.status === 201); const response = attempts.find((r) => r.status === 201);
assert.ok(response); assert.ok(response);
const created = CertificateConfigurationResponseSchema.parse(await response.json()).configuration; const created = CertificateConfigurationResponseSchema.parse(await response.json()).configuration;
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7"); assert.equal(created.history[0]?.id[14], "7");
assert.deepEqual(await save(input, created.id), created); assert.deepEqual(await save(input, created.id), created);
@ -135,6 +159,7 @@ export async function verifyCertificates(
}, },
created.id created.id
); );
assert.equal(configured.history.length, 2); assert.equal(configured.history.length, 2);
assert.equal(configured.history[0]?.values.location, null); assert.equal(configured.history[0]?.values.location, null);
await save({ ...input, resourceId: customApp.id, location: "Firefox", state: "configured" }); await save({ ...input, resourceId: customApp.id, location: "Firefox", state: "configured" });
@ -151,6 +176,7 @@ export async function verifyCertificates(
}); });
} }
const expiry = catalog.items.find((item) => item.builtinKey === "field:expires"); const expiry = catalog.items.find((item) => item.builtinKey === "field:expires");
assert.ok(expiry); assert.ok(expiry);
const expiredResource = await request(`/resources/${companyId}/${app.id}/update`, { const expiredResource = await request(`/resources/${companyId}/${app.id}/update`, {
values: { values: {
@ -158,10 +184,12 @@ export async function verifyCertificates(
characteristics: { ...app.values.characteristics, [expiry.id]: "2000-01-01" }, characteristics: { ...app.values.characteristics, [expiry.id]: "2000-01-01" },
}, },
}); });
assert.equal(expiredResource.status, 200); assert.equal(expiredResource.status, 200);
const list = CertificateConfigurationsResponseSchema.parse( const list = CertificateConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations; ).configurations;
assert.equal(list.find((item) => item.id === created.id)?.needsReview, true); assert.equal(list.find((item) => item.id === created.id)?.needsReview, true);
assert.equal(list.find((item) => item.id === created.id)?.expired, true); assert.equal(list.find((item) => item.id === created.id)?.expired, true);
assert.equal(list.find((item) => item.id === created.id)?.expiresOn, "2000-01-01"); assert.equal(list.find((item) => item.id === created.id)?.expiresOn, "2000-01-01");
@ -171,9 +199,11 @@ export async function verifyCertificates(
created.id created.id
); );
const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id); const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id);
assert.equal(noted.history.length, 3); assert.equal(noted.history.length, 3);
const removal = await save({ ...noted.values, state: "removal_pending" }, created.id); const removal = await save({ ...noted.values, state: "removal_pending" }, created.id);
const removed = await save({ ...removal.values, state: "removed" }, created.id); const removed = await save({ ...removal.values, state: "removed" }, created.id);
assert.equal(removed.needsReview, false); assert.equal(removed.needsReview, false);
await error({ ...removed.values, notes: "Otra" }, "INVALID_CERTIFICATE_TRANSITION", created.id); await error({ ...removed.values, notes: "Otra" }, "INVALID_CERTIFICATE_TRANSITION", created.id);
assert.equal( assert.equal(
@ -202,12 +232,15 @@ export async function verifyCertificates(
const secondPerson = CompanyResponseSchema.parse( const secondPerson = CompanyResponseSchema.parse(
await secondPersonResponse.json() await secondPersonResponse.json()
).company.people.at(-1); ).company.people.at(-1);
assert.ok(secondPerson); assert.ok(secondPerson);
const secondProfile = await request(`/work-profiles/${companyId}`, { const secondProfile = await request(`/work-profiles/${companyId}`, {
values: { ...profileValues, name: "Otro perfil certificados", personId: secondPerson.id }, values: { ...profileValues, name: "Otro perfil certificados", personId: secondPerson.id },
}); });
assert.equal(secondProfile.status, 201); assert.equal(secondProfile.status, 201);
const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id; const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id;
await save({ await save({
...input, ...input,
profileId: secondId, profileId: secondId,
@ -222,9 +255,11 @@ export async function verifyCertificates(
active: false, active: false,
}, },
}); });
assert.equal(archive.status, 200); assert.equal(archive.status, 200);
const reviewed = CertificateConfigurationsResponseSchema.parse( const reviewed = CertificateConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations; ).configurations;
assert.equal(reviewed.find((item) => item.values.profileId === secondId)?.needsReview, true); assert.equal(reviewed.find((item) => item.values.profileId === secondId)?.needsReview, true);
} }

View File

@ -24,6 +24,7 @@ export async function verifyCompanyLogos(
"iVBORw0KGgoAAAANSUhEUgAAAQAAAACACAIAAABr1yBdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAACrElEQVR4nO3cwQ3EIBAEQVLpBAmIaB2B3/vYkgjg1G5kw87c6T4LgbZqcMZ/gYVANgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUshWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUkdWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUkdWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgIBGGAkQeCqRJEDg6QSTAAGleBLc7dvAv0LMPwMrG4AECOQNQAIE8glEAgRyBiABAjkEkwCB3AKRAIFcg5IAgcwBSIBABmEkQCCTYBIgkCgECRBIFogECCQMRwIEkgYlAQKJQ5MAgfQBSIBACjEkQCCNMBIgkEokCRBIJ5gEqSMrxZOg3dvAv0LMPwMrG4AECOQNQAIE8glEAgRyBiABAjkEkwCB3AKRAIFcg5IAgcwBSIBABmEkQCCTYBIgkCgECRBIFogECCQMRwIEkgYlAQKJQ5MAgfQBSIBA/9vgAyvyiK2z3NTsAAAAAElFTkSuQmCC", "iVBORw0KGgoAAAANSUhEUgAAAQAAAACACAIAAABr1yBdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAACrElEQVR4nO3cwQ3EIBAEQVLpBAmIaB2B3/vYkgjg1G5kw87c6T4LgbZqcMZ/gYVANgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUshWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUkdWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUkdWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgIBGGAkQeCqRJEDg6QSTAAGleBLc7dvAv0LMPwMrG4AECOQNQAIE8glEAgRyBiABAjkEkwCB3AKRAIFcg5IAgcwBSIBABmEkQCCTYBIgkCgECRBIFogECCQMRwIEkgYlAQKJQ5MAgfQBSIBACjEkQCCNMBIgkEokCRBIJ5gEqSMrxZOg3dvAv0LMPwMrG4AECOQNQAIE8glEAgRyBiABAjkEkwCB3AKRAIFcg5IAgcwBSIBABmEkQCCTYBIgkCgECRBIFogECCQMRwIEkgYlAQKJQ5MAgfQBSIBA/9vgAyvyiK2z3NTsAAAAAElFTkSuQmCC",
"base64" "base64"
); );
assert.equal((await fetch(`${baseUrl}${path}?version=${"a".repeat(64)}`)).status, 401); assert.equal((await fetch(`${baseUrl}${path}?version=${"a".repeat(64)}`)).status, 401);
assert.equal( assert.equal(
( (
@ -56,15 +57,19 @@ export async function verifyCompanyLogos(
413 413
); );
const upload = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: png }); const upload = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: png });
assert.equal(upload.status, 200); assert.equal(upload.status, 200);
const logo = CompanyLogoResponseSchema.parse(await upload.json()); const logo = CompanyLogoResponseSchema.parse(await upload.json());
assert.ok(logo.logoVersion); assert.ok(logo.logoVersion);
const url = `${baseUrl}${path}?version=${logo.logoVersion}`; const url = `${baseUrl}${path}?version=${logo.logoVersion}`;
const image = await fetch(url, { headers: { cookie } }); const image = await fetch(url, { headers: { cookie } });
assert.equal(image.status, 200); assert.equal(image.status, 200);
assert.equal(image.headers.get("content-type"), "image/webp"); assert.equal(image.headers.get("content-type"), "image/webp");
assert.match(image.headers.get("cache-control") ?? "", /private/); assert.match(image.headers.get("cache-control") ?? "", /private/);
const bytes = Buffer.from(await image.arrayBuffer()); const bytes = Buffer.from(await image.arrayBuffer());
assert.ok(bytes.length <= 64 * 1024); assert.ok(bytes.length <= 64 * 1024);
assert.equal(bytes.subarray(8, 12).toString(), "WEBP"); assert.equal(bytes.subarray(8, 12).toString(), "WEBP");
assert.equal( assert.equal(
@ -77,13 +82,16 @@ export async function verifyCompanyLogos(
404 404
); );
const repeated = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: png }); const repeated = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: png });
assert.deepEqual(CompanyLogoResponseSchema.parse(await repeated.json()), logo); assert.deepEqual(CompanyLogoResponseSchema.parse(await repeated.json()), logo);
const detail = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } }); const detail = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
assert.equal( assert.equal(
CompanyResponseSchema.parse(await detail.json()).company.logoVersion, CompanyResponseSchema.parse(await detail.json()).company.logoVersion,
logo.logoVersion logo.logoVersion
); );
const list = await fetch(`${baseUrl}/companies`, { headers: { cookie } }); const list = await fetch(`${baseUrl}/companies`, { headers: { cookie } });
assert.equal( assert.equal(
CompaniesResponseSchema.parse(await list.json()).companies.find((item) => item.id === companyId) CompaniesResponseSchema.parse(await list.json()).companies.find((item) => item.id === companyId)
?.logoVersion, ?.logoVersion,
@ -97,11 +105,14 @@ export async function verifyCompanyLogos(
"base64" "base64"
), ),
}); });
assert.equal(replacement.status, 200); assert.equal(replacement.status, 200);
const replaced = CompanyLogoResponseSchema.parse(await replacement.json()); const replaced = CompanyLogoResponseSchema.parse(await replacement.json());
assert.notEqual(replaced.logoVersion, logo.logoVersion); assert.notEqual(replaced.logoVersion, logo.logoVersion);
assert.equal((await fetch(url, { headers: { cookie } })).status, 404); assert.equal((await fetch(url, { headers: { cookie } })).status, 404);
const updatedUrl = `${baseUrl}${path}?version=${replaced.logoVersion}`; const updatedUrl = `${baseUrl}${path}?version=${replaced.logoVersion}`;
assert.equal((await fetch(updatedUrl, { headers: { cookie } })).status, 200); assert.equal((await fetch(updatedUrl, { headers: { cookie } })).status, 200);
const current = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } }); const current = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
const company = CompanyResponseSchema.parse(await current.json()).company; const company = CompanyResponseSchema.parse(await current.json()).company;
@ -110,6 +121,7 @@ export async function verifyCompanyLogos(
headers: { ...headers, "content-type": "application/json" }, headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ name: company.name, active: company.active }), body: JSON.stringify({ name: company.name, active: company.active }),
}); });
assert.equal(update.status, 200); assert.equal(update.status, 200);
assert.equal( assert.equal(
CompanyResponseSchema.parse(await update.json()).company.logoVersion, CompanyResponseSchema.parse(await update.json()).company.logoVersion,
@ -121,11 +133,13 @@ export async function verifyCompanyLogos(
headers: { ...headers, "content-type": "application/json" }, headers: { ...headers, "content-type": "application/json" },
body: "{}", body: "{}",
}); });
assert.equal(removed.status, 200); assert.equal(removed.status, 200);
assert.equal(CompanyLogoResponseSchema.parse(await removed.json()).logoVersion, null); assert.equal(CompanyLogoResponseSchema.parse(await removed.json()).logoVersion, null);
} }
assert.equal((await fetch(url, { headers: { cookie } })).status, 404); assert.equal((await fetch(url, { headers: { cookie } })).status, 404);
assert.equal((await fetch(updatedUrl, { headers: { cookie } })).status, 404); assert.equal((await fetch(updatedUrl, { headers: { cookie } })).status, 404);
const cleared = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } }); const cleared = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
assert.equal(CompanyResponseSchema.parse(await cleared.json()).company.logoVersion, null); assert.equal(CompanyResponseSchema.parse(await cleared.json()).company.logoVersion, null);
} }

View File

@ -14,9 +14,11 @@ const DatabaseEnvironmentSchema = z.object({
export function readDatabaseEnvironment(source: NodeJS.ProcessEnv) { export function readDatabaseEnvironment(source: NodeJS.ProcessEnv) {
const parsed = DatabaseEnvironmentSchema.safeParse(source); const parsed = DatabaseEnvironmentSchema.safeParse(source);
if (!parsed.success) { if (!parsed.success) {
throw new Error("Configuración de persistencia o sesión incompleta."); throw new Error("Configuración de persistencia o sesión incompleta.");
} }
return parsed.data; return parsed.data;
} }

View File

@ -7,8 +7,10 @@ const ServerEnvironmentSchema = z.object({
/** El arranque rechaza una configuración inválida antes de abrir el puerto. */ /** El arranque rechaza una configuración inválida antes de abrir el puerto. */
export function readServerEnvironment(environment: NodeJS.ProcessEnv) { export function readServerEnvironment(environment: NodeJS.ProcessEnv) {
const result = ServerEnvironmentSchema.safeParse({ PORT: environment.PORT ?? "3001" }); const result = ServerEnvironmentSchema.safeParse({ PORT: environment.PORT ?? "3001" });
if (!result.success) { if (!result.success) {
throw new Error("Invalid server configuration: PORT must be between 1 and 65535"); throw new Error("Invalid server configuration: PORT must be between 1 and 65535");
} }
return result.data; return result.data;
} }

View File

@ -14,14 +14,19 @@ import {
import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common"; import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common";
type Request = (path: string, body?: unknown) => Promise<Response>; type Request = (path: string, body?: unknown) => Promise<Response>;
export async function verifyDashboard(request: Request, companyId: string, otherId: string) { export async function verifyDashboard(request: Request, companyId: string, otherId: string) {
async function get(path: string) { async function get(path: string) {
const response = await request(path); const response = await request(path);
assert.equal(response.status, 200, await response.clone().text()); assert.equal(response.status, 200, await response.clone().text());
return response.json(); return response.json();
} }
const summary = DashboardSummarySchema.parse(await get("/dashboard/summary")); const summary = DashboardSummarySchema.parse(await get("/dashboard/summary"));
const companies = DashboardCompaniesSchema.parse(await get("/dashboard/companies")); const companies = DashboardCompaniesSchema.parse(await get("/dashboard/companies"));
assert.equal( assert.equal(
summary.assets, summary.assets,
companies.items.reduce((sum, item) => sum + item.assets, 0) companies.items.reduce((sum, item) => sum + item.assets, 0)
@ -32,6 +37,7 @@ export async function verifyDashboard(request: Request, companyId: string, other
); );
const tickets = TicketListSchema.parse(await get("/tickets")); const tickets = TicketListSchema.parse(await get("/tickets"));
const interventions = InterventionListSchema.parse(await get("/interventions")); const interventions = InterventionListSchema.parse(await get("/interventions"));
assert.equal( assert.equal(
summary.tickets, summary.tickets,
tickets.tickets.filter((item) => !["resolved", "cancelled"].includes(item.values.state)).length tickets.tickets.filter((item) => !["resolved", "cancelled"].includes(item.values.state)).length
@ -45,6 +51,7 @@ export async function verifyDashboard(request: Request, companyId: string, other
for (const scope of [companyId, otherId]) { for (const scope of [companyId, otherId]) {
const scoped = DashboardSummarySchema.parse(await get(`/dashboard/summary?companyId=${scope}`)); const scoped = DashboardSummarySchema.parse(await get(`/dashboard/summary?companyId=${scope}`));
const row = companies.items.find((item) => item.id === scope); const row = companies.items.find((item) => item.id === scope);
assert.ok(row); assert.ok(row);
assert.equal(scoped.assets, row.assets); assert.equal(scoped.assets, row.assets);
assert.equal(scoped.resources, row.resources); assert.equal(scoped.resources, row.resources);
@ -62,20 +69,25 @@ export async function verifyDashboard(request: Request, companyId: string, other
const result = DashboardEntriesSchema.parse( const result = DashboardEntriesSchema.parse(
await get(`/dashboard/${block}${block.includes("?") ? "&" : "?"}companyId=${scope}`) await get(`/dashboard/${block}${block.includes("?") ? "&" : "?"}companyId=${scope}`)
); );
assert.ok(result.items.every((item) => item.companyId === scope)); assert.ok(result.items.every((item) => item.companyId === scope));
assert.ok(result.items.length <= 10); assert.ok(result.items.length <= 10);
} }
} }
const work = DashboardEntriesSchema.parse(await get("/dashboard/work")); const work = DashboardEntriesSchema.parse(await get("/dashboard/work"));
assert.equal(work.total, summary.tickets + summary.interventions); assert.equal(work.total, summary.tickets + summary.interventions);
assert.ok( assert.ok(
work.items.every((item) => !["resolved", "cancelled", "finished"].includes(item.state ?? "")) work.items.every((item) => !["resolved", "cancelled", "finished"].includes(item.state ?? ""))
); );
const priorityRank = (value: string | null) => const priorityRank = (value: string | null) =>
["urgent", "high", "normal", "low"].indexOf(value ?? "normal"); ["urgent", "high", "normal", "low"].indexOf(value ?? "normal");
for (let index = 1; index < work.items.length; index++) { for (let index = 1; index < work.items.length; index++) {
const previous = work.items[index - 1]; const previous = work.items[index - 1];
const current = work.items[index]; const current = work.items[index];
assert.ok(previous && current); assert.ok(previous && current);
assert.ok(priorityRank(previous.priority) <= priorityRank(current.priority)); assert.ok(priorityRank(previous.priority) <= priorityRank(current.priority));
if (priorityRank(previous.priority) === priorityRank(current.priority)) { if (priorityRank(previous.priority) === priorityRank(current.priority)) {
@ -83,20 +95,24 @@ export async function verifyDashboard(request: Request, companyId: string, other
} }
} }
const activity = DashboardEntriesSchema.parse(await get("/dashboard/activity")); const activity = DashboardEntriesSchema.parse(await get("/dashboard/activity"));
assert.equal(new Set(activity.items.map((item) => item.eventId)).size, activity.items.length); assert.equal(new Set(activity.items.map((item) => item.eventId)).size, activity.items.length);
assert.ok(activity.items.every((item) => item.eventId)); assert.ok(activity.items.every((item) => item.eventId));
assert.equal(activity.total, activity.items.length); assert.equal(activity.total, activity.items.length);
const attention = DashboardAttentionSchema.parse(await get("/dashboard/attention")); const attention = DashboardAttentionSchema.parse(await get("/dashboard/attention"));
assert.equal(attention.items.length, 5); assert.equal(attention.items.length, 5);
for (const alert of attention.items) { for (const alert of attention.items) {
const details = DashboardEntriesSchema.parse( const details = DashboardEntriesSchema.parse(
await get(`/dashboard/details?issue=${alert.kind}`) await get(`/dashboard/details?issue=${alert.kind}`)
); );
assert.equal(alert.count, details.total); assert.equal(alert.count, details.total);
} }
const beyond = DashboardEntriesSchema.parse( const beyond = DashboardEntriesSchema.parse(
await get("/dashboard/details?kind=asset&page=100000") await get("/dashboard/details?kind=asset&page=100000")
); );
assert.equal(beyond.items.length, 0); assert.equal(beyond.items.length, 0);
assert.equal(beyond.total, summary.assets); assert.equal(beyond.total, summary.assets);
for (const path of [ for (const path of [
@ -112,24 +128,31 @@ export async function verifyDashboard(request: Request, companyId: string, other
export async function verifyDashboardBoundaries(request: Request) { export async function verifyDashboardBoundaries(request: Request) {
const created = await request("/companies", { name: "Dashboard vacío", active: true }); const created = await request("/companies", { name: "Dashboard vacío", active: true });
assert.equal(created.status, 201, await created.clone().text()); assert.equal(created.status, 201, await created.clone().text());
const companyId = CompanyResponseSchema.parse(await created.json()).company.id; const companyId = CompanyResponseSchema.parse(await created.json()).company.id;
const empty = await request(`/dashboard/summary?companyId=${companyId}`); const empty = await request(`/dashboard/summary?companyId=${companyId}`);
const zero = DashboardSummarySchema.parse(await empty.json()); const zero = DashboardSummarySchema.parse(await empty.json());
assert.deepEqual(zero, { companies: 1, tickets: 0, interventions: 0, assets: 0, resources: 0 }); assert.deepEqual(zero, { companies: 1, tickets: 0, interventions: 0, assets: 0, resources: 0 });
const catalogResponse = await request("/resource-catalog/global"); const catalogResponse = await request("/resource-catalog/global");
const catalog = CatalogResponseSchema.parse(await catalogResponse.json()); const catalog = CatalogResponseSchema.parse(await catalogResponse.json());
const expiry = catalog.items.find((item) => item.builtinKey === "field:expires"); const expiry = catalog.items.find((item) => item.builtinKey === "field:expires");
assert.ok(expiry); assert.ok(expiry);
const date = (offset: number) => const date = (offset: number) =>
new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10); new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
for (const [issue, key] of [ for (const [issue, key] of [
["certificate", "certificate"], ["certificate", "certificate"],
["license", "software_license"], ["license", "software_license"],
]) { ]) {
const template = catalog.items.find((item) => item.builtinKey === `template:${key}`); const template = catalog.items.find((item) => item.builtinKey === `template:${key}`);
assert.ok(template); assert.ok(template);
const expected: string[] = []; const expected: string[] = [];
for (const [offset, active] of [ for (const [offset, active] of [
[0, true], [0, true],
[30, true], [30, true],
@ -147,15 +170,19 @@ export async function verifyDashboardBoundaries(request: Request) {
characteristics: offset === null ? {} : { [expiry.id]: date(offset) }, characteristics: offset === null ? {} : { [expiry.id]: date(offset) },
}, },
}); });
assert.equal(response.status, 201, await response.clone().text()); assert.equal(response.status, 201, await response.clone().text());
const resource = ResourceResponseSchema.parse(await response.json()).resource; const resource = ResourceResponseSchema.parse(await response.json()).resource;
if (active && (offset === 0 || offset === 30)) { if (active && (offset === 0 || offset === 30)) {
expected.push(resource.id); expected.push(resource.id);
} }
} }
const response = await request(`/dashboard/details?companyId=${companyId}&issue=${issue}`); const response = await request(`/dashboard/details?companyId=${companyId}&issue=${issue}`);
assert.equal(response.status, 200, await response.clone().text()); assert.equal(response.status, 200, await response.clone().text());
const result = DashboardEntriesSchema.parse(await response.json()); const result = DashboardEntriesSchema.parse(await response.json());
assert.equal(result.total, 2); assert.equal(result.total, 2);
assert.deepEqual(new Set(result.items.map((item) => item.id)), new Set(expected)); assert.deepEqual(new Set(result.items.map((item) => item.id)), new Set(expected));
} }
@ -165,6 +192,7 @@ export async function verifyDashboardBoundaries(request: Request) {
const second = DashboardEntriesSchema.parse( const second = DashboardEntriesSchema.parse(
await (await request(`/dashboard/details?companyId=${companyId}&kind=resource&page=2`)).json() await (await request(`/dashboard/details?companyId=${companyId}&kind=resource&page=2`)).json()
); );
assert.equal(first.total, 12); assert.equal(first.total, 12);
assert.equal(first.items.length, 10); assert.equal(first.items.length, 10);
assert.equal(second.items.length, 2); assert.equal(second.items.length, 2);
@ -182,9 +210,11 @@ export async function verifyDashboardInactiveAccess(request: Request, companyId:
isContact: false, isContact: false,
}; };
const created = await request(`/companies/${companyId}/people`, personValues); const created = await request(`/companies/${companyId}/people`, personValues);
assert.equal(created.status, 201, await created.clone().text()); assert.equal(created.status, 201, await created.clone().text());
const company = CompanyResponseSchema.parse(await created.json()).company; const company = CompanyResponseSchema.parse(await created.json()).company;
const person = company.people.find((item) => item.name === personValues.name); const person = company.people.find((item) => item.name === personValues.name);
assert.ok(person); assert.ok(person);
const values = { const values = {
personId: person.id, personId: person.id,
@ -199,25 +229,32 @@ export async function verifyDashboardInactiveAccess(request: Request, companyId:
notes: null, notes: null,
}; };
const response = await request(`/remote-connections/${companyId}`, { values }); const response = await request(`/remote-connections/${companyId}`, { values });
assert.equal(response.status, 201, await response.clone().text()); assert.equal(response.status, 201, await response.clone().text());
const connection = RemoteConnectionResponseSchema.parse(await response.json()).connection; const connection = RemoteConnectionResponseSchema.parse(await response.json()).connection;
const deactivated = await request(`/companies/${companyId}/people/${person.id}/update`, { const deactivated = await request(`/companies/${companyId}/people/${person.id}/update`, {
...personValues, ...personValues,
active: false, active: false,
}); });
assert.equal(deactivated.status, 200, await deactivated.clone().text()); assert.equal(deactivated.status, 200, await deactivated.clone().text());
async function count() { async function count() {
const response = await request( const response = await request(
`/dashboard/details?companyId=${companyId}&issue=inactive_person` `/dashboard/details?companyId=${companyId}&issue=inactive_person`
); );
assert.equal(response.status, 200, await response.clone().text()); assert.equal(response.status, 200, await response.clone().text());
return DashboardEntriesSchema.parse(await response.json()); return DashboardEntriesSchema.parse(await response.json());
} }
assert.ok((await count()).items.some((item) => item.id === person.id)); assert.ok((await count()).items.some((item) => item.id === person.id));
for (const state of ["removal_pending", "removed"]) { for (const state of ["removal_pending", "removed"]) {
const updated = await request(`/remote-connections/${companyId}/${connection.id}/update`, { const updated = await request(`/remote-connections/${companyId}/${connection.id}/update`, {
values: { ...values, state }, values: { ...values, state },
}); });
assert.equal(updated.status, 200, await updated.clone().text()); assert.equal(updated.status, 200, await updated.clone().text());
assert.equal( assert.equal(
(await count()).items.some((item) => item.id === person.id), (await count()).items.some((item) => item.id === person.id),

View File

@ -56,6 +56,7 @@ import { verifyWorkstations } from "./workstations.integration";
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => { test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
const environment = readDatabaseEnvironment(process.env); const environment = readDatabaseEnvironment(process.env);
// Este proceso solo puede limpiar la base de pruebas dedicada. // Este proceso solo puede limpiar la base de pruebas dedicada.
assert.equal(environment.DB_NAME, "metis_desk_test"); assert.equal(environment.DB_NAME, "metis_desk_test");
const database = createDatabase(environment); const database = createDatabase(environment);
@ -63,6 +64,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
legacyResourceFixtures: true, legacyResourceFixtures: true,
}); });
const server = createApp(() => undefined, identity).listen(0, "127.0.0.1"); const server = createApp(() => undefined, identity).listen(0, "127.0.0.1");
try { try {
await once(server, "listening"); await once(server, "listening");
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1"); await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
@ -149,6 +151,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
// Simular la tabla de contactos de la versión anterior solo en la base de pruebas. // Simular la tabla de contactos de la versión anterior solo en la base de pruebas.
const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411"; const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411";
const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898"; const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898";
await database.query("INSERT INTO companies (id,name,active) VALUES (:id,'Legacy',1)", { await database.query("INSERT INTO companies (id,name,active) VALUES (:id,'Legacy',1)", {
replacements: { id: legacyCompanyId }, replacements: { id: legacyCompanyId },
}); });
@ -165,6 +168,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
"SELECT id,email,isContact FROM company_contacts WHERE id=:id", "SELECT id,email,isContact FROM company_contacts WHERE id=:id",
{ replacements: { id: legacyPersonId }, type: QueryTypes.SELECT } { replacements: { id: legacyPersonId }, type: QueryTypes.SELECT }
); );
assert.equal(legacy[0]?.id, legacyPersonId); assert.equal(legacy[0]?.id, legacyPersonId);
assert.equal(legacy[0]?.email, "legacy@example.test"); assert.equal(legacy[0]?.email, "legacy@example.test");
assert.equal(Number(legacy[0]?.isContact), 1); assert.equal(Number(legacy[0]?.isContact), 1);
@ -176,11 +180,14 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
}); });
const address = server.address(); const address = server.address();
assert.ok(address && typeof address !== "string"); assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}/api`; const baseUrl = `http://127.0.0.1:${address.port}/api`;
function browser() { function browser() {
let cookie = ""; let cookie = "";
let csrf = ""; let csrf = "";
return { return {
async request( async request(
path: string, path: string,
@ -199,28 +206,36 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
...(body === undefined ? {} : { body: JSON.stringify(body) }), ...(body === undefined ? {} : { body: JSON.stringify(body) }),
}); });
const newCookie = response.headers.get("set-cookie"); const newCookie = response.headers.get("set-cookie");
if (newCookie) { if (newCookie) {
cookie = newCookie.split(";")[0] ?? ""; cookie = newCookie.split(";")[0] ?? "";
} }
return response; return response;
}, },
async session() { async session() {
const response = await this.request("/auth/session"); const response = await this.request("/auth/session");
assert.equal(response.status, 200); assert.equal(response.status, 200);
const data = SessionResponseSchema.parse(await response.json()); const data = SessionResponseSchema.parse(await response.json());
csrf = data.csrfToken; csrf = data.csrfToken;
return data; return data;
}, },
cookie: () => cookie, cookie: () => cookie,
}; };
} }
async function expectError(response: Response, status: number, code: string) { async function expectError(response: Response, status: number, code: string) {
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(IdentityErrorResponseSchema.parse(await response.json()).code, code); assert.equal(IdentityErrorResponseSchema.parse(await response.json()).code, code);
} }
assert.equal((await fetch(`${baseUrl}/asset-brands`)).status, 401); assert.equal((await fetch(`${baseUrl}/asset-brands`)).status, 401);
const admin = browser(); const admin = browser();
const competitor = browser(); const competitor = browser();
assert.equal((await admin.session()).setupRequired, true); assert.equal((await admin.session()).setupRequired, true);
await competitor.session(); await competitor.session();
await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED"); await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED");
@ -236,6 +251,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
email: "ADMIN@example.test", email: "ADMIN@example.test",
password: "Admin123", password: "Admin123",
}; };
await expectError( await expectError(
await admin.request("/auth/bootstrap", first, "https://other.example"), await admin.request("/auth/bootstrap", first, "https://other.example"),
403, 403,
@ -256,12 +272,15 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
admin.request("/auth/bootstrap", first), admin.request("/auth/bootstrap", first),
competitor.request("/auth/bootstrap", { ...first, email: "other@example.test" }), competitor.request("/auth/bootstrap", { ...first, email: "other@example.test" }),
]); ]);
assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]);
const winnerIndex = attempts.findIndex((response) => response.status === 201); const winnerIndex = attempts.findIndex((response) => response.status === 201);
const administrator = winnerIndex === 0 ? admin : competitor; const administrator = winnerIndex === 0 ? admin : competitor;
const winnerResponse = attempts[winnerIndex]; const winnerResponse = attempts[winnerIndex];
assert.ok(winnerResponse); assert.ok(winnerResponse);
const initial = UserResponseSchema.parse(await winnerResponse.json()).user; const initial = UserResponseSchema.parse(await winnerResponse.json()).user;
assert.equal(initial.isInitialAdmin, true); assert.equal(initial.isInitialAdmin, true);
assert.equal(initial.id.charAt(14), "7"); assert.equal(initial.id.charAt(14), "7");
if (winnerIndex === 0) { if (winnerIndex === 0) {
@ -301,8 +320,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
password: "User1234", password: "User1234",
}; };
const created = await administrator.request("/users", person); const created = await administrator.request("/users", person);
assert.equal(created.status, 201); assert.equal(created.status, 201);
const user = UserResponseSchema.parse(await created.json()).user; const user = UserResponseSchema.parse(await created.json()).user;
assert.equal(user.isInitialAdmin, false); assert.equal(user.isInitialAdmin, false);
await expectError( await expectError(
await administrator.request("/users", { ...person, email: "OPERATOR@example.test" }), await administrator.request("/users", { ...person, email: "OPERATOR@example.test" }),
@ -310,9 +331,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
"EMAIL_EXISTS" "EMAIL_EXISTS"
); );
const list = UsersResponseSchema.parse(await (await administrator.request("/users")).json()); const list = UsersResponseSchema.parse(await (await administrator.request("/users")).json());
assert.equal(list.users.length, 2); assert.equal(list.users.length, 2);
assert.equal(JSON.stringify(list).includes("passwordHash"), false); assert.equal(JSON.stringify(list).includes("passwordHash"), false);
const operator = browser(); const operator = browser();
await operator.session(); await operator.session();
await expectError( await expectError(
await operator.request("/auth/login", { email: person.email, password: "incorrect" }), await operator.request("/auth/login", { email: person.email, password: "incorrect" }),
@ -335,6 +358,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
"INVALID_CREDENTIALS" "INVALID_CREDENTIALS"
); );
const oldSession = browser(); const oldSession = browser();
await oldSession.session(); await oldSession.session();
assert.equal( assert.equal(
(await oldSession.request("/auth/login", { email: person.email, password: person.password })) (await oldSession.request("/auth/login", { email: person.email, password: person.password }))
@ -343,6 +367,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
); );
await oldSession.session(); await oldSession.session();
const changedPassword = "Nuevo123"; const changedPassword = "Nuevo123";
assert.equal( assert.equal(
( (
await operator.request("/auth/change-password", { await operator.request("/auth/change-password", {
@ -355,6 +380,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
assert.equal((await operator.session()).user?.id, user.id); assert.equal((await operator.session()).user?.id, user.id);
assert.equal((await oldSession.session()).user, null); assert.equal((await oldSession.session()).user, null);
const resetPassword = "Reset123"; const resetPassword = "Reset123";
assert.equal( assert.equal(
(await administrator.request(`/users/${user.id}/reset-password`, { password: resetPassword })) (await administrator.request(`/users/${user.id}/reset-password`, { password: resetPassword }))
.status, .status,
@ -405,6 +431,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
); );
await operator.session(); await operator.session();
const anonymousCompanyBrowser = browser(); const anonymousCompanyBrowser = browser();
await anonymousCompanyBrowser.session(); await anonymousCompanyBrowser.session();
await expectError(await anonymousCompanyBrowser.request("/companies"), 401, "UNAUTHENTICATED"); await expectError(await anonymousCompanyBrowser.request("/companies"), 401, "UNAUTHENTICATED");
for (const block of ["summary", "work", "attention", "activity", "companies", "details"]) { for (const block of ["summary", "work", "attention", "activity", "companies", "details"]) {
@ -418,8 +445,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
name: "Compañía de pruebas", name: "Compañía de pruebas",
active: true, active: true,
}); });
assert.equal(companyCreated.status, 201); assert.equal(companyCreated.status, 201);
const customer = CompanyResponseSchema.parse(await companyCreated.json()).company; const customer = CompanyResponseSchema.parse(await companyCreated.json()).company;
assert.equal(customer.id.charAt(14), "7"); assert.equal(customer.id.charAt(14), "7");
const secondCreated = await operator.request("/companies", { const secondCreated = await operator.request("/companies", {
name: "Segunda compañía", name: "Segunda compañía",
@ -433,6 +462,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
phone: "+34 900 123 456", phone: "+34 900 123 456",
active: true, active: true,
}; };
await expectError( await expectError(
await operator.request( await operator.request(
`/companies/${customer.id}/contacts`, `/companies/${customer.id}/contacts`,
@ -454,9 +484,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
`/companies/${customer.id}/contacts`, `/companies/${customer.id}/contacts`,
personContact personContact
); );
assert.equal(addedContact.status, 201); assert.equal(addedContact.status, 201);
const afterContact = CompanyResponseSchema.parse(await addedContact.json()).company; const afterContact = CompanyResponseSchema.parse(await addedContact.json()).company;
const contact = afterContact.contacts[0]; const contact = afterContact.contacts[0];
assert.ok(contact); assert.ok(contact);
assert.equal(contact.companyId, customer.id); assert.equal(contact.companyId, customer.id);
assert.equal(contact.id.charAt(14), "7"); assert.equal(contact.id.charAt(14), "7");
@ -465,6 +497,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
`/companies/${secondCompany.id}/contacts/${contact.id}/update`, `/companies/${secondCompany.id}/contacts/${contact.id}/update`,
personContact personContact
); );
assert.equal(crossContact.status, 404); assert.equal(crossContact.status, 404);
assert.equal( assert.equal(
CompanyErrorResponseSchema.parse(await crossContact.json()).code, CompanyErrorResponseSchema.parse(await crossContact.json()).code,
@ -474,6 +507,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
`/companies/${customer.id}/contacts/${contact.id}/update`, `/companies/${customer.id}/contacts/${contact.id}/update`,
{ ...personContact, name: "Ana Actualizada", active: false } { ...personContact, name: "Ana Actualizada", active: false }
); );
assert.equal(changedContact.status, 200); assert.equal(changedContact.status, 200);
assert.equal( assert.equal(
CompanyResponseSchema.parse(await changedContact.json()).company.contacts[0]?.active, CompanyResponseSchema.parse(await changedContact.json()).company.contacts[0]?.active,
@ -482,8 +516,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
const noAddressSite = await operator.request(`/companies/${customer.id}/sites`, { const noAddressSite = await operator.request(`/companies/${customer.id}/sites`, {
name: "Almacén", name: "Almacén",
}); });
assert.equal(noAddressSite.status, 201); assert.equal(noAddressSite.status, 201);
const emptySite = CompanyResponseSchema.parse(await noAddressSite.json()).company.sites[0]; const emptySite = CompanyResponseSchema.parse(await noAddressSite.json()).company.sites[0];
assert.ok(emptySite); assert.ok(emptySite);
assert.equal(emptySite.address, null); assert.equal(emptySite.address, null);
const siteAddress = "Calle Mayor, 10\n28001 Madrid"; const siteAddress = "Calle Mayor, 10\n28001 Madrid";
@ -491,6 +527,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
`/companies/${customer.id}/sites/${emptySite.id}/update`, `/companies/${customer.id}/sites/${emptySite.id}/update`,
{ name: "Sede central", address: siteAddress } { name: "Sede central", address: siteAddress }
); );
assert.equal(changedSite.status, 200); assert.equal(changedSite.status, 200);
assert.equal( assert.equal(
CompanyResponseSchema.parse(await changedSite.json()).company.sites[0]?.address, CompanyResponseSchema.parse(await changedSite.json()).company.sites[0]?.address,
@ -500,8 +537,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
`/companies/${secondCompany.id}/sites/${emptySite.id}/update`, `/companies/${secondCompany.id}/sites/${emptySite.id}/update`,
{ name: "Ajena", address: null } { name: "Ajena", address: null }
); );
assert.equal(crossSite.status, 404); assert.equal(crossSite.status, 404);
const largeAddress = "Á".repeat(10000); const largeAddress = "Á".repeat(10000);
assert.equal( assert.equal(
( (
await operator.request(`/companies/${customer.id}/sites`, { await operator.request(`/companies/${customer.id}/sites`, {
@ -524,10 +563,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
name: "Compañía actualizada", name: "Compañía actualizada",
active: false, active: false,
}); });
assert.equal(editedCompany.status, 200); assert.equal(editedCompany.status, 200);
const details = CompanyResponseSchema.parse( const details = CompanyResponseSchema.parse(
await (await operator.request(`/companies/${customer.id}`)).json() await (await operator.request(`/companies/${customer.id}`)).json()
).company; ).company;
assert.equal(details.id, customer.id); assert.equal(details.id, customer.id);
assert.equal(details.active, false); assert.equal(details.active, false);
assert.equal(details.contacts[0]?.id, contact.id); assert.equal(details.contacts[0]?.id, contact.id);
@ -535,6 +576,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
const companiesList = CompaniesResponseSchema.parse( const companiesList = CompaniesResponseSchema.parse(
await (await operator.request("/companies")).json() await (await operator.request("/companies")).json()
); );
assert.equal(companiesList.companies.length, 2); assert.equal(companiesList.companies.length, 2);
await assert.rejects( await assert.rejects(
database.query("DELETE FROM companies WHERE id = :id", { replacements: { id: customer.id } }) database.query("DELETE FROM companies WHERE id = :id", { replacements: { id: customer.id } })
@ -543,17 +585,22 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
`/companies/${customer.id}/sites/${emptySite.id}/update`, `/companies/${customer.id}/sites/${emptySite.id}/update`,
{ name: "Sede central", address: null } { name: "Sede central", address: null }
); );
assert.equal(clearedSite.status, 200); assert.equal(clearedSite.status, 200);
// Personas y departamentos: mismas fronteras de sesión, UUID y compañía. // Personas y departamentos: mismas fronteras de sesión, UUID y compañía.
const peoplePath = `/companies/${customer.id}`; const peoplePath = `/companies/${customer.id}`;
async function companyData(response: Response) { async function companyData(response: Response) {
assert.ok(response.status === 200 || response.status === 201); assert.ok(response.status === 200 || response.status === 201);
return CompanyResponseSchema.parse(await response.json()).company; return CompanyResponseSchema.parse(await response.json()).company;
} }
async function companyError(response: Response, status: number, code: string) { async function companyError(response: Response, status: number, code: string) {
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(CompanyErrorResponseSchema.parse(await response.json()).code, code); assert.equal(CompanyErrorResponseSchema.parse(await response.json()).code, code);
} }
const personBody = { const personBody = {
name: "Persona sin correo", name: "Persona sin correo",
position: null, position: null,
@ -566,6 +613,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await operator.request(`${peoplePath}/people`, personBody) await operator.request(`${peoplePath}/people`, personBody)
); );
const companyPerson = personResponse.people.find((item) => item.name === personBody.name); const companyPerson = personResponse.people.find((item) => item.name === personBody.name);
assert.ok(companyPerson); assert.ok(companyPerson);
assert.equal(companyPerson.email, null); assert.equal(companyPerson.email, null);
assert.equal(companyPerson.id.charAt(14), "7"); assert.equal(companyPerson.id.charAt(14), "7");
@ -591,6 +639,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await operator.request(`${peoplePath}/departments`, { name: " Compras ", active: true }) await operator.request(`${peoplePath}/departments`, { name: " Compras ", active: true })
); );
const department = departmentResponse.departments.find((item) => item.name === "Compras"); const department = departmentResponse.departments.find((item) => item.name === "Compras");
assert.ok(department); assert.ok(department);
await companyError( await companyError(
await operator.request(`${peoplePath}/departments`, { name: "COMPRAS", active: true }), await operator.request(`${peoplePath}/departments`, { name: "COMPRAS", active: true }),
@ -604,6 +653,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
}) })
); );
const foreignDepartment = otherDepartmentResponse.departments[0]; const foreignDepartment = otherDepartmentResponse.departments[0];
assert.ok(foreignDepartment); assert.ok(foreignDepartment);
const assignmentBody = { const assignmentBody = {
departmentId: department.id, departmentId: department.id,
@ -612,6 +662,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
endDate: null, endDate: null,
isPrimary: true, isPrimary: true,
}; };
await companyError( await companyError(
await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, { await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, {
...assignmentBody, ...assignmentBody,
@ -626,6 +677,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
const membership = assignmentResponse.assignments.find( const membership = assignmentResponse.assignments.find(
(item) => item.personId === companyPerson.id (item) => item.personId === companyPerson.id
); );
assert.ok(membership); assert.ok(membership);
assert.equal(membership.id.charAt(14), "7"); assert.equal(membership.id.charAt(14), "7");
assert.equal(membership.history.length, 1); assert.equal(membership.history.length, 1);
@ -643,6 +695,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
const secondDepartment = secondResponse.departments.find( const secondDepartment = secondResponse.departments.find(
(item) => item.name === "Administración" (item) => item.name === "Administración"
); );
assert.ok(secondDepartment); assert.ok(secondDepartment);
await companyError( await companyError(
await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, { await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, {
@ -672,6 +725,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
) )
); );
const history = editedMembership.assignments.find((item) => item.id === membership.id)?.history; const history = editedMembership.assignments.find((item) => item.id === membership.id)?.history;
assert.equal(history?.length, 2); assert.equal(history?.length, 2);
assert.equal(history?.[0]?.position, "Responsable"); assert.equal(history?.[0]?.position, "Responsable");
const repeatedMembership = await companyData( const repeatedMembership = await companyData(
@ -680,6 +734,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
editedAssignment editedAssignment
) )
); );
assert.equal( assert.equal(
repeatedMembership.assignments.find((item) => item.id === membership.id)?.history.length, repeatedMembership.assignments.find((item) => item.id === membership.id)?.history.length,
2 2
@ -737,6 +792,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
{ ...editedAssignment, endDate: "2026-03-01", isPrimary: false } { ...editedAssignment, endDate: "2026-03-01", isPrimary: false }
) )
); );
assert.equal(closed.assignments.find((item) => item.id === membership.id)?.history.length, 3); assert.equal(closed.assignments.find((item) => item.id === membership.id)?.history.length, 3);
await companyData( await companyData(
await operator.request(`${peoplePath}/people/${contact.id}/update`, { await operator.request(`${peoplePath}/people/${contact.id}/update`, {
@ -759,6 +815,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
isPrimary: false, isPrimary: false,
}), }),
]); ]);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
// La FK compuesta impide pertenencias cruzadas aunque se intente escribir SQL directamente. // La FK compuesta impide pertenencias cruzadas aunque se intente escribir SQL directamente.
await assert.rejects( await assert.rejects(
@ -778,6 +835,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
"SELECT COUNT(*) AS total FROM users", "SELECT COUNT(*) AS total FROM users",
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
); );
assert.equal(Number(userCount[0]?.total), 2); assert.equal(Number(userCount[0]?.total), 2);
const accessCompany = CompanyResponseSchema.parse( const accessCompany = CompanyResponseSchema.parse(
@ -786,6 +844,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
const accessOther = CompanyResponseSchema.parse( const accessOther = CompanyResponseSchema.parse(
await (await operator.request(`/companies/${secondCompany.id}`)).json() await (await operator.request(`/companies/${secondCompany.id}`)).json()
).company; ).company;
await verifyAccess((...args) => operator.request(...args), accessCompany, accessOther); await verifyAccess((...args) => operator.request(...args), accessCompany, accessOther);
await verifyAssets( await verifyAssets(
(...args) => operator.request(...args), (...args) => operator.request(...args),
@ -892,10 +951,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí. // El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
const migratedRuntime = await createIdentityRuntime(database, environment); const migratedRuntime = await createIdentityRuntime(database, environment);
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1"); const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
try { try {
await once(migratedServer, "listening"); await once(migratedServer, "listening");
const address = z.object({ port: z.number() }).parse(migratedServer.address()); const address = z.object({ port: z.number() }).parse(migratedServer.address());
const session = await operator.session(); const session = await operator.session();
await verifyResourceMigration( await verifyResourceMigration(
(path, body) => (path, body) =>
fetch(`http://127.0.0.1:${address.port}/api${path}`, { fetch(`http://127.0.0.1:${address.port}/api${path}`, {
@ -928,6 +989,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
"SELECT COUNT(*) AS total FROM users", "SELECT COUNT(*) AS total FROM users",
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
); );
assert.equal(Number(countAfterAccess[0]?.total), 2); assert.equal(Number(countAfterAccess[0]?.total), 2);
assert.equal((await operator.request("/auth/logout", {})).status, 200); assert.equal((await operator.request("/auth/logout", {})).status, 200);
await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED"); await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED");
@ -1012,15 +1074,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
"SELECT passwordHash FROM users WHERE id = :id", "SELECT passwordHash FROM users WHERE id = :id",
{ replacements: { id: user.id }, type: QueryTypes.SELECT } { replacements: { id: user.id }, type: QueryTypes.SELECT }
); );
assert.ok(stored[0]?.passwordHash.startsWith("scrypt-v1$")); assert.ok(stored[0]?.passwordHash.startsWith("scrypt-v1$"));
assert.equal(JSON.stringify(stored).includes(resetPassword), false); assert.equal(JSON.stringify(stored).includes(resetPassword), false);
// Al acumular fallos se rechazan los siguientes intentos. // Al acumular fallos se rechazan los siguientes intentos.
let limited = false; let limited = false;
for (let index = 0; index < 12; index += 1) { for (let index = 0; index < 12; index += 1) {
const response = await operator.request("/auth/login", { const response = await operator.request("/auth/login", {
email: "missing@example.test", email: "missing@example.test",
password: "wrong", password: "wrong",
}); });
if (response.status === 429) { if (response.status === 429) {
limited = true; limited = true;
break; break;

View File

@ -7,11 +7,13 @@ async function start(): Promise<void> {
const environment = readServerEnvironment(process.env); const environment = readServerEnvironment(process.env);
const databaseEnvironment = readDatabaseEnvironment(process.env); const databaseEnvironment = readDatabaseEnvironment(process.env);
const database = createDatabase(databaseEnvironment); const database = createDatabase(databaseEnvironment);
try { try {
const identity = await createIdentityRuntime(database, databaseEnvironment); const identity = await createIdentityRuntime(database, databaseEnvironment);
const server = createApp(undefined, identity).listen(environment.PORT, "127.0.0.1", () => { const server = createApp(undefined, identity).listen(environment.PORT, "127.0.0.1", () => {
console.info(JSON.stringify({ event: "server_started", port: environment.PORT })); console.info(JSON.stringify({ event: "server_started", port: environment.PORT }));
}); });
server.on("error", (error) => { server.on("error", (error) => {
identity.stop(); identity.stop();
database database
@ -21,6 +23,7 @@ async function start(): Promise<void> {
process.exitCode = 1; process.exitCode = 1;
}); });
let stopping = false; let stopping = false;
const shutdown = () => { const shutdown = () => {
if (stopping) { if (stopping) {
return; return;
@ -38,6 +41,7 @@ async function start(): Promise<void> {
}); });
}); });
}; };
process.once("SIGINT", shutdown); process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown); process.once("SIGTERM", shutdown);
} catch (error) { } catch (error) {
@ -48,8 +52,10 @@ async function start(): Promise<void> {
throw error; throw error;
} }
} }
start().catch((error: unknown) => { start().catch((error: unknown) => {
const message = error instanceof Error ? error.message : "Unknown startup failure"; const message = error instanceof Error ? error.message : "Unknown startup failure";
console.error(JSON.stringify({ event: "server_start_failed", message })); console.error(JSON.stringify({ event: "server_start_failed", message }));
process.exitCode = 1; process.exitCode = 1;
}); });

View File

@ -5,6 +5,7 @@ export type HttpLogEntry = Readonly<{
statusCode: number; statusCode: number;
durationMs: number; durationMs: number;
}>; }>;
export type HttpLogger = (entry: HttpLogEntry) => void; export type HttpLogger = (entry: HttpLogEntry) => void;
// No se registran URL, cabeceras ni cuerpos: pueden contener datos de clientes o credenciales. // No se registran URL, cabeceras ni cuerpos: pueden contener datos de clientes o credenciales.

View File

@ -28,6 +28,7 @@ export async function createIdentityRuntime(
assets.services.inventory, assets.services.inventory,
resources.services.inventory resources.services.inventory
); );
await resources.migrateLegacy(); await resources.migrateLegacy();
const dashboard = companies.initializeDashboard( const dashboard = companies.initializeDashboard(
assets.services.dashboard, assets.services.dashboard,
@ -52,6 +53,7 @@ export async function createIdentityRuntime(
maxAge: 8 * 60 * 60 * 1000, maxAge: 8 * 60 * 60 * 1000,
}, },
}); });
return { return {
register: (app: Parameters<typeof identity.register>[0]) => { register: (app: Parameters<typeof identity.register>[0]) => {
identity.register(app); identity.register(app);

View File

@ -8,6 +8,7 @@ import {
} from "@metis/companies/common"; } from "@metis/companies/common";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyInterventionTemplates( export async function verifyInterventionTemplates(
request: Request, request: Request,
companyId: string, companyId: string,
@ -16,6 +17,7 @@ export async function verifyInterventionTemplates(
) { ) {
const path = "/intervention-templates/global"; const path = "/intervention-templates/global";
const list = InterventionTemplatesSchema.parse(await (await request(path)).json()); const list = InterventionTemplatesSchema.parse(await (await request(path)).json());
assert.equal(list.templates.length, 6); assert.equal(list.templates.length, 6);
assert.ok(list.templates.every((template) => template.companyId === null)); assert.ok(list.templates.every((template) => template.companyId === null));
const values = { const values = {
@ -28,12 +30,17 @@ export async function verifyInterventionTemplates(
], ],
}; };
const body = { values, requestKey: randomUUID(), expectedRevisionId: null }; const body = { values, requestKey: randomUUID(), expectedRevisionId: null };
async function save(url: string, payload: unknown, status = 201) { async function save(url: string, payload: unknown, status = 201) {
const response = await request(url, payload); const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text()); assert.equal(response.status, status, await response.clone().text());
return InterventionTemplateResponseSchema.parse(await response.json()).template; return InterventionTemplateResponseSchema.parse(await response.json()).template;
} }
const created = await save(path, body); const created = await save(path, body);
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.equal(created.history.length, 1); assert.equal(created.history.length, 1);
assert.equal((await save(path, body)).id, created.id); assert.equal((await save(path, body)).id, created.id);
@ -44,21 +51,26 @@ export async function verifyInterventionTemplates(
...body, ...body,
requestKey: randomUUID(), requestKey: randomUUID(),
}); });
assert.equal(local.companyId, companyId); assert.equal(local.companyId, companyId);
const scoped = InterventionTemplatesSchema.parse( const scoped = InterventionTemplatesSchema.parse(
await (await request(`/intervention-templates/${companyId}`)).json() await (await request(`/intervention-templates/${companyId}`)).json()
); );
assert.ok(scoped.templates.some((item) => item.id === created.id)); assert.ok(scoped.templates.some((item) => item.id === created.id));
assert.ok(scoped.templates.some((item) => item.id === local.id)); assert.ok(scoped.templates.some((item) => item.id === local.id));
const other = InterventionTemplatesSchema.parse( const other = InterventionTemplatesSchema.parse(
await (await request(`/intervention-templates/${otherId}`)).json() await (await request(`/intervention-templates/${otherId}`)).json()
); );
assert.ok(other.templates.every((item) => item.companyId === null)); assert.ok(other.templates.every((item) => item.companyId === null));
const updateBody = (template: InterventionTemplateDTO) => ({ const updateBody = (template: InterventionTemplateDTO) => ({
values: { ...template.values, active: false, steps: [...template.values.steps].reverse() }, values: { ...template.values, active: false, steps: [...template.values.steps].reverse() },
requestKey: randomUUID(), requestKey: randomUUID(),
expectedRevisionId: template.history.at(-1)?.id, expectedRevisionId: template.history.at(-1)?.id,
}); });
assert.equal( assert.equal(
( (
await request( await request(
@ -74,6 +86,7 @@ export async function verifyInterventionTemplates(
404 404
); );
const updated = await save(`${path}/${created.id}/update`, updateBody(created), 200); const updated = await save(`${path}/${created.id}/update`, updateBody(created), 200);
assert.equal(updated.history.length, 2); assert.equal(updated.history.length, 2);
assert.equal(updated.values.active, false); assert.equal(updated.values.active, false);
assert.equal(updated.history[0]?.values.active, true); assert.equal(updated.history[0]?.values.active, true);
@ -83,6 +96,7 @@ export async function verifyInterventionTemplates(
{ ...body, values: updated.values, expectedRevisionId: updated.history.at(-1)?.id }, { ...body, values: updated.values, expectedRevisionId: updated.history.at(-1)?.id },
200 200
); );
assert.equal(same.history.length, 2); assert.equal(same.history.length, 2);
assert.equal((await request(`${path}/${created.id}/update`, updateBody(created))).status, 409); assert.equal((await request(`${path}/${created.id}/update`, updateBody(created))).status, 409);
const responses = await Promise.all( const responses = await Promise.all(
@ -93,6 +107,7 @@ export async function verifyInterventionTemplates(
}) })
) )
); );
assert.deepEqual(responses.map((response) => response.status).sort(), [200, 409]); assert.deepEqual(responses.map((response) => response.status).sort(), [200, 409]);
assert.equal( assert.equal(
(await request(path, { ...body, requestKey: randomUUID(), values: { ...values, steps: [] } })) (await request(path, { ...body, requestKey: randomUUID(), values: { ...values, steps: [] } }))
@ -105,6 +120,7 @@ export async function verifyInterventionTemplates(
assert.equal((await request(path, body, "http://evil.example")).status, 403); assert.equal((await request(path, body, "http://evil.example")).status, 403);
assert.equal((await request(path, body, undefined, "invalid")).status, 403); assert.equal((await request(path, body, undefined, "invalid")).status, 403);
const seed = list.templates[0]; const seed = list.templates[0];
assert.ok(seed); assert.ok(seed);
const modifiedSeed = await save( const modifiedSeed = await save(
`${path}/${seed.id}/update`, `${path}/${seed.id}/update`,
@ -114,10 +130,12 @@ export async function verifyInterventionTemplates(
}, },
200 200
); );
await migrate(); await migrate();
await migrate(); await migrate();
const afterMigration = InterventionTemplatesSchema.parse(await (await request(path)).json()); const afterMigration = InterventionTemplatesSchema.parse(await (await request(path)).json());
const retained = afterMigration.templates.find((item) => item.id === seed.id); const retained = afterMigration.templates.find((item) => item.id === seed.id);
assert.deepEqual(retained, modifiedSeed); assert.deepEqual(retained, modifiedSeed);
assert.equal(afterMigration.templates.length, 7); assert.equal(afterMigration.templates.length, 7);
} }

View File

@ -12,17 +12,21 @@ import {
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyInterventions(request: Request, companyId: string, otherId: string) { export async function verifyInterventions(request: Request, companyId: string, otherId: string) {
const path = `/interventions/${companyId}`; const path = `/interventions/${companyId}`;
const lookupResponse = await request(`${path}/lookups`); const lookupResponse = await request(`${path}/lookups`);
assert.equal(lookupResponse.status, 200); assert.equal(lookupResponse.status, 200);
const lookups = InterventionLookupsSchema.parse(await lookupResponse.json()); const lookups = InterventionLookupsSchema.parse(await lookupResponse.json());
const contextResponse = await request("/interventions"); const contextResponse = await request("/interventions");
assert.equal(contextResponse.status, 200, await contextResponse.clone().text()); assert.equal(contextResponse.status, 200, await contextResponse.clone().text());
const context = InterventionListSchema.parse(await contextResponse.json()); const context = InterventionListSchema.parse(await contextResponse.json());
const technician = context.technicians.find((item) => item.active); const technician = context.technicians.find((item) => item.active);
const asset = lookups.assets.find((item) => item.active); const asset = lookups.assets.find((item) => item.active);
const ticket = lookups.tickets[0]; const ticket = lookups.tickets[0];
assert.ok(technician && asset && ticket); assert.ok(technician && asset && ticket);
const values: InterventionInput = { const values: InterventionInput = {
subject: "Actuación sencilla", subject: "Actuación sencilla",
@ -41,17 +45,24 @@ export async function verifyInterventions(request: Request, companyId: string, o
steps: [], steps: [],
}; };
const body = { values, template: null, requestKey: randomUUID(), expectedRevisionId: null }; const body = { values, template: null, requestKey: randomUUID(), expectedRevisionId: null };
async function save(url: string, payload: unknown, status = 201) { async function save(url: string, payload: unknown, status = 201) {
const response = await request(url, payload); const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text()); assert.equal(response.status, status, await response.clone().text());
return InterventionResponseSchema.parse(await response.json()).intervention; return InterventionResponseSchema.parse(await response.json()).intervention;
} }
async function error(url: string, payload: unknown, status: number, code: string) { async function error(url: string, payload: unknown, status: number, code: string) {
const response = await request(url, payload); const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text()); assert.equal(response.status, status, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
} }
const simple = await save(path, body); const simple = await save(path, body);
assert.equal(simple.id[14], "7"); assert.equal(simple.id[14], "7");
assert.match(simple.reference, /^I-[0-9]{6,}$/); assert.match(simple.reference, /^I-[0-9]{6,}$/);
assert.equal(simple.history.length, 1); assert.equal(simple.history.length, 1);
@ -61,13 +72,16 @@ export async function verifyInterventions(request: Request, companyId: string, o
assert.equal((await save(path, body)).id, simple.id); assert.equal((await save(path, body)).id, simple.id);
await error(path, { ...body, values: { ...values, subject: "Otra" } }, 409, "REQUEST_KEY_REUSED"); await error(path, { ...body, values: { ...values, subject: "Otra" } }, 409, "REQUEST_KEY_REUSED");
await error(`/interventions/${otherId}`, body, 409, "REQUEST_KEY_REUSED"); await error(`/interventions/${otherId}`, body, 409, "REQUEST_KEY_REUSED");
const update = (item: InterventionDTO, next: InterventionInput) => ({ const update = (item: InterventionDTO, next: InterventionInput) => ({
values: next, values: next,
template: null, template: null,
requestKey: randomUUID(), requestKey: randomUUID(),
expectedRevisionId: item.history.at(-1)?.id, expectedRevisionId: item.history.at(-1)?.id,
}); });
const same = await save(`${path}/${simple.id}/update`, update(simple, values), 200); const same = await save(`${path}/${simple.id}/update`, update(simple, values), 200);
assert.equal(same.history.length, 1); assert.equal(same.history.length, 1);
await error( await error(
path, path,
@ -86,6 +100,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
}), }),
200 200
); );
assert.equal(finished.history.length, 2); assert.equal(finished.history.length, 2);
assert.equal(finished.values.state, "finished"); assert.equal(finished.values.state, "finished");
await error(`${path}/${simple.id}/update`, update(simple, values), 409, "INTERVENTION_CHANGED"); await error(`${path}/${simple.id}/update`, update(simple, values), 409, "INTERVENTION_CHANGED");
@ -100,6 +115,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
request(`${path}/${simple.id}/update`, update(finished, { ...finished.values, resolution })) request(`${path}/${simple.id}/update`, update(finished, { ...finished.values, resolution }))
) )
); );
assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]); assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]);
const linked = await save(path, { const linked = await save(path, {
...body, ...body,
@ -111,6 +127,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
affected: [{ kind: "asset", targetId: asset.id }], affected: [{ kind: "asset", targetId: asset.id }],
}, },
}); });
assert.equal(linked.ticketReference, ticket.reference); assert.equal(linked.ticketReference, ticket.reference);
await error( await error(
`/interventions/${otherId}`, `/interventions/${otherId}`,
@ -137,6 +154,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
const globalTemplate = lookups.templates.find( const globalTemplate = lookups.templates.find(
(item) => item.values.active && item.companyId === null (item) => item.values.active && item.companyId === null
); );
assert.ok(globalTemplate); assert.ok(globalTemplate);
const step = { const step = {
id: randomUUID(), id: randomUUID(),
@ -153,6 +171,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
values: { ...values, subject: "Revisar con procedimiento", ticketId: ticket.id, steps: [step] }, values: { ...values, subject: "Revisar con procedimiento", ticketId: ticket.id, steps: [step] },
}; };
const templated = await save(path, templatedBody); const templated = await save(path, templatedBody);
assert.equal(templated.procedure?.values.name, globalTemplate.values.name); assert.equal(templated.procedure?.values.name, globalTemplate.values.name);
await error( await error(
path, path,
@ -172,6 +191,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
expectedRevisionId: globalTemplate.history.at(-1)?.id, expectedRevisionId: globalTemplate.history.at(-1)?.id,
} }
); );
assert.equal(beforeTemplateUpdate.status, 200); assert.equal(beforeTemplateUpdate.status, 200);
InterventionTemplateResponseSchema.parse(await beforeTemplateUpdate.json()); InterventionTemplateResponseSchema.parse(await beforeTemplateUpdate.json());
assert.equal((await save(path, templatedBody)).id, templated.id); assert.equal((await save(path, templatedBody)).id, templated.id);
@ -179,6 +199,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
const persisted = InterventionListSchema.parse( const persisted = InterventionListSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).interventions.find((item) => item.id === templated.id); ).interventions.find((item) => item.id === templated.id);
assert.deepEqual(persisted?.procedure, templated.procedure); assert.deepEqual(persisted?.procedure, templated.procedure);
await error( await error(
`${path}/${templated.id}/update`, `${path}/${templated.id}/update`,
@ -214,11 +235,13 @@ export async function verifyInterventions(request: Request, companyId: string, o
}), }),
200 200
); );
assert.equal(done.values.steps[0]?.notes, "No utiliza correo"); assert.equal(done.values.steps[0]?.notes, "No utiliza correo");
assert.equal(done.procedure?.revisionId, templated.procedure?.revisionId); assert.equal(done.procedure?.revisionId, templated.procedure?.revisionId);
const ticketAfter = TicketListSchema.parse( const ticketAfter = TicketListSchema.parse(
await (await request(`/tickets/${companyId}`)).json() await (await request(`/tickets/${companyId}`)).json()
).tickets.find((item) => item.id === ticket.id); ).tickets.find((item) => item.id === ticket.id);
assert.equal(ticketAfter?.values.state, ticket.values.state); assert.equal(ticketAfter?.values.state, ticket.values.state);
await error( await error(
`${path}/${done.id}/update`, `${path}/${done.id}/update`,
@ -227,10 +250,13 @@ export async function verifyInterventions(request: Request, companyId: string, o
"INVALID_REQUEST" "INVALID_REQUEST"
); );
const other = await save(`/interventions/${otherId}`, { ...body, requestKey: randomUUID() }); const other = await save(`/interventions/${otherId}`, { ...body, requestKey: randomUUID() });
assert.notEqual(other.reference, simple.reference); assert.notEqual(other.reference, simple.reference);
const all = InterventionListSchema.parse(await (await request("/interventions")).json()); const all = InterventionListSchema.parse(await (await request("/interventions")).json());
assert.ok(all.interventions.some((item) => item.id === other.id)); assert.ok(all.interventions.some((item) => item.id === other.id));
const scoped = InterventionListSchema.parse(await (await request(path)).json()); const scoped = InterventionListSchema.parse(await (await request(path)).json());
assert.ok(scoped.interventions.every((item) => item.companyId === companyId)); assert.ok(scoped.interventions.every((item) => item.companyId === companyId));
assert.equal((await request(path, { ...body, unexpected: true })).status, 400); assert.equal((await request(path, { ...body, unexpected: true })).status, 400);
assert.equal((await request("/interventions?unknown=1")).status, 400); assert.equal((await request("/interventions?unknown=1")).status, 400);

View File

@ -10,46 +10,64 @@ import { CompanyResponseSchema } from "@metis/companies/common";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { UniqueID } from "../../../packages/rdx-ddd/src/index"; import { UniqueID } from "../../../packages/rdx-ddd/src/index";
export async function verifyInventory( export async function verifyInventory(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>, request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
companyId: string, companyId: string,
otherId: string otherId: string
) { ) {
const path = `/assets/${companyId}`; const path = `/assets/${companyId}`;
async function save(url: string, values: unknown) { async function save(url: string, values: unknown) {
const response = await request(url, { values }); const response = await request(url, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text()); assert.ok([200, 201].includes(response.status), await response.clone().text());
return AssetResponseSchema.parse(await response.json()).asset; return AssetResponseSchema.parse(await response.json()).asset;
} }
async function error(url: string, values: unknown, code: string, status = 409) { async function error(url: string, values: unknown, code: string, status = 409) {
const response = await request(url, { values }); const response = await request(url, { values });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
} }
async function read(url = path) { async function read(url = path) {
const response = await request(url); const response = await request(url);
assert.equal(response.status, 200); assert.equal(response.status, 200);
return InventoryResponseSchema.parse(await response.json()); return InventoryResponseSchema.parse(await response.json());
} }
const catalog = CatalogResponseSchema.parse( const catalog = CatalogResponseSchema.parse(
await (await request(`/asset-catalog/${companyId}`)).json() await (await request(`/asset-catalog/${companyId}`)).json()
); );
const monitor = catalog.items.find((item) => item.values.name === "Monitor"); const monitor = catalog.items.find((item) => item.values.name === "Monitor");
assert.ok(monitor); assert.ok(monitor);
const laptop = catalog.items.find((item) => item.values.name === "Portátil"); const laptop = catalog.items.find((item) => item.values.name === "Portátil");
assert.ok(laptop); assert.ok(laptop);
const inch = catalog.items.find((item) => item.values.name === "Pulgadas"); const inch = catalog.items.find((item) => item.values.name === "Pulgadas");
assert.ok(inch); assert.ok(inch);
async function site(company: string) { async function site(company: string) {
const response = await request(`/companies/${company}/sites`, { const response = await request(`/companies/${company}/sites`, {
name: "Sede inventario", name: "Sede inventario",
address: null, address: null,
}); });
assert.equal(response.status, 201); assert.equal(response.status, 201);
const saved = CompanyResponseSchema.parse(await response.json()).company.sites.at(-1); const saved = CompanyResponseSchema.parse(await response.json()).company.sites.at(-1);
assert.ok(saved); assert.ok(saved);
return saved; return saved;
} }
const ownSite = await site(companyId); const ownSite = await site(companyId);
const foreignSite = await site(otherId); const foreignSite = await site(otherId);
const input = { const input = {
@ -60,8 +78,10 @@ export async function verifyInventory(
notes: "", notes: "",
characteristics: {}, characteristics: {},
}; };
assert.equal((await read()).assets.length, 0); assert.equal((await read()).assets.length, 0);
const created = await save(path, input); const created = await save(path, input);
assert.equal(created.history.length, 1); assert.equal(created.history.length, 1);
assert.equal(created.id.charAt(14), "7"); assert.equal(created.id.charAt(14), "7");
assert.equal(created.history[0]?.id.charAt(14), "7"); assert.equal(created.history[0]?.id.charAt(14), "7");
@ -85,9 +105,11 @@ export async function verifyInventory(
status: "in_use", status: "in_use",
characteristics: { [inch.id]: 24 }, characteristics: { [inch.id]: 24 },
}); });
assert.equal(inUse.id, created.id); assert.equal(inUse.id, created.id);
assert.equal(inUse.history.length, 2); assert.equal(inUse.history.length, 2);
const unchanged = await save(`${path}/${created.id}/update`, inUse.values); const unchanged = await save(`${path}/${created.id}/update`, inUse.values);
assert.deepEqual(unchanged.history, inUse.history); assert.deepEqual(unchanged.history, inUse.history);
await error( await error(
`${path}/${created.id}/update`, `${path}/${created.id}/update`,
@ -98,6 +120,7 @@ export async function verifyInventory(
...inUse.values, ...inUse.values,
status: "retired", status: "retired",
}); });
assert.equal(retired.history.length, 3); assert.equal(retired.history.length, 3);
const section = { id: UniqueID.generateNewID().toPrimitive(), name: "Pantalla", description: "" }; const section = { id: UniqueID.generateNewID().toPrimitive(), name: "Pantalla", description: "" };
@ -110,8 +133,10 @@ export async function verifyInventory(
fields: [{ fieldId: inch.id, required: true, sectionId: section.id }], fields: [{ fieldId: inch.id, required: true, sectionId: section.id }],
}, },
}); });
assert.equal(templateResponse.status, 201); assert.equal(templateResponse.status, 201);
const templateId = SaveDefinitionResponseSchema.parse(await templateResponse.json()).savedId; const templateId = SaveDefinitionResponseSchema.parse(await templateResponse.json()).savedId;
await error(path, { ...input, templateId }, "REQUIRED_CHARACTERISTICS"); await error(path, { ...input, templateId }, "REQUIRED_CHARACTERISTICS");
await error(`/assets/${otherId}`, { ...input, templateId }, "TEMPLATE_UNAVAILABLE"); await error(`/assets/${otherId}`, { ...input, templateId }, "TEMPLATE_UNAVAILABLE");
const custom = await save(path, { const custom = await save(path, {
@ -121,6 +146,7 @@ export async function verifyInventory(
characteristics: { [inch.id]: 0 }, characteristics: { [inch.id]: 0 },
}); });
const revisionId = custom.history[0]?.id; const revisionId = custom.history[0]?.id;
assert.equal( assert.equal(
( (
await request(`/asset-catalog/${companyId}/${templateId}/update`, { await request(`/asset-catalog/${companyId}/${templateId}/update`, {
@ -135,6 +161,7 @@ export async function verifyInventory(
200 200
); );
const current = (await read()).assets.find((asset) => asset.id === custom.id); const current = (await read()).assets.find((asset) => asset.id === custom.id);
assert.ok(current); assert.ok(current);
assert.equal(current.specification.templateName, "Monitor obligatorio inventario"); assert.equal(current.specification.templateName, "Monitor obligatorio inventario");
assert.equal(current.specification.fields[0]?.required, true); assert.equal(current.specification.fields[0]?.required, true);
@ -154,8 +181,10 @@ export async function verifyInventory(
...custom.values, ...custom.values,
notes: "Plantilla conservada", notes: "Plantilla conservada",
}); });
assert.equal(updated.history[0]?.id, revisionId); assert.equal(updated.history[0]?.id, revisionId);
const stored = (await read()).assets.find((asset) => asset.id === updated.id); const stored = (await read()).assets.find((asset) => asset.id === updated.id);
assert.deepEqual(stored, updated); assert.deepEqual(stored, updated);
assert.equal((await request("/assets/00000000-0000-4000-8000-000000000001")).status, 404); assert.equal((await request("/assets/00000000-0000-4000-8000-000000000001")).status, 404);
} }

View File

@ -20,6 +20,7 @@ import { QueryTypes } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyMail( export async function verifyMail(
request: Request, request: Request,
companyId: string, companyId: string,
@ -41,12 +42,14 @@ export async function verifyMail(
const second = assets[1]; const second = assets[1];
const otherPerson = company.people.find((item) => item.active); const otherPerson = company.people.find((item) => item.active);
const foreignPerson = other.people[0]; const foreignPerson = other.people[0];
assert.ok(first && second && otherPerson && foreignPerson); assert.ok(first && second && otherPerson && foreignPerson);
for (const asset of [first, second]) { for (const asset of [first, second]) {
if (asset.values.status === "retired") { if (asset.values.status === "retired") {
const response = await request(`/assets/${companyId}/${asset.id}/update`, { const response = await request(`/assets/${companyId}/${asset.id}/update`, {
values: { ...asset.values, status: "available" }, values: { ...asset.values, status: "available" },
}); });
assert.equal(response.status, 200, await response.clone().text()); assert.equal(response.status, 200, await response.clone().text());
} }
} }
@ -59,10 +62,13 @@ export async function verifyMail(
isContact: false, isContact: false,
}; };
const peopleResponse = await request(`/companies/${companyId}/people`, personFields); const peopleResponse = await request(`/companies/${companyId}/people`, personFields);
assert.equal(peopleResponse.status, 201); assert.equal(peopleResponse.status, 201);
const person = CompanyResponseSchema.parse(await peopleResponse.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await peopleResponse.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const profile = async (personId: string, assetId: string) => { const profile = async (personId: string, assetId: string) => {
const response = await request(`/work-profiles/${companyId}`, { const response = await request(`/work-profiles/${companyId}`, {
values: { values: {
@ -74,28 +80,40 @@ export async function verifyMail(
notes: null, notes: null,
}, },
}); });
assert.equal(response.status, 201, await response.clone().text()); assert.equal(response.status, 201, await response.clone().text());
return WorkProfileResponseSchema.parse(await response.json()).profile; return WorkProfileResponseSchema.parse(await response.json()).profile;
}; };
const primary = await profile(person.id, first.id); const primary = await profile(person.id, first.id);
const mobileProfile = await profile(person.id, second.id); const mobileProfile = await profile(person.id, second.id);
const sharedProfile = await profile(otherPerson.id, first.id); const sharedProfile = await profile(otherPerson.id, first.id);
const saveBox = async (values: MailboxInput, id?: string) => { const saveBox = async (values: MailboxInput, id?: string) => {
const response = await request(boxPath + (id ? `/${id}/update` : ""), { values }); const response = await request(boxPath + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text()); assert.equal(response.status, id ? 200 : 201, await response.clone().text());
const data = MailboxesResponseSchema.parse(await response.json()); const data = MailboxesResponseSchema.parse(await response.json());
const box = data.mailboxes.find((item) => item.id === data.savedId); const box = data.mailboxes.find((item) => item.id === data.savedId);
assert.ok(box); assert.ok(box);
return box; return box;
}; };
const saveUse = async (values: MailboxUseInput, id?: string) => { const saveUse = async (values: MailboxUseInput, id?: string) => {
const response = await request(`${boxPath}/uses${id ? `/${id}/update` : ""}`, { values }); const response = await request(`${boxPath}/uses${id ? `/${id}/update` : ""}`, { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text()); assert.equal(response.status, id ? 200 : 201, await response.clone().text());
const data = MailboxesResponseSchema.parse(await response.json()); const data = MailboxesResponseSchema.parse(await response.json());
const use = data.uses.find((item) => item.id === data.savedId); const use = data.uses.find((item) => item.id === data.savedId);
assert.ok(use); assert.ok(use);
return use; return use;
}; };
const boxFields: MailboxInput = { const boxFields: MailboxInput = {
address: "ventas@example.test", address: "ventas@example.test",
kind: "corporate", kind: "corporate",
@ -113,6 +131,7 @@ export async function verifyMail(
}); });
const useFields = { mailboxId: box.id, personId: person.id, startDate: today, endDate: null }; const useFields = { mailboxId: box.id, personId: person.id, startDate: today, endDate: null };
const authorization = await saveUse(useFields); const authorization = await saveUse(useFields);
await saveUse({ ...useFields, mailboxId: external.id }); await saveUse({ ...useFields, mailboxId: external.id });
await saveUse({ ...useFields, personId: otherPerson.id }); await saveUse({ ...useFields, personId: otherPerson.id });
assert.equal(box.id[14], "7"); assert.equal(box.id[14], "7");
@ -121,6 +140,7 @@ export async function verifyMail(
const duplicateBox = await request(boxPath, { const duplicateBox = await request(boxPath, {
values: { ...boxFields, address: "VENTAS@EXAMPLE.TEST" }, values: { ...boxFields, address: "VENTAS@EXAMPLE.TEST" },
}); });
assert.equal(duplicateBox.status, 409); assert.equal(duplicateBox.status, 409);
assert.equal((await request(`${boxPath}/uses`, { values: useFields })).status, 409); assert.equal((await request(`${boxPath}/uses`, { values: useFields })).status, 409);
assert.equal( assert.equal(
@ -144,16 +164,22 @@ export async function verifyMail(
state: "configured", state: "configured",
notes: null, notes: null,
}; };
const save = async (values: MailConfigurationInput, id?: string) => { const save = async (values: MailConfigurationInput, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text()); assert.equal(response.status, id ? 200 : 201, await response.clone().text());
return MailConfigurationResponseSchema.parse(await response.json()).configuration; return MailConfigurationResponseSchema.parse(await response.json()).configuration;
}; };
const error = async (values: unknown, code: string, id?: string, status = 409) => { const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status, await response.clone().text()); assert.equal(response.status, status, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, profileId: foreignPerson.id }, "MAIL_SCOPE_MISMATCH"); await error({ ...input, profileId: foreignPerson.id }, "MAIL_SCOPE_MISMATCH");
await error({ ...input, mailboxId: foreignPerson.id }, "MAIL_SCOPE_MISMATCH"); await error({ ...input, mailboxId: foreignPerson.id }, "MAIL_SCOPE_MISMATCH");
await error({ ...input, address: "invalid" }, "INVALID_REQUEST", undefined, 400); await error({ ...input, address: "invalid" }, "INVALID_REQUEST", undefined, 400);
@ -165,10 +191,12 @@ export async function verifyMail(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: input }), request(path, { values: input }),
]); ]);
assert.deepEqual([a.status, b.status].sort(), [201, 409]); assert.deepEqual([a.status, b.status].sort(), [201, 409]);
const created = MailConfigurationResponseSchema.parse( const created = MailConfigurationResponseSchema.parse(
await (a.status === 201 ? a : b).json() await (a.status === 201 ? a : b).json()
).configuration; ).configuration;
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7"); assert.equal(created.history[0]?.id[14], "7");
assert.ok(created.history[0]?.actorName); assert.ok(created.history[0]?.actorName);
@ -182,6 +210,7 @@ export async function verifyMail(
state: "pending", state: "pending",
method: "pop", method: "pop",
}); });
assert.equal(new Set([created.id, personal.id, mobile.id, shared.id]).size, 4); assert.equal(new Set([created.id, personal.id, mobile.id, shared.id]).size, 4);
await error( await error(
{ ...input, profileId: mobileProfile.id, state: "removed" }, { ...input, profileId: mobileProfile.id, state: "removed" },
@ -198,11 +227,13 @@ export async function verifyMail(
const reviewed = MailConfigurationsResponseSchema.parse( const reviewed = MailConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations; ).configurations;
assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true); assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true);
assert.equal(reviewed.find((item) => item.id === personal.id)?.needsReview, false); assert.equal(reviewed.find((item) => item.id === personal.id)?.needsReview, false);
await error({ ...input, method: "pop" }, "MAIL_NOT_AUTHORIZED", created.id); await error({ ...input, method: "pop" }, "MAIL_NOT_AUTHORIZED", created.id);
const pending = await save({ ...input, state: "removal_pending" }, created.id); const pending = await save({ ...input, state: "removal_pending" }, created.id);
const removed = await save({ ...input, state: "removed" }, created.id); const removed = await save({ ...input, state: "removed" }, created.id);
assert.equal(pending.history.length, 2); assert.equal(pending.history.length, 2);
assert.equal(removed.history.length, 3); assert.equal(removed.history.length, 3);
assert.equal(removed.history[0]?.values.state, "configured"); assert.equal(removed.history[0]?.values.state, "configured");
@ -212,19 +243,23 @@ export async function verifyMail(
...personFields, ...personFields,
active: false, active: false,
}); });
assert.equal(deactivate.status, 200); assert.equal(deactivate.status, 200);
const inactive = MailConfigurationsResponseSchema.parse( const inactive = MailConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations; ).configurations;
assert.equal(inactive.find((item) => item.id === mobile.id)?.needsReview, true); assert.equal(inactive.find((item) => item.id === mobile.id)?.needsReview, true);
const never = await save( const never = await save(
{ ...input, mailboxId: external.id, state: "never_configured" }, { ...input, mailboxId: external.id, state: "never_configured" },
personal.id personal.id
); );
assert.equal(never.history.length, 2); assert.equal(never.history.length, 2);
// Simula un registro previo: no se reescriben sus UUID ni sus revisiones al vincularlo. // Simula un registro previo: no se reescriben sus UUID ni sus revisiones al vincularlo.
const active = await request(`/companies/${companyId}/people/${person.id}/update`, personFields); const active = await request(`/companies/${companyId}/people/${person.id}/update`, personFields);
assert.equal(active.status, 200); assert.equal(active.status, 200);
await saveUse( await saveUse(
{ ...useFields, mailboxId: external.id, startDate: today, endDate: today }, { ...useFields, mailboxId: external.id, startDate: today, endDate: today },
@ -233,8 +268,10 @@ export async function verifyMail(
const systemResponse = await request(`/access/${companyId}`, { const systemResponse = await request(`/access/${companyId}`, {
values: { kind: "system", name: "Correo legado", active: true }, values: { kind: "system", name: "Correo legado", active: true },
}); });
assert.equal(systemResponse.status, 201); assert.equal(systemResponse.status, 201);
const system = AccessResponseSchema.parse(await systemResponse.json()).items.at(-1); const system = AccessResponseSchema.parse(await systemResponse.json()).items.at(-1);
assert.ok(system); assert.ok(system);
const accountResponse = await request(`/access/${companyId}`, { const accountResponse = await request(`/access/${companyId}`, {
values: { values: {
@ -248,12 +285,15 @@ export async function verifyMail(
notes: null, notes: null,
}, },
}); });
assert.equal(accountResponse.status, 201); assert.equal(accountResponse.status, 201);
const account = AccessResponseSchema.parse(await accountResponse.json()).items.at(-1); const account = AccessResponseSchema.parse(await accountResponse.json()).items.at(-1);
assert.ok(account); assert.ok(account);
const legacyId = randomUUID(); const legacyId = randomUUID();
const revisionId = randomUUID(); const revisionId = randomUUID();
const actorId = created.history[0]?.actorId; const actorId = created.history[0]?.actorId;
assert.ok(actorId); assert.ok(actorId);
const legacyValues = { const legacyValues = {
personId: person.id, personId: person.id,
@ -265,6 +305,7 @@ export async function verifyMail(
notes: "Conservar", notes: "Conservar",
}; };
const raw = JSON.stringify(legacyValues); const raw = JSON.stringify(legacyValues);
await database.query( await database.query(
"INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,NULL,NULL,:values,:key)", "INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,NULL,NULL,:values,:key)",
{ {
@ -292,6 +333,7 @@ export async function verifyMail(
const legacy = MailConfigurationsResponseSchema.parse( const legacy = MailConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations.find((item) => item.id === legacyId); ).configurations.find((item) => item.id === legacyId);
assert.ok(legacy?.legacy); assert.ok(legacy?.legacy);
assert.equal(legacy.needsReview, true); assert.equal(legacy.needsReview, true);
await error({ ...input, mailboxId: external.id }, "MAIL_LEGACY_TARGET_MISMATCH", legacyId); await error({ ...input, mailboxId: external.id }, "MAIL_LEGACY_TARGET_MISMATCH", legacyId);
@ -300,8 +342,10 @@ export async function verifyMail(
address: legacyValues.address, address: legacyValues.address,
kind: "external", kind: "external",
}); });
await saveUse({ ...useFields, mailboxId: legacyBox.id }); await saveUse({ ...useFields, mailboxId: legacyBox.id });
const adopted = await save({ ...input, mailboxId: legacyBox.id, notes: "Conservar" }, legacyId); const adopted = await save({ ...input, mailboxId: legacyBox.id, notes: "Conservar" }, legacyId);
assert.equal(adopted.id, legacyId); assert.equal(adopted.id, legacyId);
assert.equal(adopted.legacy, false); assert.equal(adopted.legacy, false);
assert.equal(adopted.history[0]?.id, revisionId); assert.equal(adopted.history[0]?.id, revisionId);
@ -312,6 +356,7 @@ export async function verifyMail(
{ replacements: { id: revisionId }, type: QueryTypes.SELECT } { replacements: { id: revisionId }, type: QueryTypes.SELECT }
); );
const original = persisted[0]?.values; const original = persisted[0]?.values;
assert.ok(original); assert.ok(original);
assert.deepEqual(typeof original === "string" ? JSON.parse(original) : original, legacyValues); assert.deepEqual(typeof original === "string" ? JSON.parse(original) : original, legacyValues);
await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", { await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", {

View File

@ -13,6 +13,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyPrinters( export async function verifyPrinters(
request: Request, request: Request,
companyId: string, companyId: string,
@ -27,13 +28,16 @@ export async function verifyPrinters(
active: true, active: true,
isContact: false, isContact: false,
}); });
assert.equal(personResponse.status, 201); assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const inventory = InventoryResponseSchema.parse( const inventory = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json() await (await request(`/assets/${companyId}`)).json()
); );
const host = inventory.assets.find((item) => item.values.status !== "retired"); const host = inventory.assets.find((item) => item.values.status !== "retired");
assert.ok(host); assert.ok(host);
const catalog = CatalogResponseSchema.parse( const catalog = CatalogResponseSchema.parse(
await (await request(`/asset-catalog/${companyId}`)).json() await (await request(`/asset-catalog/${companyId}`)).json()
@ -42,7 +46,9 @@ export async function verifyPrinters(
(item) => (item) =>
item.values.kind === "template" && item.values.name === "Impresora" && item.effectiveActive item.values.kind === "template" && item.values.name === "Impresora" && item.effectiveActive
); );
assert.ok(template); assert.ok(template);
const addPrinter = async (name: string) => { const addPrinter = async (name: string) => {
const response = await request(`/assets/${companyId}`, { const response = await request(`/assets/${companyId}`, {
values: { values: {
@ -54,13 +60,17 @@ export async function verifyPrinters(
characteristics: {}, characteristics: {},
}, },
}); });
assert.equal(response.status, 201); assert.equal(response.status, 201);
const current = InventoryResponseSchema.parse( const current = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json() await (await request(`/assets/${companyId}`)).json()
).assets.find((item) => item.values.name === name); ).assets.find((item) => item.values.name === name);
assert.ok(current); assert.ok(current);
return current; return current;
}; };
const first = await addPrinter("Impresora prueba USB"); const first = await addPrinter("Impresora prueba USB");
const second = await addPrinter("Impresora prueba red"); const second = await addPrinter("Impresora prueba red");
const profileValues = { const profileValues = {
@ -72,6 +82,7 @@ export async function verifyPrinters(
notes: null, notes: null,
}; };
const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues }); const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues });
assert.equal(profileResponse.status, 201); assert.equal(profileResponse.status, 201);
const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile; const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile;
const path = `/printer-configurations/${companyId}`; const path = `/printer-configurations/${companyId}`;
@ -83,16 +94,22 @@ export async function verifyPrinters(
state: "configured", state: "configured",
notes: null, notes: null,
}; };
const save = async (values: PrinterConfigurationInput, id?: string) => { const save = async (values: PrinterConfigurationInput, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201); assert.equal(response.status, id ? 200 : 201);
return PrinterConfigurationResponseSchema.parse(await response.json()).configuration; return PrinterConfigurationResponseSchema.parse(await response.json()).configuration;
}; };
const error = async (values: unknown, code: string, id?: string, status = 409) => { const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
await error({ ...input, printerId: randomUUID() }, "PRINTER_REFERENCE_NOT_FOUND", undefined, 404); await error({ ...input, printerId: randomUUID() }, "PRINTER_REFERENCE_NOT_FOUND", undefined, 404);
await error({ ...input, printerId: host.id }, "PRINTER_REFERENCE_NOT_FOUND", undefined, 404); await error({ ...input, printerId: host.id }, "PRINTER_REFERENCE_NOT_FOUND", undefined, 404);
@ -102,10 +119,13 @@ export async function verifyPrinters(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: input }), request(path, { values: input }),
]); ]);
assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]);
const successful = attempts.find((response) => response.status === 201); const successful = attempts.find((response) => response.status === 201);
assert.ok(successful); assert.ok(successful);
const created = PrinterConfigurationResponseSchema.parse(await successful.json()).configuration; const created = PrinterConfigurationResponseSchema.parse(await successful.json()).configuration;
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7"); assert.equal(created.history[0]?.id[14], "7");
assert.ok(created.history[0]?.actorName); assert.ok(created.history[0]?.actorName);
@ -118,6 +138,7 @@ export async function verifyPrinters(
isDefault: false, isDefault: false,
state: "pending", state: "pending",
}); });
await error({ ...input, printerId: second.id }, "PRINTER_TARGET_IMMUTABLE", created.id); await error({ ...input, printerId: second.id }, "PRINTER_TARGET_IMMUTABLE", created.id);
await error( await error(
{ ...input, state: "removed", isDefault: false }, { ...input, state: "removed", isDefault: false },
@ -137,10 +158,12 @@ export async function verifyPrinters(
const archive = await request(`/work-profiles/${companyId}/${profile.id}/update`, { const archive = await request(`/work-profiles/${companyId}/${profile.id}/update`, {
values: { ...profileValues, active: false }, values: { ...profileValues, active: false },
}); });
assert.equal(archive.status, 200); assert.equal(archive.status, 200);
const reviewed = PrinterConfigurationsResponseSchema.parse( const reviewed = PrinterConfigurationsResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).configurations; ).configurations;
assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true); assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true);
await error( await error(
{ ...network.values, state: "configured" }, { ...network.values, state: "configured" },
@ -148,14 +171,17 @@ export async function verifyPrinters(
network.id network.id
); );
const noted = await save({ ...created.values, notes: "Retirar tras salida" }, created.id); const noted = await save({ ...created.values, notes: "Retirar tras salida" }, created.id);
assert.equal(noted.history.length, 2); assert.equal(noted.history.length, 2);
assert.equal(noted.history[0]?.values.notes, null); assert.equal(noted.history[0]?.values.notes, null);
const removal = await save( const removal = await save(
{ ...noted.values, state: "removal_pending", isDefault: false }, { ...noted.values, state: "removal_pending", isDefault: false },
created.id created.id
); );
assert.equal(removal.needsReview, true); assert.equal(removal.needsReview, true);
const removed = await save({ ...removal.values, state: "removed" }, created.id); const removed = await save({ ...removal.values, state: "removed" }, created.id);
assert.equal(removed.needsReview, false); assert.equal(removed.needsReview, false);
await error({ ...removed.values, notes: "Otro" }, "INVALID_PRINTER_TRANSITION", created.id); await error({ ...removed.values, notes: "Otro" }, "INVALID_PRINTER_TRANSITION", created.id);
assert.equal( assert.equal(

View File

@ -19,6 +19,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyProcesses( export async function verifyProcesses(
request: Request, request: Request,
companyId: string, companyId: string,
@ -29,6 +30,7 @@ export async function verifyProcesses(
const templates = ProcessTemplatesResponseSchema.parse( const templates = ProcessTemplatesResponseSchema.parse(
await (await request("/processes/templates")).json() await (await request("/processes/templates")).json()
).templates; ).templates;
for (const kind of ["onboarding", "change", "offboarding"]) { for (const kind of ["onboarding", "change", "offboarding"]) {
assert.ok(templates.some((t) => t.values.kind === kind && t.values.active)); assert.ok(templates.some((t) => t.values.kind === kind && t.values.active));
} }
@ -40,8 +42,10 @@ export async function verifyProcesses(
instructions: ["Revisar y documentar"], instructions: ["Revisar y documentar"],
}; };
const templateResponse = await request("/processes/templates", { values: templateValues }); const templateResponse = await request("/processes/templates", { values: templateValues });
assert.equal(templateResponse.status, 201); assert.equal(templateResponse.status, 201);
const template = ProcessTemplateResponseSchema.parse(await templateResponse.json()).template; const template = ProcessTemplateResponseSchema.parse(await templateResponse.json()).template;
assert.equal(template.id[14], "7"); assert.equal(template.id[14], "7");
const personValues = { const personValues = {
name: "Persona proceso", name: "Persona proceso",
@ -53,10 +57,12 @@ export async function verifyProcesses(
}; };
const personResponse = await request(`/companies/${companyId}/people`, personValues); const personResponse = await request(`/companies/${companyId}/people`, personValues);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const host = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()).assets.find( const host = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()).assets.find(
(a) => a.values.status !== "retired" (a) => a.values.status !== "retired"
); );
assert.ok(host); assert.ok(host);
const profileResponse = await request(`/work-profiles/${companyId}`, { const profileResponse = await request(`/work-profiles/${companyId}`, {
values: { values: {
@ -68,6 +74,7 @@ export async function verifyProcesses(
notes: null, notes: null,
}, },
}); });
assert.equal(profileResponse.status, 201); assert.equal(profileResponse.status, 201);
const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile; const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile;
const workstationResponse = await request(`/assets/${companyId}/workstations`, { const workstationResponse = await request(`/assets/${companyId}/workstations`, {
@ -80,10 +87,12 @@ export async function verifyProcesses(
sharedAssetIds: [], sharedAssetIds: [],
}, },
}); });
assert.equal(workstationResponse.status, 201); assert.equal(workstationResponse.status, 201);
const resource = ResourcesSchema.parse( const resource = ResourcesSchema.parse(
await (await request(`/resources/${companyId}`)).json() await (await request(`/resources/${companyId}`)).json()
).resources.find((r) => r.values.active && r.specification.templateName === "Aplicación"); ).resources.find((r) => r.values.active && r.specification.templateName === "Aplicación");
assert.ok(resource); assert.ok(resource);
const appValues = { const appValues = {
profileId: profile.id, profileId: profile.id,
@ -96,6 +105,7 @@ export async function verifyProcesses(
const appResponse = await request(`/application-configurations/${companyId}`, { const appResponse = await request(`/application-configurations/${companyId}`, {
values: appValues, values: appValues,
}); });
assert.equal(appResponse.status, 201); assert.equal(appResponse.status, 201);
const app = ApplicationConfigurationResponseSchema.parse(await appResponse.json()).configuration; const app = ApplicationConfigurationResponseSchema.parse(await appResponse.json()).configuration;
const remoteValues = { const remoteValues = {
@ -113,11 +123,13 @@ export async function verifyProcesses(
const remoteResponse = await request(`/remote-connections/${companyId}`, { const remoteResponse = await request(`/remote-connections/${companyId}`, {
values: remoteValues, values: remoteValues,
}); });
assert.equal(remoteResponse.status, 201); assert.equal(remoteResponse.status, 201);
const remote = RemoteConnectionResponseSchema.parse(await remoteResponse.json()).connection; const remote = RemoteConnectionResponseSchema.parse(await remoteResponse.json()).connection;
const currentReview = ProcessReviewResponseSchema.parse( const currentReview = ProcessReviewResponseSchema.parse(
await (await request(`${path}/people/${person.id}/review`)).json() await (await request(`${path}/people/${person.id}/review`)).json()
).review; ).review;
assert.ok( assert.ok(
currentReview.some( currentReview.some(
(r) => r.kind === "remote" && r.id === remote.id && r.sourceKind === "personal_mobile" (r) => r.kind === "remote" && r.id === remote.id && r.sourceKind === "personal_mobile"
@ -132,11 +144,14 @@ export async function verifyProcesses(
name: "Salida persona proceso", name: "Salida persona proceso",
notes: null, notes: null,
}; };
const error = async (body: unknown, code: string, id?: string, status = 409) => { const error = async (body: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values: body }); const response = await request(path + (id ? `/${id}/update` : ""), { values: body });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
await error({ ...input, personId: randomUUID() }, "PROCESS_PERSON_NOT_FOUND", undefined, 404); await error({ ...input, personId: randomUUID() }, "PROCESS_PERSON_NOT_FOUND", undefined, 404);
assert.equal((await request(path, { values: input }, "https://other.example")).status, 403); assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);
@ -145,15 +160,19 @@ export async function verifyProcesses(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: input }), request(path, { values: input }),
]); ]);
assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 409]); assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 409]);
const createdResponse = concurrent.find((r) => r.status === 201); const createdResponse = concurrent.find((r) => r.status === 201);
assert.ok(createdResponse); assert.ok(createdResponse);
const process = ProcessResponseSchema.parse(await createdResponse.json()).process; const process = ProcessResponseSchema.parse(await createdResponse.json()).process;
assert.equal(process.id[14], "7"); assert.equal(process.id[14], "7");
assert.equal(process.history[0]?.id[14], "7"); assert.equal(process.history[0]?.id[14], "7");
assert.ok(process.values.tasks.every((t) => t.id[14] === "7")); assert.ok(process.values.tasks.every((t) => t.id[14] === "7"));
assert.equal(process.values.tasks.length, currentReview.length + 1); assert.equal(process.values.tasks.length, currentReview.length + 1);
const revision = process.history.at(-1); const revision = process.history.at(-1);
assert.ok(revision); assert.ok(revision);
const edit: UpdateProcessInput = { const edit: UpdateProcessInput = {
name: process.values.name, name: process.values.name,
@ -162,12 +181,17 @@ export async function verifyProcesses(
revisionId: revision.id, revisionId: revision.id,
tasks: process.values.tasks.map((t) => ({ id: t.id, status: t.status, notes: t.notes })), tasks: process.values.tasks.map((t) => ({ id: t.id, status: t.status, notes: t.notes })),
}; };
const save = async (values: UpdateProcessInput) => { const save = async (values: UpdateProcessInput) => {
const response = await request(`${path}/${process.id}/update`, { values }); const response = await request(`${path}/${process.id}/update`, { values });
assert.equal(response.status, 200); assert.equal(response.status, 200);
return ProcessResponseSchema.parse(await response.json()).process; return ProcessResponseSchema.parse(await response.json()).process;
}; };
const unchanged = await save(edit); const unchanged = await save(edit);
assert.deepEqual(unchanged, process); assert.deepEqual(unchanged, process);
await error({ ...edit, state: "completed" }, "PROCESS_PENDING_TASKS", process.id); await error({ ...edit, state: "completed" }, "PROCESS_PENDING_TASKS", process.id);
await error({ ...edit, tasks: edit.tasks.slice(1) }, "PROCESS_TASKS_IMMUTABLE", process.id); await error({ ...edit, tasks: edit.tasks.slice(1) }, "PROCESS_TASKS_IMMUTABLE", process.id);
@ -183,6 +207,7 @@ export async function verifyProcesses(
const templateUpdate = await request(`/processes/templates/${template.id}/update`, { const templateUpdate = await request(`/processes/templates/${template.id}/update`, {
values: { ...templateValues, active: false, instructions: ["Otra plantilla"] }, values: { ...templateValues, active: false, instructions: ["Otra plantilla"] },
}); });
assert.equal(templateUpdate.status, 200); assert.equal(templateUpdate.status, 200);
const resolved = await save({ const resolved = await save({
...edit, ...edit,
@ -192,6 +217,7 @@ export async function verifyProcesses(
notes: "Revisado; conserva acceso hasta entrega", notes: "Revisado; conserva acceso hasta entrega",
})), })),
}); });
assert.equal(resolved.values.tasks[0]?.title, "Revisar y documentar"); assert.equal(resolved.values.tasks[0]?.title, "Revisar y documentar");
assert.equal(resolved.history.length, 2); assert.equal(resolved.history.length, 2);
await error({ ...edit, notes: "Cambio perdido" }, "PROCESS_CONFLICT", process.id); await error({ ...edit, notes: "Cambio perdido" }, "PROCESS_CONFLICT", process.id);
@ -199,16 +225,20 @@ export async function verifyProcesses(
...personValues, ...personValues,
active: false, active: false,
}); });
assert.equal(inactivePerson.status, 200); assert.equal(inactivePerson.status, 200);
const newRemote = await request(`/remote-connections/${companyId}`, { const newRemote = await request(`/remote-connections/${companyId}`, {
values: { ...remoteValues, method: "Herramienta nueva proceso" }, values: { ...remoteValues, method: "Herramienta nueva proceso" },
}); });
assert.equal(newRemote.status, 201); assert.equal(newRemote.status, 201);
const refreshedResponse = await request(`${path}/${process.id}/refresh`, { const refreshedResponse = await request(`${path}/${process.id}/refresh`, {
revisionId: resolved.history.at(-1)?.id, revisionId: resolved.history.at(-1)?.id,
}); });
assert.equal(refreshedResponse.status, 200); assert.equal(refreshedResponse.status, 200);
const refreshed = ProcessResponseSchema.parse(await refreshedResponse.json()).process; const refreshed = ProcessResponseSchema.parse(await refreshedResponse.json()).process;
assert.equal(refreshed.values.tasks.length, resolved.values.tasks.length + 1); assert.equal(refreshed.values.tasks.length, resolved.values.tasks.length + 1);
assert.equal(refreshed.history.length, 3); assert.equal(refreshed.history.length, 3);
assert.ok(refreshed.values.tasks.slice(0, -1).every((t) => t.status === "done")); assert.ok(refreshed.values.tasks.slice(0, -1).every((t) => t.status === "done"));
@ -228,6 +258,7 @@ export async function verifyProcesses(
state: "completed", state: "completed",
tasks: freshTasks, tasks: freshTasks,
}); });
assert.equal(finished.values.state, "completed"); assert.equal(finished.values.state, "completed");
assert.equal(finished.history.length, 4); assert.equal(finished.history.length, 4);
assert.ok(finished.review.some((r) => r.kind === "remote")); // Completar seguimiento no revoca una conexión real. assert.ok(finished.review.some((r) => r.kind === "remote")); // Completar seguimiento no revoca una conexión real.
@ -253,23 +284,29 @@ export async function verifyProcesses(
200 200
); );
const restarted = await request(path, { values: input }); const restarted = await request(path, { values: input });
assert.equal(restarted.status, 201); assert.equal(restarted.status, 201);
const next = ProcessResponseSchema.parse(await restarted.json()).process; const next = ProcessResponseSchema.parse(await restarted.json()).process;
assert.notEqual(next.id, process.id); assert.notEqual(next.id, process.id);
assert.equal((await request("/processes/templates", { values: templateValues })).status, 409); assert.equal((await request("/processes/templates", { values: templateValues })).status, 409);
const onboarding = templates.find((t) => t.values.kind === "onboarding"); const onboarding = templates.find((t) => t.values.kind === "onboarding");
assert.ok(onboarding); assert.ok(onboarding);
const onboardResponse = await request(path, { const onboardResponse = await request(path, {
values: { ...input, templateId: onboarding.id, name: "Alta test" }, values: { ...input, templateId: onboarding.id, name: "Alta test" },
}); });
assert.equal(onboardResponse.status, 201); assert.equal(onboardResponse.status, 201);
const onboard = ProcessResponseSchema.parse(await onboardResponse.json()).process; const onboard = ProcessResponseSchema.parse(await onboardResponse.json()).process;
assert.ok(onboard.values.tasks.every((t) => !t.reference)); assert.ok(onboard.values.tasks.every((t) => !t.reference));
await database.query("UPDATE person_processes SET currentKey=:key WHERE id=:id", { await database.query("UPDATE person_processes SET currentKey=:key WHERE id=:id", {
replacements: { key: "bad", id: next.id }, replacements: { key: "bad", id: next.id },
}); });
try { try {
const corrupt = await request(path); const corrupt = await request(path);
assert.equal(corrupt.status, 500); assert.equal(corrupt.status, 500);
assert.ok(!(await corrupt.text()).includes("SELECT")); assert.ok(!(await corrupt.text()).includes("SELECT"));
} finally { } finally {

View File

@ -9,6 +9,7 @@ type Request = (path: string, body?: unknown) => Promise<Response>;
export async function verifyRecordDeletion(request: Request, database: Sequelize) { export async function verifyRecordDeletion(request: Request, database: Sequelize) {
const counts = async () => { const counts = async () => {
const result: number[] = []; const result: number[] = [];
for (const table of [ for (const table of [
"company_contacts", "company_contacts",
"assets", "assets",
@ -21,10 +22,13 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
`SELECT COUNT(*) AS count FROM ${table}`, `SELECT COUNT(*) AS count FROM ${table}`,
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
); );
result.push(Number(rows[0]?.count)); result.push(Number(rows[0]?.count));
} }
return result; return result;
}; };
const folderSubjects = await database.query<{ const folderSubjects = await database.query<{
profileId: string; profileId: string;
companyId: string; companyId: string;
@ -35,6 +39,7 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
); );
const subject = folderSubjects[0]; const subject = folderSubjects[0];
assert.ok(subject, "Debe existir un perfil y una necesidad de recurso para la prueba de carpeta"); assert.ok(subject, "Debe existir un perfil y una necesidad de recurso para la prueba de carpeta");
const mappingId = randomUUID(); const mappingId = randomUUID();
const values = JSON.stringify({ const values = JSON.stringify({
@ -45,6 +50,7 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
state: "pending", state: "pending",
notes: null, notes: null,
}); });
await database.query( await database.query(
"INSERT INTO folder_mappings (id,companyId,profileId,needId,`values`,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:values,:currentKey,NULL)", "INSERT INTO folder_mappings (id,companyId,profileId,needId,`values`,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:values,:currentKey,NULL)",
{ {
@ -98,11 +104,13 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
["asset_support", "asset_support_revisions", "connectionId", "asset-support"], ["asset_support", "asset_support_revisions", "connectionId", "asset-support"],
["asset_relationships", null, null, "assets"], ["asset_relationships", null, null, "assets"],
] as const; ] as const;
for (const [table, history, foreignKey, endpoint] of specs) { for (const [table, history, foreignKey, endpoint] of specs) {
const rows = await database.query<{ id: string; companyId: string; assetId?: string }>( const rows = await database.query<{ id: string; companyId: string; assetId?: string }>(
`SELECT * FROM ${table}`, `SELECT * FROM ${table}`,
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
); );
assert.ok(rows.length > 0, `No existen datos para probar ${table}`); assert.ok(rows.length > 0, `No existen datos para probar ${table}`);
// Todos los estados, incluidos los cerrados, admiten corregir un alta equivocada. // Todos los estados, incluidos los cerrados, admiten corregir un alta equivocada.
for (const row of rows) { for (const row of rows) {
@ -113,6 +121,7 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
? `relationships/${row.id}` ? `relationships/${row.id}`
: row.id; : row.id;
const path = `/${endpoint}/${row.companyId}/${suffix}/delete`; const path = `/${endpoint}/${row.companyId}/${suffix}/delete`;
assert.equal( assert.equal(
(await request(`/${endpoint}/${randomUUID()}/${suffix}/delete`, {})).status, (await request(`/${endpoint}/${randomUUID()}/${suffix}/delete`, {})).status,
404 404
@ -125,12 +134,14 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
replacements: { id: row.id }, replacements: { id: row.id },
type: QueryTypes.SELECT, type: QueryTypes.SELECT,
}); });
assert.equal(remaining.length, 0); assert.equal(remaining.length, 0);
if (history && foreignKey) { if (history && foreignKey) {
const revisions = await database.query( const revisions = await database.query(
`SELECT id FROM ${history} WHERE ${foreignKey}=:id`, `SELECT id FROM ${history} WHERE ${foreignKey}=:id`,
{ replacements: { id: row.id }, type: QueryTypes.SELECT } { replacements: { id: row.id }, type: QueryTypes.SELECT }
); );
assert.equal(revisions.length, 0); assert.equal(revisions.length, 0);
} }
} }

View File

@ -11,6 +11,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyRelationships( export async function verifyRelationships(
request: Request, request: Request,
companyId: string, companyId: string,
@ -24,14 +25,17 @@ export async function verifyRelationships(
const type = catalog.items.find( const type = catalog.items.find(
(item) => item.values.kind === "relation" && item.values.symmetric (item) => item.values.kind === "relation" && item.values.symmetric
); );
assert.ok(type); assert.ok(type);
const template = catalog.items.find( const template = catalog.items.find(
(item) => item.values.kind === "template" && item.values.name === "Monitor" (item) => item.values.kind === "template" && item.values.name === "Monitor"
); );
assert.ok(template); assert.ok(template);
const own = InventoryResponseSchema.parse(await (await request(path)).json()).assets; const own = InventoryResponseSchema.parse(await (await request(path)).json()).assets;
const first = own[0]; const first = own[0];
const second = own[1]; const second = own[1];
assert.ok(first && second); assert.ok(first && second);
const foreignResponse = await request(`/assets/${otherId}`, { const foreignResponse = await request(`/assets/${otherId}`, {
values: { values: {
@ -43,20 +47,27 @@ export async function verifyRelationships(
notes: "", notes: "",
}, },
}); });
assert.equal(foreignResponse.status, 201); assert.equal(foreignResponse.status, 201);
const foreign = AssetResponseSchema.parse(await foreignResponse.json()).asset; const foreign = AssetResponseSchema.parse(await foreignResponse.json()).asset;
const input = { sourceId: first.id, targetId: second.id, typeId: type.id, notes: "USB" }; const input = { sourceId: first.id, targetId: second.id, typeId: type.id, notes: "USB" };
const linkPath = `${path}/relationships`; const linkPath = `${path}/relationships`;
const save = async (values: unknown) => { const save = async (values: unknown) => {
const response = await request(linkPath, { values }); const response = await request(linkPath, { values });
assert.equal(response.status, 201, await response.clone().text()); assert.equal(response.status, 201, await response.clone().text());
return RelationshipResponseSchema.parse(await response.json()).relationship; return RelationshipResponseSchema.parse(await response.json()).relationship;
}; };
const error = async (values: unknown, code: string, status = 409) => { const error = async (values: unknown, code: string, status = 409) => {
const response = await request(linkPath, { values }); const response = await request(linkPath, { values });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, targetId: first.id }, "SELF_RELATIONSHIP"); await error({ ...input, targetId: first.id }, "SELF_RELATIONSHIP");
await error({ ...input, targetId: foreign.id }, "ASSET_NOT_FOUND", 404); await error({ ...input, targetId: foreign.id }, "ASSET_NOT_FOUND", 404);
await error({ ...input, typeId: template.id }, "RELATION_TYPE_UNAVAILABLE"); await error({ ...input, typeId: template.id }, "RELATION_TYPE_UNAVAILABLE");
@ -64,6 +75,7 @@ export async function verifyRelationships(
assert.equal((await request(linkPath, { values: input }, "https://other.example")).status, 403); assert.equal((await request(linkPath, { values: input }, "https://other.example")).status, 403);
assert.equal((await request(linkPath, { values: input }, undefined, "a".repeat(64))).status, 403); assert.equal((await request(linkPath, { values: input }, undefined, "a".repeat(64))).status, 403);
const [created, retry] = await Promise.all([save(input), save(input)]); const [created, retry] = await Promise.all([save(input), save(input)]);
assert.equal(created.id, retry.id); assert.equal(created.id, retry.id);
assert.equal(created.id.charAt(14), "7"); assert.equal(created.id.charAt(14), "7");
assert.ok(created.createdByName); assert.ok(created.createdByName);
@ -81,8 +93,10 @@ export async function verifyRelationships(
await error(input, "RELATION_TYPE_UNAVAILABLE"); await error(input, "RELATION_TYPE_UNAVAILABLE");
const endPath = `${path}/relationships/${created.id}/end`; const endPath = `${path}/relationships/${created.id}/end`;
const ending = await request(endPath, {}); const ending = await request(endPath, {});
assert.equal(ending.status, 200); assert.equal(ending.status, 200);
const ended = RelationshipResponseSchema.parse(await ending.json()).relationship; const ended = RelationshipResponseSchema.parse(await ending.json()).relationship;
assert.ok(ended.endedAt); assert.ok(ended.endedAt);
assert.ok(ended.endedByName); assert.ok(ended.endedByName);
assert.deepEqual( assert.deepEqual(
@ -98,9 +112,11 @@ export async function verifyRelationships(
200 200
); );
const replacement = await save(input); const replacement = await save(input);
assert.notEqual(replacement.id, created.id); assert.notEqual(replacement.id, created.id);
await verifyDirectional(request, companyId, otherId, first.id, second.id); await verifyDirectional(request, companyId, otherId, first.id, second.id);
const stored = InventoryResponseSchema.parse(await (await request(path)).json()).relationships; const stored = InventoryResponseSchema.parse(await (await request(path)).json()).relationships;
assert.deepEqual( assert.deepEqual(
stored.find((item) => item.id === ended.id), stored.find((item) => item.id === ended.id),
ended ended
@ -110,6 +126,7 @@ export async function verifyRelationships(
.length .length
); );
} }
async function verifyDirectional( async function verifyDirectional(
request: Request, request: Request,
companyId: string, companyId: string,
@ -125,30 +142,37 @@ async function verifyDirectional(
symmetric: false, symmetric: false,
}; };
const response = await request(`/asset-catalog/${companyId}`, { values: definition }); const response = await request(`/asset-catalog/${companyId}`, { values: definition });
assert.equal(response.status, 201); assert.equal(response.status, 201);
const typeId = SaveDefinitionResponseSchema.parse(await response.json()).savedId; const typeId = SaveDefinitionResponseSchema.parse(await response.json()).savedId;
const path = `/assets/${companyId}/relationships`; const path = `/assets/${companyId}/relationships`;
const values = { sourceId, targetId, typeId, notes: "" }; const values = { sourceId, targetId, typeId, notes: "" };
const added = await request(path, { values }); const added = await request(path, { values });
assert.equal(added.status, 201); assert.equal(added.status, 201);
const link = RelationshipResponseSchema.parse(await added.json()).relationship; const link = RelationshipResponseSchema.parse(await added.json()).relationship;
const reverse = await request(path, { const reverse = await request(path, {
values: { ...values, sourceId: targetId, targetId: sourceId }, values: { ...values, sourceId: targetId, targetId: sourceId },
}); });
assert.equal(reverse.status, 201); assert.equal(reverse.status, 201);
assert.notEqual(RelationshipResponseSchema.parse(await reverse.json()).relationship.id, link.id); assert.notEqual(RelationshipResponseSchema.parse(await reverse.json()).relationship.id, link.id);
const edited = await request(`/asset-catalog/${companyId}/${typeId}/update`, { const edited = await request(`/asset-catalog/${companyId}/${typeId}/update`, {
values: { ...definition, name: "Replica hacia" }, values: { ...definition, name: "Replica hacia" },
}); });
assert.equal(edited.status, 200); assert.equal(edited.status, 200);
const conversion = await request(`/asset-catalog/${companyId}/${typeId}/update`, { const conversion = await request(`/asset-catalog/${companyId}/${typeId}/update`, {
values: { ...definition, symmetric: true }, values: { ...definition, symmetric: true },
}); });
assert.equal(conversion.status, 409); assert.equal(conversion.status, 409);
const stored = InventoryResponseSchema.parse( const stored = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json() await (await request(`/assets/${companyId}`)).json()
).relationships.find((item) => item.id === link.id); ).relationships.find((item) => item.id === link.id);
assert.equal(stored?.values.name, "Replica a"); assert.equal(stored?.values.name, "Replica a");
const other = await request(`/assets/${otherId}/relationships`, { values }); const other = await request(`/assets/${otherId}/relationships`, { values });
assert.equal(other.status, 404); assert.equal(other.status, 404);
} }

View File

@ -14,6 +14,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyRemoteConnections( export async function verifyRemoteConnections(
request: Request, request: Request,
companyId: string, companyId: string,
@ -29,16 +30,20 @@ export async function verifyRemoteConnections(
isContact: false, isContact: false,
}; };
const personResponse = await request(`/companies/${companyId}/people`, personValues); const personResponse = await request(`/companies/${companyId}/people`, personValues);
assert.equal(personResponse.status, 201); assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()).assets; const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()).assets;
const host = assets.find((a) => a.values.status !== "retired"); const host = assets.find((a) => a.values.status !== "retired");
assert.ok(host); assert.ok(host);
const catalog = CatalogResponseSchema.parse( const catalog = CatalogResponseSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json() await (await request(`/resource-catalog/${companyId}`)).json()
); );
const template = catalog.items.find((item) => item.builtinKey === "template:application"); const template = catalog.items.find((item) => item.builtinKey === "template:application");
assert.ok(template); assert.ok(template);
const resourceValues = { const resourceValues = {
name: "ERP acceso remoto", name: "ERP acceso remoto",
@ -48,6 +53,7 @@ export async function verifyRemoteConnections(
characteristics: {}, characteristics: {},
}; };
const resourceResponse = await request(`/resources/${companyId}`, { values: resourceValues }); const resourceResponse = await request(`/resources/${companyId}`, { values: resourceValues });
assert.equal(resourceResponse.status, 201); assert.equal(resourceResponse.status, 201);
const resource = ResourceResponseSchema.parse(await resourceResponse.json()).resource; const resource = ResourceResponseSchema.parse(await resourceResponse.json()).resource;
const path = `/remote-connections/${companyId}`; const path = `/remote-connections/${companyId}`;
@ -63,21 +69,30 @@ export async function verifyRemoteConnections(
state: "configured", state: "configured",
notes: null, notes: null,
}; };
const list = async (scope = companyId) => { const list = async (scope = companyId) => {
const response = await request(`/remote-connections/${scope}`); const response = await request(`/remote-connections/${scope}`);
assert.equal(response.status, 200); assert.equal(response.status, 200);
return RemoteConnectionsResponseSchema.parse(await response.json()).connections; return RemoteConnectionsResponseSchema.parse(await response.json()).connections;
}; };
const save = async (values: RemoteConnectionInput, id?: string) => { const save = async (values: RemoteConnectionInput, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201); assert.equal(response.status, id ? 200 : 201);
return RemoteConnectionResponseSchema.parse(await response.json()).connection; return RemoteConnectionResponseSchema.parse(await response.json()).connection;
}; };
const error = async (values: unknown, code: string, id?: string, status = 409) => { const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
await error({ ...input, sourceAssetId: host.id }, "INVALID_REQUEST", undefined, 400); await error({ ...input, sourceAssetId: host.id }, "INVALID_REQUEST", undefined, 400);
await error({ ...input, targetKind: "asset" }, "INVALID_REQUEST", undefined, 400); await error({ ...input, targetKind: "asset" }, "INVALID_REQUEST", undefined, 400);
@ -106,10 +121,13 @@ export async function verifyRemoteConnections(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: { ...input, method: "vpn" } }), request(path, { values: { ...input, method: "vpn" } }),
]); ]);
assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 409]); assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 409]);
const createdResponse = concurrent.find((r) => r.status === 201); const createdResponse = concurrent.find((r) => r.status === 201);
assert.ok(createdResponse); assert.ok(createdResponse);
const created = RemoteConnectionResponseSchema.parse(await createdResponse.json()).connection; const created = RemoteConnectionResponseSchema.parse(await createdResponse.json()).connection;
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7"); assert.equal(created.history[0]?.id[14], "7");
assert.ok(created.needsReview && !created.authorizationEffective); assert.ok(created.needsReview && !created.authorizationEffective);
@ -151,8 +169,10 @@ export async function verifyRemoteConnections(
method: "VPN móvil", method: "VPN móvil",
}); });
const corporate = await save({ ...input, sourceKind: "company_asset", sourceAssetId: host.id }); const corporate = await save({ ...input, sourceKind: "company_asset", sourceAssetId: host.id });
assert.ok(rdp.values.requiresVpn && corporate.values.sourceAssetId === host.id); assert.ok(rdp.values.requiresVpn && corporate.values.sourceAssetId === host.id);
const alternative = await save({ ...input, method: "Herramienta propia" }); const alternative = await save({ ...input, method: "Herramienta propia" });
await error( await error(
{ ...alternative.values, method: created.values.method }, { ...alternative.values, method: created.values.method },
"REMOTE_ALREADY_OPEN", "REMOTE_ALREADY_OPEN",
@ -168,13 +188,18 @@ export async function verifyRemoteConnections(
const authResponse = await request(`/telework-authorizations/${companyId}`, { const authResponse = await request(`/telework-authorizations/${companyId}`, {
values: teleworkValues, values: teleworkValues,
}); });
assert.equal(authResponse.status, 201); assert.equal(authResponse.status, 201);
const auth = TeleworkAuthorizationResponseSchema.parse(await authResponse.json()).authorization; const auth = TeleworkAuthorizationResponseSchema.parse(await authResponse.json()).authorization;
const current = async (id: string) => { const current = async (id: string) => {
const item = (await list()).find((item) => item.id === id); const item = (await list()).find((item) => item.id === id);
assert.ok(item); assert.ok(item);
return item; return item;
}; };
assert.equal((await current(created.id)).needsReview, false); assert.equal((await current(created.id)).needsReview, false);
assert.equal((await current(mobile.id)).needsReview, false); assert.equal((await current(mobile.id)).needsReview, false);
assert.equal( assert.equal(
@ -245,6 +270,7 @@ export async function verifyRemoteConnections(
assert.equal((await current(created.id)).needsReview, true); assert.equal((await current(created.id)).needsReview, true);
const retiring = await save({ ...created.values, state: "removal_pending" }, created.id); const retiring = await save({ ...created.values, state: "removal_pending" }, created.id);
const removed = await save({ ...retiring.values, state: "removed" }, created.id); const removed = await save({ ...retiring.values, state: "removed" }, created.id);
assert.equal(removed.needsReview, false); assert.equal(removed.needsReview, false);
assert.equal(removed.history.length, 3); assert.equal(removed.history.length, 3);
assert.ok(removed.history.every((r) => r.actorId !== person.id)); assert.ok(removed.history.every((r) => r.actorId !== person.id));
@ -255,12 +281,14 @@ export async function verifyRemoteConnections(
removed.id removed.id
); );
const replacement = await save(created.values); const replacement = await save(created.values);
assert.notEqual(replacement.id, created.id); assert.notEqual(replacement.id, created.id);
await database.query("UPDATE remote_connections SET currentKey=:key WHERE id=:id", { await database.query("UPDATE remote_connections SET currentKey=:key WHERE id=:id", {
replacements: { id: replacement.id, key: "invalid" }, replacements: { id: replacement.id, key: "invalid" },
}); });
try { try {
const corrupt = await request(path); const corrupt = await request(path);
assert.equal(corrupt.status, 500); assert.equal(corrupt.status, 500);
assert.ok(!(await corrupt.text()).includes("SELECT")); assert.ok(!(await corrupt.text()).includes("SELECT"));
} finally { } finally {

View File

@ -9,6 +9,7 @@ import { QueryTypes } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown) => Promise<Response>; type Request = (path: string, body?: unknown) => Promise<Response>;
export async function verifyResourceMigration( export async function verifyResourceMigration(
request: Request, request: Request,
companyId: string, companyId: string,
@ -26,10 +27,13 @@ export async function verifyResourceMigration(
await (await request(`/access/${companyId}`)).json() await (await request(`/access/${companyId}`)).json()
); );
const result = await request(`/resources/${companyId}`); const result = await request(`/resources/${companyId}`);
assert.equal(result.status, 200); assert.equal(result.status, 200);
const inventory = InventoryResponseSchema.parse(await result.json()); const inventory = InventoryResponseSchema.parse(await result.json());
for (const source of sources) { for (const source of sources) {
const resource = inventory.resources.find((item) => item.id === source.id); const resource = inventory.resources.find((item) => item.id === source.id);
assert.ok(resource); assert.ok(resource);
assert.equal(resource.values.integration?.kind, source.kind); assert.equal(resource.values.integration?.kind, source.kind);
verifySourceValues(resource, originalMail, originalAccess); verifySourceValues(resource, originalMail, originalAccess);
@ -38,34 +42,41 @@ export async function verifyResourceMigration(
`SELECT id,actorId,recordedAt FROM ${table} WHERE itemId=:id`, `SELECT id,actorId,recordedAt FROM ${table} WHERE itemId=:id`,
{ replacements: { id: source.id }, type: QueryTypes.SELECT } { replacements: { id: source.id }, type: QueryTypes.SELECT }
); );
assert.equal(resource.history.length, revisions.length); assert.equal(resource.history.length, revisions.length);
for (const old of revisions) { for (const old of revisions) {
const current: ResourceDTO["history"][number] | undefined = resource.history.find( const current: ResourceDTO["history"][number] | undefined = resource.history.find(
(item) => item.id === old.id (item) => item.id === old.id
); );
assert.ok(current); assert.ok(current);
assert.equal(current.actorId, old.actorId); assert.equal(current.actorId, old.actorId);
assert.equal(current.recordedAt, old.recordedAt); assert.equal(current.recordedAt, old.recordedAt);
} }
} }
const module = await createResourceModule(database, (_request, _response, next) => next()); const module = await createResourceModule(database, (_request, _response, next) => next());
await module.migrateLegacy(); await module.migrateLegacy();
const again = InventoryResponseSchema.parse( const again = InventoryResponseSchema.parse(
await (await request(`/resources/${companyId}`)).json() await (await request(`/resources/${companyId}`)).json()
); );
assert.deepEqual( assert.deepEqual(
again.resources.map((item) => [item.id, item.history.map((row) => row.id)]).sort(), again.resources.map((item) => [item.id, item.history.map((row) => row.id)]).sort(),
inventory.resources.map((item) => [item.id, item.history.map((row) => row.id)]).sort() inventory.resources.map((item) => [item.id, item.history.map((row) => row.id)]).sort()
); );
const mailbox = inventory.resources.find((item) => item.values.integration?.kind === "mailbox"); const mailbox = inventory.resources.find((item) => item.values.integration?.kind === "mailbox");
assert.ok(mailbox && mailbox.values.integration?.kind === "mailbox"); assert.ok(mailbox && mailbox.values.integration?.kind === "mailbox");
const integration = mailbox.values.integration; const integration = mailbox.values.integration;
const values = { ...mailbox.values, active: false, notes: "Revisión desde Recursos" }; const values = { ...mailbox.values, active: false, notes: "Revisión desde Recursos" };
const system = inventory.references?.find((item) => item.kind === "system"); const system = inventory.references?.find((item) => item.kind === "system");
assert.ok(system); assert.ok(system);
const wrongReference = await request(`/resources/${companyId}/${mailbox.id}/update`, { const wrongReference = await request(`/resources/${companyId}/${mailbox.id}/update`, {
values: { ...values, integration: { ...integration, accountId: system.id } }, values: { ...values, integration: { ...integration, accountId: system.id } },
}); });
assert.equal(wrongReference.status, 409); assert.equal(wrongReference.status, 409);
assert.equal( assert.equal(
z.object({ code: z.string() }).parse(await wrongReference.json()).code, z.object({ code: z.string() }).parse(await wrongReference.json()).code,
@ -80,6 +91,7 @@ export async function verifyResourceMigration(
}, },
}, },
}); });
assert.equal(invalidEmail.status, 409); assert.equal(invalidEmail.status, 409);
assert.equal( assert.equal(
z.object({ code: z.string() }).parse(await invalidEmail.json()).code, z.object({ code: z.string() }).parse(await invalidEmail.json()).code,
@ -97,16 +109,20 @@ export async function verifyResourceMigration(
}, },
}, },
}); });
assert.equal(duplicate.status, 409); assert.equal(duplicate.status, 409);
assert.equal(z.object({ code: z.string() }).parse(await duplicate.json()).code, "NAME_EXISTS"); assert.equal(z.object({ code: z.string() }).parse(await duplicate.json()).code, "NAME_EXISTS");
const changed = await request(`/resources/${companyId}/${mailbox.id}/update`, { values }); const changed = await request(`/resources/${companyId}/${mailbox.id}/update`, { values });
assert.equal(changed.status, 200); assert.equal(changed.status, 200);
const updated = ResourceResponseSchema.parse(await changed.json()).resource; const updated = ResourceResponseSchema.parse(await changed.json()).resource;
assert.equal(updated.history.length, mailbox.history.length + 1); assert.equal(updated.history.length, mailbox.history.length + 1);
assert.equal(updated.id, mailbox.id); assert.equal(updated.id, mailbox.id);
const boxes = MailboxesResponseSchema.parse( const boxes = MailboxesResponseSchema.parse(
await (await request(`/mailboxes/${companyId}`)).json() await (await request(`/mailboxes/${companyId}`)).json()
); );
assert.equal(boxes.mailboxes.find((item) => item.id === mailbox.id)?.values.active, false); assert.equal(boxes.mailboxes.find((item) => item.id === mailbox.id)?.values.active, false);
assert.equal( assert.equal(
boxes.mailboxes.find((item) => item.id === mailbox.id)?.values.accountId, boxes.mailboxes.find((item) => item.id === mailbox.id)?.values.accountId,
@ -132,6 +148,7 @@ export async function verifyResourceMigration(
409 409
); );
const access = inventory.resources.find((item) => item.values.integration?.kind === "access"); const access = inventory.resources.find((item) => item.values.integration?.kind === "access");
assert.ok(access && access.values.integration?.kind === "access"); assert.ok(access && access.values.integration?.kind === "access");
const referenceField = access.values.integration.referenceFieldId; const referenceField = access.values.integration.referenceFieldId;
const nextValues = { const nextValues = {
@ -141,14 +158,17 @@ export async function verifyResourceMigration(
const saved = await request(`/resources/${companyId}/${access.id}/update`, { const saved = await request(`/resources/${companyId}/${access.id}/update`, {
values: nextValues, values: nextValues,
}); });
assert.equal(saved.status, 200); assert.equal(saved.status, 200);
const snapshot = AccessResponseSchema.parse(await (await request(`/access/${companyId}`)).json()); const snapshot = AccessResponseSchema.parse(await (await request(`/access/${companyId}`)).json());
const projected = snapshot.items.find((item) => item.id === access.id); const projected = snapshot.items.find((item) => item.id === access.id);
assert.ok(projected && projected.values.kind === "resource"); assert.ok(projected && projected.values.kind === "resource");
assert.equal(projected.values.reference, nextValues.characteristics[referenceField]); assert.equal(projected.values.reference, nextValues.characteristics[referenceField]);
const oldWrite = await request(`/access/${companyId}/${access.id}/update`, { const oldWrite = await request(`/access/${companyId}/${access.id}/update`, {
values: projected.values, values: projected.values,
}); });
assert.equal(oldWrite.status, 409); assert.equal(oldWrite.status, 409);
assert.equal(z.object({ code: z.string() }).parse(await oldWrite.json()).code, "RESOURCE_MOVED"); assert.equal(z.object({ code: z.string() }).parse(await oldWrite.json()).code, "RESOURCE_MOVED");
} }
@ -160,8 +180,10 @@ function verifySourceValues(
) { ) {
const source = { id: resource.id }; const source = { id: resource.id };
const integration = resource.values.integration; const integration = resource.values.integration;
if (integration?.kind === "mailbox") { if (integration?.kind === "mailbox") {
const sourceValues = originalMail.mailboxes.find((item) => item.id === source.id)?.values; const sourceValues = originalMail.mailboxes.find((item) => item.id === source.id)?.values;
assert.ok(sourceValues); assert.ok(sourceValues);
assert.equal(resource.values.characteristics[integration.addressFieldId], sourceValues.address); assert.equal(resource.values.characteristics[integration.addressFieldId], sourceValues.address);
assert.equal( assert.equal(
@ -174,6 +196,7 @@ function verifySourceValues(
assert.equal(resource.values.notes, sourceValues.notes ?? ""); assert.equal(resource.values.notes, sourceValues.notes ?? "");
} else if (integration?.kind === "access") { } else if (integration?.kind === "access") {
const sourceValues = originalAccess.items.find((item) => item.id === source.id)?.values; const sourceValues = originalAccess.items.find((item) => item.id === source.id)?.values;
assert.ok(sourceValues?.kind === "resource"); assert.ok(sourceValues?.kind === "resource");
assert.equal(resource.values.name, sourceValues.name); assert.equal(resource.values.name, sourceValues.name);
assert.equal( assert.equal(

View File

@ -24,27 +24,40 @@ export async function verifyResources(
const company = `/resource-catalog/${companyId}`; const company = `/resource-catalog/${companyId}`;
const other = `/resource-catalog/${otherId}`; const other = `/resource-catalog/${otherId}`;
const inventory = `/resources/${companyId}`; const inventory = `/resources/${companyId}`;
async function read(path: string) { async function read(path: string) {
const response = await request(path); const response = await request(path);
assert.equal(response.status, 200); assert.equal(response.status, 200);
return CatalogResponseSchema.parse(await response.json()); return CatalogResponseSchema.parse(await response.json());
} }
async function save(path: string, values: unknown) { async function save(path: string, values: unknown) {
const response = await request(path, { values }); const response = await request(path, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text()); assert.ok([200, 201].includes(response.status), await response.clone().text());
return SaveDefinitionResponseSchema.parse(await response.json()); return SaveDefinitionResponseSchema.parse(await response.json());
} }
async function error(path: string, body: unknown, code: string, status = 409) { async function error(path: string, body: unknown, code: string, status = 409) {
const response = await request(path, body); const response = await request(path, body);
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
} }
async function resource(path: string, values: unknown) { async function resource(path: string, values: unknown) {
const response = await request(path, { values }); const response = await request(path, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text()); assert.ok([200, 201].includes(response.status), await response.clone().text());
return ResourceResponseSchema.parse(await response.json()).resource; return ResourceResponseSchema.parse(await response.json()).resource;
} }
const initial = await read(global); const initial = await read(global);
assert.equal(initial.items.length, 26); assert.equal(initial.items.length, 26);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 6); assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 6);
assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7")); assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7"));
@ -56,12 +69,17 @@ export async function verifyResources(
const licenseTemplate = initial.items.find( const licenseTemplate = initial.items.find(
(item) => item.builtinKey === "template:software_license" (item) => item.builtinKey === "template:software_license"
); );
assert.ok(licenseTemplate && licenseTemplate.values.kind === "template"); assert.ok(licenseTemplate && licenseTemplate.values.kind === "template");
const licenseFieldId = (key: string) => { const licenseFieldId = (key: string) => {
const field = initial.items.find((item) => item.builtinKey === `field:${key}`); const field = initial.items.find((item) => item.builtinKey === `field:${key}`);
assert.ok(field && field.values.kind === "field"); assert.ok(field && field.values.kind === "field");
return field.id; return field.id;
}; };
const licenseInput = { const licenseInput = {
name: "Microsoft 365 Business Standard", name: "Microsoft 365 Business Standard",
templateId: licenseTemplate.id, templateId: licenseTemplate.id,
@ -79,6 +97,7 @@ export async function verifyResources(
}, },
}; };
const license = await resource(inventory, licenseInput); const license = await resource(inventory, licenseInput);
assert.equal(license.companyId, companyId); assert.equal(license.companyId, companyId);
assert.equal(license.specification.templateName, "Licencia de software"); assert.equal(license.specification.templateName, "Licencia de software");
assert.equal(license.values.characteristics[licenseFieldId("auto_renew")], false); assert.equal(license.values.characteristics[licenseFieldId("auto_renew")], false);
@ -87,6 +106,7 @@ export async function verifyResources(
...licenseInput, ...licenseInput,
characteristics: { ...licenseInput.characteristics, [licenseFieldId("license_quantity")]: 30 }, characteristics: { ...licenseInput.characteristics, [licenseFieldId("license_quantity")]: 30 },
}); });
assert.equal(licenseUpdated.history.length, 2); assert.equal(licenseUpdated.history.length, 2);
assert.equal( assert.equal(
licenseUpdated.history[0]?.values.characteristics[licenseFieldId("license_quantity")], licenseUpdated.history[0]?.values.characteristics[licenseFieldId("license_quantity")],
@ -119,6 +139,7 @@ export async function verifyResources(
name: "ESET empresa", name: "ESET empresa",
characteristics: {}, characteristics: {},
}); });
assert.deepEqual(minimal.values.characteristics, {}); assert.deepEqual(minimal.values.characteristics, {});
await error( await error(
`/resources/${otherId}/${license.id}/update`, `/resources/${otherId}/${license.id}/update`,
@ -145,9 +166,11 @@ export async function verifyResources(
); );
const assetsResponse = await request("/asset-catalog/global"); const assetsResponse = await request("/asset-catalog/global");
const assets = AssetCatalogResponseSchema.parse(await assetsResponse.json()); const assets = AssetCatalogResponseSchema.parse(await assetsResponse.json());
assert.ok(initial.items.every((item) => !assets.items.some((asset) => asset.id === item.id))); assert.ok(initial.items.every((item) => !assets.items.some((asset) => asset.id === item.id)));
const stock = initial.items.find((item) => item.values.name === "Dirección de correo"); const stock = initial.items.find((item) => item.values.name === "Dirección de correo");
const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo"); const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo");
assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template"); assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template");
await error( await error(
`${global}/${stock.id}/update`, `${global}/${stock.id}/update`,
@ -171,6 +194,7 @@ export async function verifyResources(
); );
await createResourceModule(database, (_request, _response, next) => next()); await createResourceModule(database, (_request, _response, next) => next());
const restarted = await read(global); const restarted = await read(global);
assert.deepEqual( assert.deepEqual(
restarted.items.map((item) => item.id).sort(), restarted.items.map((item) => item.id).sort(),
initial.items.map((item) => item.id).sort() initial.items.map((item) => item.id).sort()
@ -190,9 +214,11 @@ export async function verifyResources(
}; };
const local = await save(company, definition); const local = await save(company, definition);
const fieldId = local.savedId; const fieldId = local.savedId;
assert.ok(!(await read(other)).items.some((item) => item.id === fieldId)); assert.ok(!(await read(other)).items.some((item) => item.id === fieldId));
const assetField = assets.items.find((item) => item.values.kind === "field"); const assetField = assets.items.find((item) => item.values.kind === "field");
const assetTemplate = assets.items.find((item) => item.values.kind === "template"); const assetTemplate = assets.items.find((item) => item.values.kind === "template");
assert.ok(assetField && assetTemplate); assert.ok(assetField && assetTemplate);
const section = { const section = {
id: UniqueID.generateNewID().toPrimitive(), id: UniqueID.generateNewID().toPrimitive(),
@ -206,6 +232,7 @@ export async function verifyResources(
description: "", description: "",
fields: [{ fieldId, required: true, sectionId: section.id }], fields: [{ fieldId, required: true, sectionId: section.id }],
}; };
await error( await error(
company, company,
{ values: { ...template, fields: [{ fieldId: assetField.id, required: false }] } }, { values: { ...template, fields: [{ fieldId: assetField.id, required: false }] } },
@ -219,6 +246,7 @@ export async function verifyResources(
await error(other, { values: template }, "FIELD_UNAVAILABLE"); await error(other, { values: template }, "FIELD_UNAVAILABLE");
await error(global, { values: template }, "FIELD_UNAVAILABLE"); await error(global, { values: template }, "FIELD_UNAVAILABLE");
const createdTemplate = await save(company, template); const createdTemplate = await save(company, template);
await error( await error(
`${other}/${createdTemplate.savedId}/update`, `${other}/${createdTemplate.savedId}/update`,
{ values: template }, { values: template },
@ -232,6 +260,7 @@ export async function verifyResources(
notes: "", notes: "",
characteristics: { [fieldId]: "Zoom" }, characteristics: { [fieldId]: "Zoom" },
}; };
await error( await error(
inventory, inventory,
{ values: { ...first, templateId: assetTemplate.id } }, { values: { ...first, templateId: assetTemplate.id } },
@ -251,11 +280,13 @@ export async function verifyResources(
403 403
); );
const created = await resource(inventory, first); const created = await resource(inventory, first);
assert.deepEqual(created.specification.sections, [section]); assert.deepEqual(created.specification.sections, [section]);
assert.equal(created.specification.fields[0]?.sectionId, section.id); assert.equal(created.specification.fields[0]?.sectionId, section.id);
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7"); assert.equal(created.history[0]?.id[14], "7");
const repeated = await resource(`${inventory}/${created.id}/update`, first); const repeated = await resource(`${inventory}/${created.id}/update`, first);
assert.equal(repeated.history.length, 1); assert.equal(repeated.history.length, 1);
assert.equal(repeated.id, created.id); assert.equal(repeated.id, created.id);
await error( await error(
@ -269,6 +300,7 @@ export async function verifyResources(
active: false, active: false,
notes: "Fin de uso", notes: "Fin de uso",
}); });
assert.equal(altered.id, created.id); assert.equal(altered.id, created.id);
assert.equal(altered.history.length, 2); assert.equal(altered.history.length, 2);
assert.equal(altered.history[0]?.values.active, true); assert.equal(altered.history[0]?.values.active, true);
@ -290,6 +322,7 @@ export async function verifyResources(
const stored = InventoryResponseSchema.parse(await listResponse.json()).resources.find( const stored = InventoryResponseSchema.parse(await listResponse.json()).resources.find(
(item) => item.id === created.id (item) => item.id === created.id
); );
assert.ok(stored); assert.ok(stored);
assert.deepEqual(stored.specification.sections, [section]); assert.deepEqual(stored.specification.sections, [section]);
assert.equal(stored.specification.fields[0]?.sectionId, section.id); assert.equal(stored.specification.fields[0]?.sectionId, section.id);
@ -306,16 +339,19 @@ export async function verifyResources(
{ replacements: { id: created.id }, type: QueryTypes.SELECT } { replacements: { id: created.id }, type: QueryTypes.SELECT }
); );
const latest = audit[0]; const latest = audit[0];
assert.ok(latest); assert.ok(latest);
try { try {
await database.query("UPDATE resource_revisions SET position=999 WHERE id=:id", { await database.query("UPDATE resource_revisions SET position=999 WHERE id=:id", {
replacements: { id: latest.id }, replacements: { id: latest.id },
}); });
const inconsistent = await request(inventory); const inconsistent = await request(inventory);
assert.equal(inconsistent.status, 500); assert.equal(inconsistent.status, 500);
const failure = z const failure = z
.strictObject({ code: z.string(), requestId: z.string() }) .strictObject({ code: z.string(), requestId: z.string() })
.parse(await inconsistent.json()); .parse(await inconsistent.json());
assert.equal(failure.code, "INTERNAL_ERROR"); assert.equal(failure.code, "INTERNAL_ERROR");
} finally { } finally {
await database.query("UPDATE resource_revisions SET position=:position WHERE id=:id", { await database.query("UPDATE resource_revisions SET position=:position WHERE id=:id", {
@ -352,6 +388,7 @@ export async function verifyResources(
request(inventory, { values: concurrentInput }), request(inventory, { values: concurrentInput }),
request(inventory, { values: concurrentInput }), request(inventory, { values: concurrentInput }),
]); ]);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
// Los buzones anteriores y los recursos básicos continúan en sus superficies originales. // Los buzones anteriores y los recursos básicos continúan en sus superficies originales.
assert.equal((await request(`/mailboxes/${companyId}`)).status, 200); assert.equal((await request(`/mailboxes/${companyId}`)).status, 200);
@ -375,6 +412,7 @@ export async function verifyResources(
const groupedStored = (await read(global)).items.find( const groupedStored = (await read(global)).items.find(
(item) => item.id === groupedGlobal.savedId (item) => item.id === groupedGlobal.savedId
); );
assert.ok(groupedStored?.values.kind === "template"); assert.ok(groupedStored?.values.kind === "template");
assert.deepEqual(groupedStored.values.sections, [globalSection]); assert.deepEqual(groupedStored.values.sections, [globalSection]);
await error( await error(

View File

@ -11,15 +11,18 @@ import {
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifySupport(request: Request, companyId: string, otherId: string) { export async function verifySupport(request: Request, companyId: string, otherId: string) {
const host = InventoryResponseSchema.parse( const host = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json() await (await request(`/assets/${companyId}`)).json()
).assets.find((item) => item.values.status !== "retired"); ).assets.find((item) => item.values.status !== "retired");
assert.ok(host); assert.ok(host);
const tools = SupportToolsSchema.parse( const tools = SupportToolsSchema.parse(
await (await request("/asset-support/tools")).json() await (await request("/asset-support/tools")).json()
).tools; ).tools;
const tool = tools.find((item) => item.name === "AnyDesk"); const tool = tools.find((item) => item.name === "AnyDesk");
assert.ok(tool); assert.ok(tool);
for (const name of ["AnyDesk", "Supremo Control", "Escritorio remoto", "TeamViewer"]) { for (const name of ["AnyDesk", "Supremo Control", "Escritorio remoto", "TeamViewer"]) {
assert.ok(tools.some((item) => item.name === name)); assert.ok(tools.some((item) => item.name === name));
@ -33,14 +36,19 @@ export async function verifySupport(request: Request, companyId: string, otherId
state: "available" as const, state: "available" as const,
notes: "Test", notes: "Test",
}; };
async function error(url: string, body: unknown, status: number, code: string) { async function error(url: string, body: unknown, status: number, code: string) {
const response = await request(url, body); const response = await request(url, body);
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
} }
const saved = await request(path, { values }); const saved = await request(path, { values });
assert.equal(saved.status, 201, await saved.clone().text()); assert.equal(saved.status, 201, await saved.clone().text());
const first = SupportResponseSchema.parse(await saved.json()).connection; const first = SupportResponseSchema.parse(await saved.json()).connection;
assert.equal(first.id[14], "7"); assert.equal(first.id[14], "7");
assert.equal(first.history.length, 1); assert.equal(first.history.length, 1);
await error(`/asset-support/${otherId}/${host.id}`, { values }, 404, "ASSET_NOT_FOUND"); await error(`/asset-support/${otherId}/${host.id}`, { values }, 404, "ASSET_NOT_FOUND");
@ -57,23 +65,29 @@ export async function verifySupport(request: Request, companyId: string, otherId
preferred: false, preferred: false,
}; };
const second = await request(path, { values: secondValues }); const second = await request(path, { values: secondValues });
assert.equal(second.status, 201); assert.equal(second.status, 201);
const secondItem = SupportResponseSchema.parse(await second.json()).connection; const secondItem = SupportResponseSchema.parse(await second.json()).connection;
await error(path, { values: secondValues }, 409, "CONNECTION_EXISTS"); await error(path, { values: secondValues }, 409, "CONNECTION_EXISTS");
await error(`${path}/${randomUUID()}/update`, { values }, 404, "CONNECTION_NOT_FOUND"); await error(`${path}/${randomUUID()}/update`, { values }, 404, "CONNECTION_NOT_FOUND");
const same = await request(`${path}/${first.id}/update`, { values }); const same = await request(`${path}/${first.id}/update`, { values });
assert.equal(same.status, 200); assert.equal(same.status, 200);
assert.equal(SupportResponseSchema.parse(await same.json()).connection.history.length, 1); assert.equal(SupportResponseSchema.parse(await same.json()).connection.history.length, 1);
const update = await request(`${path}/${first.id}/update`, { const update = await request(`${path}/${first.id}/update`, {
values: { ...values, requiresVpn: true }, values: { ...values, requiresVpn: true },
}); });
assert.equal(update.status, 200); assert.equal(update.status, 200);
const changed = SupportResponseSchema.parse(await update.json()).connection; const changed = SupportResponseSchema.parse(await update.json()).connection;
assert.equal(changed.history.length, 2); assert.equal(changed.history.length, 2);
assert.equal(changed.history[0]?.values.requiresVpn, false); assert.equal(changed.history[0]?.values.requiresVpn, false);
const switched = await request(`${path}/${first.id}/update`, { const switched = await request(`${path}/${first.id}/update`, {
values: { ...changed.values, preferred: false }, values: { ...changed.values, preferred: false },
}); });
assert.equal(switched.status, 200); assert.equal(switched.status, 200);
assert.equal( assert.equal(
( (
@ -89,8 +103,10 @@ export async function verifySupport(request: Request, companyId: string, otherId
const custom = await request("/asset-support/tools", { const custom = await request("/asset-support/tools", {
values: { name: "RustDesk test", active: true }, values: { name: "RustDesk test", active: true },
}); });
assert.equal(custom.status, 201); assert.equal(custom.status, 201);
const customTool = SupportToolResponseSchema.parse(await custom.json()).tool; const customTool = SupportToolResponseSchema.parse(await custom.json()).tool;
await error( await error(
"/asset-support/tools", "/asset-support/tools",
{ values: { name: "rustdesk TEST", active: true } }, { values: { name: "rustdesk TEST", active: true } },
@ -118,11 +134,13 @@ export async function verifySupport(request: Request, companyId: string, otherId
"TOOL_UNAVAILABLE" "TOOL_UNAVAILABLE"
); );
const listed = SupportListSchema.parse(await (await request(path)).json()); const listed = SupportListSchema.parse(await (await request(path)).json());
assert.equal(listed.connections.length, 2); assert.equal(listed.connections.length, 2);
assert.equal(listed.connections.filter((item) => item.values.preferred).length, 1); assert.equal(listed.connections.filter((item) => item.values.preferred).length, 1);
const retired = await request(`/assets/${companyId}/${host.id}/update`, { const retired = await request(`/assets/${companyId}/${host.id}/update`, {
values: { ...host.values, status: "retired" }, values: { ...host.values, status: "retired" },
}); });
assert.equal(retired.status, 200); assert.equal(retired.status, 200);
await error( await error(
`${path}/${first.id}/update`, `${path}/${first.id}/update`,

View File

@ -11,6 +11,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyTelework( export async function verifyTelework(
request: Request, request: Request,
companyId: string, companyId: string,
@ -26,8 +27,10 @@ export async function verifyTelework(
isContact: false, isContact: false,
}; };
const createdPerson = await request(`/companies/${companyId}/people`, personValues); const createdPerson = await request(`/companies/${companyId}/people`, personValues);
assert.equal(createdPerson.status, 201); assert.equal(createdPerson.status, 201);
const person = CompanyResponseSchema.parse(await createdPerson.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await createdPerson.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const path = `/telework-authorizations/${companyId}`; const path = `/telework-authorizations/${companyId}`;
const input: TeleworkAuthorizationInput = { const input: TeleworkAuthorizationInput = {
@ -37,20 +40,27 @@ export async function verifyTelework(
expiresOn: null, expiresOn: null,
notes: null, notes: null,
}; };
const error = async (values: unknown, code: string, id?: string, status = 409) => { const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
const save = async (values: TeleworkAuthorizationInput, id: string) => { const save = async (values: TeleworkAuthorizationInput, id: string) => {
const response = await request(`${path}/${id}/update`, { values }); const response = await request(`${path}/${id}/update`, { values });
assert.equal(response.status, 200); assert.equal(response.status, 200);
return TeleworkAuthorizationResponseSchema.parse(await response.json()).authorization; return TeleworkAuthorizationResponseSchema.parse(await response.json()).authorization;
}; };
const list = async (scope = companyId) => const list = async (scope = companyId) =>
TeleworkAuthorizationsResponseSchema.parse( TeleworkAuthorizationsResponseSchema.parse(
await (await request(`/telework-authorizations/${scope}`)).json() await (await request(`/telework-authorizations/${scope}`)).json()
).authorizations; ).authorizations;
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
await error( await error(
{ ...input, startsOn: "2026-12-01", expiresOn: "2026-11-30" }, { ...input, startsOn: "2026-12-01", expiresOn: "2026-11-30" },
@ -65,12 +75,15 @@ export async function verifyTelework(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: input }), request(path, { values: input }),
]); ]);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
const created = concurrent.find((response) => response.status === 201); const created = concurrent.find((response) => response.status === 201);
assert.ok(created); assert.ok(created);
const authorization = TeleworkAuthorizationResponseSchema.parse( const authorization = TeleworkAuthorizationResponseSchema.parse(
await created.json() await created.json()
).authorization; ).authorization;
assert.equal(authorization.id[14], "7"); assert.equal(authorization.id[14], "7");
assert.equal(authorization.history[0]?.id[14], "7"); assert.equal(authorization.history[0]?.id[14], "7");
assert.equal(authorization.effective, true); assert.equal(authorization.effective, true);
@ -79,30 +92,36 @@ export async function verifyTelework(
assert.deepEqual(await save(input, authorization.id), authorization); assert.deepEqual(await save(input, authorization.id), authorization);
await error({ ...input, personId: randomUUID() }, "TELEWORK_PERSON_IMMUTABLE", authorization.id); await error({ ...input, personId: randomUUID() }, "TELEWORK_PERSON_IMMUTABLE", authorization.id);
const otherCreate = await request(`/telework-authorizations/${otherId}`, { values: input }); const otherCreate = await request(`/telework-authorizations/${otherId}`, { values: input });
assert.equal(otherCreate.status, 404); assert.equal(otherCreate.status, 404);
assert.equal((await list(otherId)).length, 0); assert.equal((await list(otherId)).length, 0);
const foreignUpdate = await request( const foreignUpdate = await request(
`/telework-authorizations/${otherId}/${authorization.id}/update`, `/telework-authorizations/${otherId}/${authorization.id}/update`,
{ values: input } { values: input }
); );
assert.equal(foreignUpdate.status, 404); assert.equal(foreignUpdate.status, 404);
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const oneDay = await save({ ...input, startsOn: today, expiresOn: today }, authorization.id); const oneDay = await save({ ...input, startsOn: today, expiresOn: today }, authorization.id);
assert.equal(oneDay.status, "authorized"); assert.equal(oneDay.status, "authorized");
assert.equal(oneDay.effective, true); assert.equal(oneDay.effective, true);
const scheduled = await save( const scheduled = await save(
{ ...input, startsOn: "9999-12-31", expiresOn: "9999-12-31" }, { ...input, startsOn: "9999-12-31", expiresOn: "9999-12-31" },
authorization.id authorization.id
); );
assert.equal(scheduled.status, "scheduled"); assert.equal(scheduled.status, "scheduled");
assert.equal(scheduled.effective, false); assert.equal(scheduled.effective, false);
const expired = await save( const expired = await save(
{ ...input, startsOn: "2000-01-01", expiresOn: "2000-01-01" }, { ...input, startsOn: "2000-01-01", expiresOn: "2000-01-01" },
authorization.id authorization.id
); );
assert.equal(expired.status, "expired"); assert.equal(expired.status, "expired");
assert.equal(expired.effective, false); assert.equal(expired.effective, false);
const renewed = await save(input, authorization.id); const renewed = await save(input, authorization.id);
assert.equal(renewed.status, "authorized"); assert.equal(renewed.status, "authorized");
assert.equal( assert.equal(
( (
@ -114,11 +133,13 @@ export async function verifyTelework(
200 200
); );
const reviewed = (await list()).find((item) => item.id === authorization.id); const reviewed = (await list()).find((item) => item.id === authorization.id);
assert.ok(reviewed); assert.ok(reviewed);
assert.equal(reviewed.needsReview, true); assert.equal(reviewed.needsReview, true);
assert.equal(reviewed.effective, false); assert.equal(reviewed.effective, false);
await error({ ...input, notes: "Extender" }, "TELEWORK_PERSON_INACTIVE", authorization.id); await error({ ...input, notes: "Extender" }, "TELEWORK_PERSON_INACTIVE", authorization.id);
const revoked = await save({ ...input, active: false, notes: "Salida" }, authorization.id); const revoked = await save({ ...input, active: false, notes: "Salida" }, authorization.id);
assert.equal(revoked.status, "revoked"); assert.equal(revoked.status, "revoked");
assert.equal(revoked.needsReview, false); assert.equal(revoked.needsReview, false);
assert.equal(revoked.history.length, 6); assert.equal(revoked.history.length, 6);
@ -130,6 +151,7 @@ export async function verifyTelework(
); );
await error(input, "TELEWORK_ALREADY_EXISTS"); await error(input, "TELEWORK_ALREADY_EXISTS");
const reactivated = await save({ ...input, notes: "Regresa" }, authorization.id); const reactivated = await save({ ...input, notes: "Regresa" }, authorization.id);
assert.equal(reactivated.effective, true); assert.equal(reactivated.effective, true);
assert.equal(reactivated.history.length, 7); assert.equal(reactivated.history.length, 7);
assert.ok(reactivated.history.every((revision) => revision.actorId !== person.id)); assert.ok(reactivated.history.every((revision) => revision.actorId !== person.id));
@ -141,8 +163,10 @@ export async function verifyTelework(
}); });
try { try {
const corrupt = await request(path); const corrupt = await request(path);
assert.equal(corrupt.status, 500); assert.equal(corrupt.status, 500);
const body = await corrupt.text(); const body = await corrupt.text();
assert.ok(!body.includes("SELECT")); assert.ok(!body.includes("SELECT"));
assert.ok(!body.includes("inconsistente")); assert.ok(!body.includes("inconsistente"));
} finally { } finally {

View File

@ -12,16 +12,19 @@ import {
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyTickets(request: Request, companyId: string, otherId: string) { export async function verifyTickets(request: Request, companyId: string, otherId: string) {
const path = `/tickets/${companyId}`; const path = `/tickets/${companyId}`;
const key = randomUUID(); const key = randomUUID();
const lookupsResponse = await request(`${path}/lookups`); const lookupsResponse = await request(`${path}/lookups`);
assert.equal(lookupsResponse.status, 200); assert.equal(lookupsResponse.status, 200);
const lookups = TicketLookupsSchema.parse(await lookupsResponse.json()); const lookups = TicketLookupsSchema.parse(await lookupsResponse.json());
const list = TicketListSchema.parse(await (await request("/tickets")).json()); const list = TicketListSchema.parse(await (await request("/tickets")).json());
const person = lookups.people.find((item) => item.active); const person = lookups.people.find((item) => item.active);
const asset = lookups.assets.find((item) => item.active); const asset = lookups.assets.find((item) => item.active);
const technician = list.technicians.find((item) => item.active); const technician = list.technicians.find((item) => item.active);
assert.ok(person && asset && technician); assert.ok(person && asset && technician);
const values: TicketInput = { const values: TicketInput = {
subject: "Ticket de prueba", subject: "Ticket de prueba",
@ -37,20 +40,26 @@ export async function verifyTickets(request: Request, companyId: string, otherId
], ],
}; };
const body = { values, requestKey: key, expectedRevisionId: null }; const body = { values, requestKey: key, expectedRevisionId: null };
async function error(url: string, payload: unknown, status: number, code: string) { async function error(url: string, payload: unknown, status: number, code: string) {
const response = await request(url, payload); const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text()); assert.equal(response.status, status, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
} }
const createdResponse = await request(path, body); const createdResponse = await request(path, body);
assert.equal(createdResponse.status, 201, await createdResponse.clone().text()); assert.equal(createdResponse.status, 201, await createdResponse.clone().text());
const created = TicketResponseSchema.parse(await createdResponse.json()).ticket; const created = TicketResponseSchema.parse(await createdResponse.json()).ticket;
assert.equal(created.id[14], "7"); assert.equal(created.id[14], "7");
assert.match(created.reference, /^T-[0-9]{6,}$/); assert.match(created.reference, /^T-[0-9]{6,}$/);
assert.equal(created.history.length, 1); assert.equal(created.history.length, 1);
assert.equal(created.requesterName, person.name); assert.equal(created.requesterName, person.name);
assert.equal(created.assigneeName, technician.name); assert.equal(created.assigneeName, technician.name);
const repeatedResponse = await request(path, body); const repeatedResponse = await request(path, body);
assert.equal(repeatedResponse.status, 201); assert.equal(repeatedResponse.status, 201);
assert.equal(TicketResponseSchema.parse(await repeatedResponse.json()).ticket.id, created.id); assert.equal(TicketResponseSchema.parse(await repeatedResponse.json()).ticket.id, created.id);
await error(path, { ...body, values: { ...values, subject: "Otro" } }, 409, "REQUEST_KEY_REUSED"); await error(path, { ...body, values: { ...values, subject: "Otro" } }, 409, "REQUEST_KEY_REUSED");
@ -58,6 +67,7 @@ export async function verifyTickets(request: Request, companyId: string, otherId
const otherPerson = TicketLookupsSchema.parse( const otherPerson = TicketLookupsSchema.parse(
await (await request(`/tickets/${otherId}/lookups`)).json() await (await request(`/tickets/${otherId}/lookups`)).json()
).people[0]; ).people[0];
if (otherPerson) { if (otherPerson) {
await error( await error(
path, path,
@ -92,18 +102,24 @@ export async function verifyTickets(request: Request, companyId: string, otherId
assert.equal((await request(path, body, "http://evil.example")).status, 403); assert.equal((await request(path, body, "http://evil.example")).status, 403);
assert.equal((await request(path, body, undefined, "invalid")).status, 403); assert.equal((await request(path, body, undefined, "invalid")).status, 403);
assert.equal((await request("/tickets?unknown=1")).status, 400); assert.equal((await request("/tickets?unknown=1")).status, 400);
async function update(ticket: TicketDTO, next: TicketInput) { async function update(ticket: TicketDTO, next: TicketInput) {
const response = await request(`${path}/${ticket.id}/update`, { const response = await request(`${path}/${ticket.id}/update`, {
values: next, values: next,
requestKey: randomUUID(), requestKey: randomUUID(),
expectedRevisionId: ticket.history.at(-1)?.id, expectedRevisionId: ticket.history.at(-1)?.id,
}); });
assert.equal(response.status, 200, await response.clone().text()); assert.equal(response.status, 200, await response.clone().text());
return TicketResponseSchema.parse(await response.json()).ticket; return TicketResponseSchema.parse(await response.json()).ticket;
} }
const same = await update(created, values); const same = await update(created, values);
assert.equal(same.history.length, 1); assert.equal(same.history.length, 1);
const updated = await update(same, { ...values, state: "in_progress", priority: "high" }); const updated = await update(same, { ...values, state: "in_progress", priority: "high" });
assert.equal(updated.history.length, 2); assert.equal(updated.history.length, 2);
assert.equal(updated.reference, created.reference); assert.equal(updated.reference, created.reference);
assert.equal(updated.history[0]?.values.state, "open"); assert.equal(updated.history[0]?.values.state, "open");
@ -123,23 +139,29 @@ export async function verifyTickets(request: Request, companyId: string, otherId
}) })
) )
); );
assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]); assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]);
const other = await request(`/tickets/${otherId}`, { const other = await request(`/tickets/${otherId}`, {
values: { ...values, requesterId: null, assigneeId: null, affected: [] }, values: { ...values, requesterId: null, assigneeId: null, affected: [] },
requestKey: randomUUID(), requestKey: randomUUID(),
expectedRevisionId: null, expectedRevisionId: null,
}); });
assert.equal(other.status, 201); assert.equal(other.status, 201);
const otherTicket = TicketResponseSchema.parse(await other.json()).ticket; const otherTicket = TicketResponseSchema.parse(await other.json()).ticket;
assert.notEqual(otherTicket.reference, created.reference); assert.notEqual(otherTicket.reference, created.reference);
const global = TicketListSchema.parse(await (await request("/tickets")).json()); const global = TicketListSchema.parse(await (await request("/tickets")).json());
assert.ok(global.tickets.some((item) => item.id === created.id)); assert.ok(global.tickets.some((item) => item.id === created.id));
assert.ok(global.tickets.some((item) => item.id === otherTicket.id)); assert.ok(global.tickets.some((item) => item.id === otherTicket.id));
const scoped = TicketListSchema.parse(await (await request(path)).json()); const scoped = TicketListSchema.parse(await (await request(path)).json());
assert.ok(scoped.tickets.every((item) => item.companyId === companyId)); assert.ok(scoped.tickets.every((item) => item.companyId === companyId));
const originalPerson = CompanyResponseSchema.parse( const originalPerson = CompanyResponseSchema.parse(
await (await request(`/companies/${companyId}`)).json() await (await request(`/companies/${companyId}`)).json()
).company.people.find((item) => item.id === person.id); ).company.people.find((item) => item.id === person.id);
assert.ok(originalPerson); assert.ok(originalPerson);
const personValues = { const personValues = {
name: originalPerson.name, name: originalPerson.name,
@ -153,14 +175,17 @@ export async function verifyTickets(request: Request, companyId: string, otherId
...personValues, ...personValues,
active: false, active: false,
}); });
assert.equal(disabledPerson.status, 200); assert.equal(disabledPerson.status, 200);
CompanyResponseSchema.parse(await disabledPerson.json()); CompanyResponseSchema.parse(await disabledPerson.json());
const current = scoped.tickets.find((item) => item.id === created.id); const current = scoped.tickets.find((item) => item.id === created.id);
assert.ok(current); assert.ok(current);
const retained = await update(current, { const retained = await update(current, {
...current.values, ...current.values,
description: "Referencia histórica retenida", description: "Referencia histórica retenida",
}); });
assert.equal(retained.values.requesterId, person.id); assert.equal(retained.values.requesterId, person.id);
await error(path, { ...body, requestKey: randomUUID() }, 409, "REQUESTER_UNAVAILABLE"); await error(path, { ...body, requestKey: randomUUID() }, 409, "REQUESTER_UNAVAILABLE");
assert.equal( assert.equal(
@ -170,5 +195,6 @@ export async function verifyTickets(request: Request, companyId: string, otherId
const host = InventoryResponseSchema.parse( const host = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json() await (await request(`/assets/${companyId}`)).json()
).assets.find((item) => item.id === asset.id); ).assets.find((item) => item.id === asset.id);
assert.ok(host); assert.ok(host);
} }

View File

@ -12,6 +12,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyWorkProfiles( export async function verifyWorkProfiles(
request: Request, request: Request,
companyId: string, companyId: string,
@ -24,6 +25,7 @@ export async function verifyWorkProfiles(
); );
const first = inventory.assets[0]; const first = inventory.assets[0];
const second = inventory.assets[1]; const second = inventory.assets[1];
assert.ok(first && second); assert.ok(first && second);
for (const item of [first, second]) { for (const item of [first, second]) {
if (item.values.status === "retired") { if (item.values.status === "retired") {
@ -46,11 +48,14 @@ export async function verifyWorkProfiles(
isContact: false, isContact: false,
}; };
const people = await request(`/companies/${companyId}/people`, fields); const people = await request(`/companies/${companyId}/people`, fields);
assert.equal(people.status, 201); assert.equal(people.status, 201);
const person = CompanyResponseSchema.parse(await people.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await people.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const foreign = CompanyResponseSchema.parse(await (await request(`/companies/${otherId}`)).json()) const foreign = CompanyResponseSchema.parse(await (await request(`/companies/${otherId}`)).json())
.company.people[0]; .company.people[0];
assert.ok(foreign); assert.ok(foreign);
const input = { const input = {
name: "Perfil Ana", name: "Perfil Ana",
@ -60,16 +65,22 @@ export async function verifyWorkProfiles(
active: true, active: true,
notes: null, notes: null,
}; };
const save = async (values: unknown, id?: string) => { const save = async (values: unknown, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text()); assert.equal(response.status, id ? 200 : 201, await response.clone().text());
return WorkProfileResponseSchema.parse(await response.json()).profile; return WorkProfileResponseSchema.parse(await response.json()).profile;
}; };
const error = async (values: unknown, code: string, id?: string, status = 409) => { const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values }); const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status, await response.clone().text()); assert.equal(response.status, status, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, personId: foreign.id }, "PROFILE_REFERENCE_NOT_FOUND", undefined, 404); await error({ ...input, personId: foreign.id }, "PROFILE_REFERENCE_NOT_FOUND", undefined, 404);
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400); await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
assert.equal((await request(path, { values: input }, "https://other.example")).status, 403); assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);
@ -78,15 +89,19 @@ export async function verifyWorkProfiles(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: input }), request(path, { values: input }),
]); ]);
assert.deepEqual(responses.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(responses.map((response) => response.status).sort(), [201, 409]);
const response = responses.find((response) => response.status === 201); const response = responses.find((response) => response.status === 201);
assert.ok(response); assert.ok(response);
const created = WorkProfileResponseSchema.parse(await response.json()).profile; const created = WorkProfileResponseSchema.parse(await response.json()).profile;
assert.equal(created.id.charAt(14), "7"); assert.equal(created.id.charAt(14), "7");
assert.equal(created.history[0]?.id.charAt(14), "7"); assert.equal(created.history[0]?.id.charAt(14), "7");
assert.ok(created.history[0]?.actorName); assert.ok(created.history[0]?.actorName);
assert.deepEqual(await save(input, created.id), created); assert.deepEqual(await save(input, created.id), created);
const another = await save({ ...input, assetId: second.id }); const another = await save({ ...input, assetId: second.id });
assert.equal(another.values.personId, created.values.personId); assert.equal(another.values.personId, created.values.personId);
const otherPersonResponse = await request(`/companies/${companyId}/people`, { const otherPersonResponse = await request(`/companies/${companyId}/people`, {
...fields, ...fields,
@ -95,16 +110,22 @@ export async function verifyWorkProfiles(
const otherPerson = CompanyResponseSchema.parse( const otherPerson = CompanyResponseSchema.parse(
await otherPersonResponse.json() await otherPersonResponse.json()
).company.people.at(-1); ).company.people.at(-1);
assert.ok(otherPerson); assert.ok(otherPerson);
await save({ ...input, personId: otherPerson.id }); await save({ ...input, personId: otherPerson.id });
await error({ ...input, assetId: second.id }, "PROFILE_TARGET_IMMUTABLE", created.id); await error({ ...input, assetId: second.id }, "PROFILE_TARGET_IMMUTABLE", created.id);
const createAccess = async (values: AccessFieldsDTO) => { const createAccess = async (values: AccessFieldsDTO) => {
const res = await request(`/access/${companyId}`, { values }); const res = await request(`/access/${companyId}`, { values });
assert.equal(res.status, 201, await res.clone().text()); assert.equal(res.status, 201, await res.clone().text());
const item = AccessResponseSchema.parse(await res.json()).items.at(-1); const item = AccessResponseSchema.parse(await res.json()).items.at(-1);
assert.ok(item); assert.ok(item);
return item; return item;
}; };
const system = await createAccess({ kind: "system", name: "Dominio perfiles", active: true }); const system = await createAccess({ kind: "system", name: "Dominio perfiles", active: true });
const account = await createAccess({ const account = await createAccess({
kind: "account", kind: "account",
@ -116,6 +137,7 @@ export async function verifyWorkProfiles(
expiresOn: null, expiresOn: null,
notes: null, notes: null,
}); });
await error({ ...input, accountId: account.id }, "PROFILE_REFERENCE_UNAVAILABLE", created.id); await error({ ...input, accountId: account.id }, "PROFILE_REFERENCE_UNAVAILABLE", created.id);
await createAccess({ await createAccess({
kind: "account_person", kind: "account_person",
@ -126,6 +148,7 @@ export async function verifyWorkProfiles(
configuration: "pending", configuration: "pending",
}); });
const withAccount = await save({ ...input, accountId: account.id }, created.id); const withAccount = await save({ ...input, accountId: account.id }, created.id);
assert.equal(withAccount.history.length, 2); assert.equal(withAccount.history.length, 2);
assert.equal(withAccount.history[0]?.values.accountId, null); assert.equal(withAccount.history[0]?.values.accountId, null);
assert.equal( assert.equal(
@ -139,11 +162,14 @@ export async function verifyWorkProfiles(
const reviewed = WorkProfilesResponseSchema.parse( const reviewed = WorkProfilesResponseSchema.parse(
await (await request(path)).json() await (await request(path)).json()
).profiles.find((item) => item.id === created.id); ).profiles.find((item) => item.id === created.id);
assert.equal(reviewed?.needsReview, true); assert.equal(reviewed?.needsReview, true);
const archived = await save({ ...withAccount.values, active: false }, created.id); const archived = await save({ ...withAccount.values, active: false }, created.id);
assert.equal(archived.needsReview, false); assert.equal(archived.needsReview, false);
await error(withAccount.values, "PROFILE_REFERENCE_UNAVAILABLE", created.id); await error(withAccount.values, "PROFILE_REFERENCE_UNAVAILABLE", created.id);
const replacement = await save(input); const replacement = await save(input);
assert.notEqual(replacement.id, created.id); assert.notEqual(replacement.id, created.id);
assert.equal( assert.equal(
(await request(`/work-profiles/${otherId}/${created.id}/update`, { values: input })).status, (await request(`/work-profiles/${otherId}/${created.id}/update`, { values: input })).status,
@ -165,6 +191,7 @@ export async function verifyWorkProfiles(
}); });
} }
const listed = WorkProfilesResponseSchema.parse(await (await request(path)).json()); const listed = WorkProfilesResponseSchema.parse(await (await request(path)).json());
assert.deepEqual( assert.deepEqual(
listed.profiles.find((item) => item.id === created.id), listed.profiles.find((item) => item.id === created.id),
archived archived

View File

@ -6,6 +6,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4"; import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>; type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyWorkstations( export async function verifyWorkstations(
request: Request, request: Request,
companyId: string, companyId: string,
@ -26,6 +27,7 @@ export async function verifyWorkstations(
.assets[0]; .assets[0];
const first = inventory.assets[0]; const first = inventory.assets[0];
const second = inventory.assets[1]; const second = inventory.assets[1];
assert.ok(first && second && foreign); assert.ok(first && second && foreign);
const personFields = { const personFields = {
name: "Persona puesto", name: "Persona puesto",
@ -36,14 +38,18 @@ export async function verifyWorkstations(
isContact: false, isContact: false,
}; };
const personResponse = await request(`/companies/${companyId}/people`, personFields); const personResponse = await request(`/companies/${companyId}/people`, personFields);
assert.equal(personResponse.status, 201); assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1); const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person); assert.ok(person);
const otherPersonResponse = await request(`/companies/${otherId}/people`, personFields); const otherPersonResponse = await request(`/companies/${otherId}/people`, personFields);
assert.equal(otherPersonResponse.status, 201); assert.equal(otherPersonResponse.status, 201);
const otherPerson = CompanyResponseSchema.parse( const otherPerson = CompanyResponseSchema.parse(
await otherPersonResponse.json() await otherPersonResponse.json()
).company.people.at(-1); ).company.people.at(-1);
assert.ok(otherPerson); assert.ok(otherPerson);
const input = { const input = {
name: "Puesto Ana", name: "Puesto Ana",
@ -53,16 +59,22 @@ export async function verifyWorkstations(
assetIds: [first.id, second.id], assetIds: [first.id, second.id],
sharedAssetIds: [], sharedAssetIds: [],
}; };
const save = async (values: unknown, id?: string) => { const save = async (values: unknown, id?: string) => {
const response = await request(id ? `${path}/${id}/update` : path, { values }); const response = await request(id ? `${path}/${id}/update` : path, { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text()); assert.equal(response.status, id ? 200 : 201, await response.clone().text());
return WorkstationResponseSchema.parse(await response.json()).workstation; return WorkstationResponseSchema.parse(await response.json()).workstation;
}; };
const error = async (values: unknown, code: string, id?: string, status = 409) => { const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(id ? `${path}/${id}/update` : path, { values }); const response = await request(id ? `${path}/${id}/update` : path, { values });
assert.equal(response.status, status); assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code); assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}; };
await error({ ...input, assetIds: [foreign.id] }, "ASSET_NOT_FOUND", undefined, 404); await error({ ...input, assetIds: [foreign.id] }, "ASSET_NOT_FOUND", undefined, 404);
await error({ ...input, siteId: other.sites[0]?.id }, "SITE_NOT_FOUND", undefined, 404); await error({ ...input, siteId: other.sites[0]?.id }, "SITE_NOT_FOUND", undefined, 404);
await error({ ...input, personId: otherPerson.id }, "PERSON_UNAVAILABLE"); await error({ ...input, personId: otherPerson.id }, "PERSON_UNAVAILABLE");
@ -74,9 +86,11 @@ export async function verifyWorkstations(
request(path, { values: input }), request(path, { values: input }),
request(path, { values: { ...input, name: "Puesto conflictivo" } }), request(path, { values: { ...input, name: "Puesto conflictivo" } }),
]); ]);
assert.deepEqual([a.status, b.status].sort(), [201, 409]); assert.deepEqual([a.status, b.status].sort(), [201, 409]);
const success = a.status === 201 ? a : b; const success = a.status === 201 ? a : b;
const created = WorkstationResponseSchema.parse(await success.json()).workstation; const created = WorkstationResponseSchema.parse(await success.json()).workstation;
assert.equal(created.id.charAt(14), "7"); assert.equal(created.id.charAt(14), "7");
assert.equal(created.history[0]?.id.charAt(14), "7"); assert.equal(created.history[0]?.id.charAt(14), "7");
assert.ok(created.history[0]?.actorName); assert.ok(created.history[0]?.actorName);
@ -84,8 +98,10 @@ export async function verifyWorkstations(
{ ...created.values, assetIds: [...created.values.assetIds].reverse() }, { ...created.values, assetIds: [...created.values.assetIds].reverse() },
created.id created.id
); );
assert.deepEqual(unchanged, created); assert.deepEqual(unchanged, created);
const empty = await save({ ...input, name: "Puesto vacío", siteId: null, assetIds: [] }); const empty = await save({ ...input, name: "Puesto vacío", siteId: null, assetIds: [] });
assert.equal( assert.equal(
( (
await request(`/companies/${companyId}/people/${person.id}/update`, { await request(`/companies/${companyId}/people/${person.id}/update`, {
@ -100,17 +116,22 @@ export async function verifyWorkstations(
await error({ ...empty.values, assetIds: [first.id] }, "ASSET_IN_WORKSTATION", empty.id); await error({ ...empty.values, assetIds: [first.id] }, "ASSET_IN_WORKSTATION", empty.id);
await error({ ...empty.values, assetIds: [created.id] }, "ASSET_NOT_FOUND", empty.id, 404); await error({ ...empty.values, assetIds: [created.id] }, "ASSET_NOT_FOUND", empty.id, 404);
const inactive = await save({ ...created.values, active: false }, created.id); const inactive = await save({ ...created.values, active: false }, created.id);
assert.equal(inactive.history.length, 2); assert.equal(inactive.history.length, 2);
const receiver = await save({ ...empty.values, assetIds: [first.id] }, empty.id); const receiver = await save({ ...empty.values, assetIds: [first.id] }, empty.id);
await error({ ...created.values, active: true }, "ASSET_IN_WORKSTATION", created.id); await error({ ...created.values, active: true }, "ASSET_IN_WORKSTATION", created.id);
const removed = await save({ ...receiver.values, assetIds: [] }, receiver.id); const removed = await save({ ...receiver.values, assetIds: [] }, receiver.id);
assert.equal(removed.history.length, 3); assert.equal(removed.history.length, 3);
const restored = await save(created.values, created.id); const restored = await save(created.values, created.id);
assert.equal(restored.history.length, 3); assert.equal(restored.history.length, 3);
assert.deepEqual(restored.history[1]?.values.assetIds, created.values.assetIds); assert.deepEqual(restored.history[1]?.values.assetIds, created.values.assetIds);
const foreignUpdate = await request(`/assets/${otherId}/workstations/${created.id}/update`, { const foreignUpdate = await request(`/assets/${otherId}/workstations/${created.id}/update`, {
values: created.values, values: created.values,
}); });
assert.equal(foreignUpdate.status, 404); assert.equal(foreignUpdate.status, 404);
await assert.rejects( await assert.rejects(
database.query("UPDATE workstation_assets SET assetId=:asset WHERE workstationId=:id", { database.query("UPDATE workstation_assets SET assetId=:asset WHERE workstationId=:id", {
@ -118,6 +139,7 @@ export async function verifyWorkstations(
}) })
); );
const corruption = { ...restored.values, assetIds: [foreign.id] }; const corruption = { ...restored.values, assetIds: [foreign.id] };
await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", { await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
replacements: { values: JSON.stringify(corruption), id: restored.id }, replacements: { values: JSON.stringify(corruption), id: restored.id },
}); });
@ -131,6 +153,7 @@ export async function verifyWorkstations(
const current = InventoryResponseSchema.parse( const current = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json() await (await request(`/assets/${companyId}`)).json()
); );
assert.deepEqual( assert.deepEqual(
current.workstations.find((item) => item.id === restored.id), current.workstations.find((item) => item.id === restored.id),
restored restored
@ -143,6 +166,7 @@ export async function verifyWorkstations(
// Las instantáneas anteriores se leen sin reescribir identidades ni historia. // Las instantáneas anteriores se leen sin reescribir identidades ni historia.
const legacyRoot = { ...restored.values }; const legacyRoot = { ...restored.values };
const { sharedAssetIds: _legacyShared, ...legacyValues } = legacyRoot; const { sharedAssetIds: _legacyShared, ...legacyValues } = legacyRoot;
await database.query( await database.query(
"UPDATE workstations SET " + "UPDATE workstations SET " +
String.fromCharCode(96) + String.fromCharCode(96) +
@ -196,11 +220,13 @@ export async function verifyWorkstations(
const sharedInput = { ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] }; const sharedInput = { ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] };
const sharedOne = await save({ ...sharedInput, name: "Compartido uno" }); const sharedOne = await save({ ...sharedInput, name: "Compartido uno" });
const sharedTwo = await save({ ...sharedInput, name: "Compartido dos" }); const sharedTwo = await save({ ...sharedInput, name: "Compartido dos" });
await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION"); await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION");
await save({ ...sharedOne.values, sharedAssetIds: [] }, sharedOne.id); await save({ ...sharedOne.values, sharedAssetIds: [] }, sharedOne.id);
await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION"); await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION");
await save({ ...sharedTwo.values, active: false }, sharedTwo.id); await save({ ...sharedTwo.values, active: false }, sharedTwo.id);
const exclusive = await save({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }); const exclusive = await save({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] });
await error({ ...sharedTwo.values, active: true }, "ASSET_IN_WORKSTATION", sharedTwo.id); await error({ ...sharedTwo.values, active: true }, "ASSET_IN_WORKSTATION", sharedTwo.id);
await save({ ...exclusive.values, active: false }, exclusive.id); await save({ ...exclusive.values, active: false }, exclusive.id);
const race = await Promise.all([ const race = await Promise.all([
@ -216,5 +242,6 @@ export async function verifyWorkstations(
values: { ...sharedInput, name: "Uso compartido concurrente", sharedAssetIds: [second.id] }, values: { ...sharedInput, name: "Uso compartido concurrente", sharedAssetIds: [second.id] },
}), }),
]); ]);
assert.deepEqual(race.map((response) => response.status).sort(), [201, 409]); assert.deepEqual(race.map((response) => response.status).sort(), [201, 409]);
} }

View File

@ -35,6 +35,7 @@ test("las acciones del dashboard conservan nombre, selección y deshabilitado si
2 2
</DashboardNavigationButton> </DashboardNavigationButton>
); );
assert.ok(markup.includes('aria-label="Open tickets: 2"')); assert.ok(markup.includes('aria-label="Open tickets: 2"'));
assert.ok(markup.includes('aria-pressed="true"')); assert.ok(markup.includes('aria-pressed="true"'));
assert.ok(markup.includes('disabled=""')); assert.ok(markup.includes('disabled=""'));
@ -48,6 +49,7 @@ test("los estados del dashboard mantienen texto observable además del color", (
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<DashboardStateBadge state={state}>{label}</DashboardStateBadge> <DashboardStateBadge state={state}>{label}</DashboardStateBadge>
); );
assert.ok(markup.includes(`>${label}</span>`)); assert.ok(markup.includes(`>${label}</span>`));
if (state === null) { if (state === null) {
assert.equal(markup.includes("data-state="), false); assert.equal(markup.includes("data-state="), false);
@ -72,6 +74,7 @@ test("las tablas del dashboard conservan caption, cabeceras semánticas y conten
</tbody> </tbody>
</DashboardTable> </DashboardTable>
); );
assert.match(markup, /<caption[^>]*>Pending work<\/caption>/); assert.match(markup, /<caption[^>]*>Pending work<\/caption>/);
assert.ok(markup.includes('<th scope="col">Reference</th>')); assert.ok(markup.includes('<th scope="col">Reference</th>'));
assert.ok(markup.includes("<td>T-2026-001</td>")); assert.ok(markup.includes("<td>T-2026-001</td>"));
@ -83,12 +86,14 @@ async function renderPage(
seed?: (client: QueryClient) => void seed?: (client: QueryClient) => void
) { ) {
const { instance, initialization } = createAppI18n(language); const { instance, initialization } = createAppI18n(language);
await initialization; await initialization;
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: false, retryOnMount: false, staleTime: Number.POSITIVE_INFINITY }, queries: { retry: false, retryOnMount: false, staleTime: Number.POSITIVE_INFINITY },
}, },
}); });
if (session) { if (session) {
client.setQueryData(identityKeys.session, session); client.setQueryData(identityKeys.session, session);
} }
@ -105,12 +110,14 @@ async function renderPage(
client.clear(); client.clear();
} }
} }
test("el acceso protege el contenido y traduce la carga de sesión", async () => { test("el acceso protege el contenido y traduce la carga de sesión", async () => {
for (const [language, pending] of [ for (const [language, pending] of [
["es", "Comprobando acceso"], ["es", "Comprobando acceso"],
["en", "Checking access"], ["en", "Checking access"],
] as const) { ] as const) {
const markup = await renderPage(language); const markup = await renderPage(language);
assert.equal(markup.includes("Tu espacio de soporte"), false); assert.equal(markup.includes("Tu espacio de soporte"), false);
assert.equal(markup.includes("Your support workspace"), false); assert.equal(markup.includes("Your support workspace"), false);
assert.match(markup, /role="combobox"/); assert.match(markup, /role="combobox"/);
@ -126,11 +133,13 @@ test("la configuración única y el login presentan sus formularios traducidos s
] as const) { ] as const) {
const snapshot = { user: null, csrfToken: "a".repeat(64), setupRequired: true }; const snapshot = { user: null, csrfToken: "a".repeat(64), setupRequired: true };
const setup = await renderPage(language, snapshot); const setup = await renderPage(language, snapshot);
assert.ok(setup.includes(setupTitle)); assert.ok(setup.includes(setupTitle));
assert.match(setup, /autoComplete="new-password"/); assert.match(setup, /autoComplete="new-password"/);
assert.match(setup, /minLength="8"/); assert.match(setup, /minLength="8"/);
assert.equal(setup.includes("Tu espacio de soporte"), false); assert.equal(setup.includes("Tu espacio de soporte"), false);
const login = await renderPage(language, { ...snapshot, setupRequired: false }); const login = await renderPage(language, { ...snapshot, setupRequired: false });
assert.ok(login.includes(loginTitle)); assert.ok(login.includes(loginTitle));
assert.match(login, /autoComplete="current-password"/); assert.match(login, /autoComplete="current-password"/);
assert.match(login, /minLength="1"/); assert.match(login, /minLength="1"/);
@ -140,11 +149,13 @@ test("la configuración única y el login presentan sus formularios traducidos s
test("solo el administrador inicial ve la navegación de gestión de usuarios", async () => { test("solo el administrador inicial ve la navegación de gestión de usuarios", async () => {
const snapshot = { user, csrfToken: "a".repeat(64), setupRequired: false }; const snapshot = { user, csrfToken: "a".repeat(64), setupRequired: false };
const normal = await renderPage("es", snapshot); const normal = await renderPage("es", snapshot);
assert.ok(normal.includes("Tu espacio de soporte")); assert.ok(normal.includes("Tu espacio de soporte"));
assert.ok(normal.includes("Mi contraseña")); assert.ok(normal.includes("Mi contraseña"));
assert.ok(normal.includes("Salir")); assert.ok(normal.includes("Salir"));
assert.equal(normal.includes('href="/settings/users"'), false); assert.equal(normal.includes('href="/settings/users"'), false);
const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } }); const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } });
assert.ok(admin.includes('href="/settings/users"')); assert.ok(admin.includes('href="/settings/users"'));
}); });
@ -167,6 +178,7 @@ test("el espacio abre Inicio tras el login y mantiene acceso a las compañías s
client.setQueryData(catalogKeys.scope(workspaceCompany.id), workspaceCatalog); client.setQueryData(catalogKeys.scope(workspaceCompany.id), workspaceCatalog);
} }
); );
assert.ok(html.includes(`aria-label="${spaces}"`)); assert.ok(html.includes(`aria-label="${spaces}"`));
assert.ok(html.includes(language === "es" ? "Trabajo pendiente" : "Pending work")); assert.ok(html.includes(language === "es" ? "Trabajo pendiente" : "Pending work"));
assert.ok(html.includes(language === "es" ? "Resumen por compañías" : "Company summary")); assert.ok(html.includes(language === "es" ? "Resumen por compañías" : "Company summary"));
@ -185,8 +197,10 @@ test("el espacio abre Inicio tras el login y mantiene acceso a las compañías s
test("el dashboard distingue carga, vacío y fallo parcial sin reemplazar métricas desconocidas por cero", async () => { test("el dashboard distingue carga, vacío y fallo parcial sin reemplazar métricas desconocidas por cero", async () => {
const session = { user, csrfToken: "a".repeat(64), setupRequired: false }; const session = { user, csrfToken: "a".repeat(64), setupRequired: false };
for (const language of ["es", "en"]) { for (const language of ["es", "en"]) {
const loading = await renderPage(language, session); const loading = await renderPage(language, session);
assert.ok(loading.includes("—")); assert.ok(loading.includes("—"));
assert.ok(loading.includes('role="status"')); assert.ok(loading.includes('role="status"'));
const empty = await renderPage(language, session, (client) => { const empty = await renderPage(language, session, (client) => {
@ -203,6 +217,7 @@ test("el dashboard distingue carga, vacío y fallo parcial sin reemplazar métri
} }
client.setQueryData(dashboardKeys.block("attention"), { items: [] }); client.setQueryData(dashboardKeys.block("attention"), { items: [] });
}); });
assert.ok( assert.ok(
empty.includes( empty.includes(
language === "es" language === "es"
@ -222,8 +237,10 @@ test("el dashboard distingue carga, vacío y fallo parcial sin reemplazar métri
const query = client const query = client
.getQueryCache() .getQueryCache()
.build(client, { queryKey: dashboardKeys.block("activity") }); .build(client, { queryKey: dashboardKeys.block("activity") });
query.setState({ status: "error", error: new Error("failed"), fetchStatus: "idle" }); query.setState({ status: "error", error: new Error("failed"), fetchStatus: "idle" });
}); });
assert.ok(partial.includes('role="alert"')); assert.ok(partial.includes('role="alert"'));
assert.ok(partial.includes(">23</strong>")); assert.ok(partial.includes(">23</strong>"));
assert.ok(!partial.includes("dashboard.blockError")); assert.ok(!partial.includes("dashboard.blockError"));
@ -231,6 +248,7 @@ test("el dashboard distingue carga, vacío y fallo parcial sin reemplazar métri
}); });
test("los enlaces del dashboard mantienen la compañía y la ficha exacta", () => { test("los enlaces del dashboard mantienen la compañía y la ficha exacta", () => {
const item = { id: "entry", companyId: "company" }; const item = { id: "entry", companyId: "company" };
assert.deepEqual(entryDestination({ ...item, kind: "ticket" }), { assert.deepEqual(entryDestination({ ...item, kind: "ticket" }), {
area: "tickets", area: "tickets",
companyId: "company", companyId: "company",
@ -258,6 +276,7 @@ test("los enlaces del dashboard mantienen la compañía y la ficha exacta", () =
test("los bloques del dashboard presentan datos, avisos y actividad reales en ambos idiomas", async () => { test("los bloques del dashboard presentan datos, avisos y actividad reales en ambos idiomas", async () => {
const session = { user, csrfToken: "a".repeat(64), setupRequired: false }; const session = { user, csrfToken: "a".repeat(64), setupRequired: false };
for (const language of ["es", "en"]) { for (const language of ["es", "en"]) {
const html = await renderPage(language, session, (client) => { const html = await renderPage(language, session, (client) => {
client.setQueryData(companyKeys.list, { companies: [workspaceCompany] }); client.setQueryData(companyKeys.list, { companies: [workspaceCompany] });
@ -280,6 +299,7 @@ test("los bloques del dashboard presentan datos, avisos y actividad reales en am
recordedAt: "2026-10-01T10:30:00.000Z", recordedAt: "2026-10-01T10:30:00.000Z",
actorName: "Operador test", actorName: "Operador test",
}; };
client.setQueryData(dashboardKeys.block("work"), { items: [entry], total: 1 }); client.setQueryData(dashboardKeys.block("work"), { items: [entry], total: 1 });
client.setQueryData(dashboardKeys.block("activity"), { items: [entry], total: 1 }); client.setQueryData(dashboardKeys.block("activity"), { items: [entry], total: 1 });
client.setQueryData(dashboardKeys.block("attention"), { client.setQueryData(dashboardKeys.block("attention"), {
@ -301,6 +321,7 @@ test("los bloques del dashboard presentan datos, avisos y actividad reales en am
total: 1, total: 1,
}); });
}); });
assert.ok(html.includes("Revisar acceso")); assert.ok(html.includes("Revisar acceso"));
assert.ok(html.includes("T-2026-001")); assert.ok(html.includes("T-2026-001"));
assert.ok(html.includes("Operador test")); assert.ok(html.includes("Operador test"));

View File

@ -11,7 +11,9 @@ export function App() {
queryFn: identityApi.session, queryFn: identityApi.session,
retry: false, retry: false,
}); });
useAppNavigation(session.data); useAppNavigation(session.data);
return ( return (
<div className="min-h-dvh bg-shell-content"> <div className="min-h-dvh bg-shell-content">
{!session.data?.user && ( {!session.data?.user && (

View File

@ -5,13 +5,17 @@ import { createAppI18n } from "./i18n";
function translationLeaves(value: unknown, prefix = ""): Record<string, string> { function translationLeaves(value: unknown, prefix = ""): Record<string, string> {
assert.ok(value !== null && typeof value === "object" && !Array.isArray(value)); assert.ok(value !== null && typeof value === "object" && !Array.isArray(value));
return Object.fromEntries( return Object.fromEntries(
Object.entries(value).flatMap(([key, content]) => { Object.entries(value).flatMap(([key, content]) => {
const path = prefix ? `${prefix}.${key}` : key; const path = prefix ? `${prefix}.${key}` : key;
if (typeof content === "string") { if (typeof content === "string") {
assert.ok(content.trim(), `Traducción vacía: ${path}`); assert.ok(content.trim(), `Traducción vacía: ${path}`);
return [[path, content]]; return [[path, content]];
} }
return Object.entries(translationLeaves(content, path)); return Object.entries(translationLeaves(content, path));
}) })
); );
@ -23,13 +27,16 @@ function parameters(value: string) {
test("todos los módulos cargan claves completas y parámetros equivalentes en es y en", async () => { test("todos los módulos cargan claves completas y parámetros equivalentes en es y en", async () => {
const { instance, initialization } = createAppI18n(); const { instance, initialization } = createAppI18n();
await initialization; await initialization;
for (const namespace of ["core", "identity", "companies", "access", "assets", "resources"]) { for (const namespace of ["core", "identity", "companies", "access", "assets", "resources"]) {
const spanish = translationLeaves(instance.getResourceBundle("es", namespace)); const spanish = translationLeaves(instance.getResourceBundle("es", namespace));
const english = translationLeaves(instance.getResourceBundle("en", namespace)); const english = translationLeaves(instance.getResourceBundle("en", namespace));
assert.deepEqual(Object.keys(english).sort(), Object.keys(spanish).sort(), namespace); assert.deepEqual(Object.keys(english).sort(), Object.keys(spanish).sort(), namespace);
for (const [key, value] of Object.entries(spanish)) { for (const [key, value] of Object.entries(spanish)) {
const translated = english[key]; const translated = english[key];
assert.equal(typeof translated, "string"); assert.equal(typeof translated, "string");
assert.ok(translated); assert.ok(translated);
assert.deepEqual(parameters(translated), parameters(value), `${namespace}:${key}`); assert.deepEqual(parameters(translated), parameters(value), `${namespace}:${key}`);
@ -45,6 +52,7 @@ test("todos los módulos cargan claves completas y parámetros equivalentes en e
test("las traducciones se resuelven en inglés y el idioma desconocido utiliza castellano", async () => { test("las traducciones se resuelven en inglés y el idioma desconocido utiliza castellano", async () => {
const { instance, initialization } = createAppI18n("en"); const { instance, initialization } = createAppI18n("en");
await initialization; await initialization;
assert.equal(instance.t("language"), "Language"); assert.equal(instance.t("language"), "Language");
assert.equal(instance.t("brands.title", { ns: "assets" }), "Brands"); assert.equal(instance.t("brands.title", { ns: "assets" }), "Brands");

View File

@ -37,5 +37,6 @@ export function createAppI18n(language = "es") {
}, },
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
}); });
return { instance, initialization }; return { instance, initialization };
} }

View File

@ -9,6 +9,7 @@ import { createAppI18n } from "./i18n";
import "./global.css"; import "./global.css";
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
throw new Error("Missing application root"); throw new Error("Missing application root");
} }

View File

@ -24,6 +24,7 @@ const AUTHENTICATED: SessionResponseDTO = {
test("el acceso restaura el destino solicitado, sin bucles ni redirecciones externas", () => { test("el acceso restaura el destino solicitado, sin bucles ni redirecciones externas", () => {
const destination = "/companies/019b8838-44af-776d-a406-af74452b6f26/people"; const destination = "/companies/019b8838-44af-776d-a406-af74452b6f26/people";
const login = `/login?${new URLSearchParams({ returnTo: destination })}`; const login = `/login?${new URLSearchParams({ returnTo: destination })}`;
assert.equal(appRedirect(destination, ANONYMOUS), login); assert.equal(appRedirect(destination, ANONYMOUS), login);
assert.equal(appRedirect(login, ANONYMOUS), undefined); assert.equal(appRedirect(login, ANONYMOUS), undefined);
assert.equal(appRedirect(login, AUTHENTICATED), destination); assert.equal(appRedirect(login, AUTHENTICATED), destination);

View File

@ -9,6 +9,7 @@ function safeReturnTo(value: string | null | undefined): string | undefined {
function accessHref(target: string, requested: string | null) { function accessHref(target: string, requested: string | null) {
const returnTo = safeReturnTo(requested); const returnTo = safeReturnTo(requested);
return `${target}${returnTo && returnTo !== "/" ? `?${new URLSearchParams({ returnTo })}` : ""}`; return `${target}${returnTo && returnTo !== "/" ? `?${new URLSearchParams({ returnTo })}` : ""}`;
} }
@ -16,19 +17,23 @@ export function appRedirect(location: string, session: SessionResponseDTO): stri
const url = new URL(location, "http://metis.invalid"); const url = new URL(location, "http://metis.invalid");
const pathname = url.pathname.replace(/\/+$/u, "") || "/"; const pathname = url.pathname.replace(/\/+$/u, "") || "/";
const accessPage = pathname === "/login" || pathname === "/setup"; const accessPage = pathname === "/login" || pathname === "/setup";
if (!session.user) { if (!session.user) {
const target = session.setupRequired ? "/setup" : "/login"; const target = session.setupRequired ? "/setup" : "/login";
const requested = accessPage ? url.searchParams.get("returnTo") : location; const requested = accessPage ? url.searchParams.get("returnTo") : location;
const destination = accessHref(target, requested); const destination = accessHref(target, requested);
return destination === location ? undefined : destination; return destination === location ? undefined : destination;
} }
if (accessPage) { if (accessPage) {
const requested = url.searchParams.get("returnTo"); const requested = url.searchParams.get("returnTo");
return safeReturnTo(requested) ?? "/dashboard"; return safeReturnTo(requested) ?? "/dashboard";
} }
if (pathname === "/") { if (pathname === "/") {
return `/dashboard${url.search}`; return `/dashboard${url.search}`;
} }
const canonical = `${pathname}${url.search}`; const canonical = `${pathname}${url.search}`;
return canonical === location ? undefined : canonical; return canonical === location ? undefined : canonical;
} }

View File

@ -6,11 +6,13 @@ import { appRedirect } from "./app-redirect";
export function useAppNavigation(session: SessionResponseDTO | undefined) { export function useAppNavigation(session: SessionResponseDTO | undefined) {
const location = useBrowserLocation(); const location = useBrowserLocation();
useEffect(() => { useEffect(() => {
if (!session) { if (!session) {
return; return;
} }
const destination = appRedirect(location, session); const destination = appRedirect(location, session);
if (destination) { if (destination) {
navigateTo(destination, { replace: true }); navigateTo(destination, { replace: true });
} }

View File

@ -29,8 +29,10 @@ export const workspaceCompany: CompanyDTO = {
departments: [], departments: [],
assignments: [], assignments: [],
}; };
const fieldId = "0199b200-0000-7000-8000-000000000004"; const fieldId = "0199b200-0000-7000-8000-000000000004";
const templateId = "0199b200-0000-7000-8000-000000000005"; const templateId = "0199b200-0000-7000-8000-000000000005";
export const workspaceCatalog: CatalogResponseDTO = { export const workspaceCatalog: CatalogResponseDTO = {
items: [ items: [
{ {
@ -67,6 +69,7 @@ export const workspaceCatalog: CatalogResponseDTO = {
}, },
], ],
}; };
export const workspaceAssets: AssetDTO[] = ["PORT-ANA-01", "PORT-RESERVA"].map((name, index) => { export const workspaceAssets: AssetDTO[] = ["PORT-ANA-01", "PORT-RESERVA"].map((name, index) => {
const values: AssetDTO["values"] = { const values: AssetDTO["values"] = {
name, name,
@ -77,6 +80,7 @@ export const workspaceAssets: AssetDTO[] = ["PORT-ANA-01", "PORT-RESERVA"].map((
notes: "", notes: "",
characteristics: { [fieldId]: "Lenovo" }, characteristics: { [fieldId]: "Lenovo" },
}; };
return { return {
id: `0199b200-0000-7000-8000-00000000000${index + 7}`, id: `0199b200-0000-7000-8000-00000000000${index + 7}`,
companyId: workspaceCompany.id, companyId: workspaceCompany.id,

View File

@ -10,6 +10,7 @@
"ignoreUnknown": true, "ignoreUnknown": true,
"includes": [ "includes": [
"**", "**",
"!!packages/shadcn-ui",
"!!**/node_modules", "!!**/node_modules",
"!!**/.next", "!!**/.next",
"!!**/dist", "!!**/dist",

View File

@ -0,0 +1,66 @@
# Formato y espaciado del código
Biome mantiene el formato general, la organización de imports y las reglas de calidad existentes. ESLint Stylistic complementa únicamente el espaciado vertical que Biome no inserta automáticamente. Las versiones están fijadas en package.json y pnpm-lock.yaml; el parser de TypeScript permite analizar TS, TSX y las sobrecargas sin añadir reglas de análisis duplicadas.
## Configuración
`.lintrc` es un JSON con las reglas de espaciado. No es un nombre de configuración que Biome o ESLint detecten por sí mismos: `eslint.config.mjs` lo carga y registra el plugin y el parser. Para ajustar la política, editar las reglas de `.lintrc`.
- Una línea vacía antes y después de funciones, clases, interfaces, tipos, enums y declaraciones exportadas.
- Separación entre funciones declaradas mediante variables, incluidas funciones flecha.
- Una línea vacía entre métodos de clase, constructor y accesores, y entre un grupo de propiedades y un método.
- Las propiedades de clase pueden seguir agrupadas sin líneas vacías individuales.
- Las firmas consecutivas de sobrecarga y su implementación se mantienen juntas.
- Separación al terminar un grupo de variables y antes de return cuando existe una instrucción previa.
- Como máximo una línea vacía consecutiva. Biome mantiene sus agrupaciones de imports.
El código de terceros de `packages/shadcn-ui`, los directorios generados, dependencias y declaraciones `.d.ts` quedan fuera del espaciado. La biblioteca vendor también queda excluida de Biome para respetar AGENTS.md.
## Comandos
```bash
pnpm format # Biome y después espaciado vertical, en todo el código propio
pnpm format:biome # Solo el formateador de Biome
pnpm format:spacing # Aplicar únicamente el espaciado vertical
pnpm lint:spacing # Comprobar espaciado sin modificar archivos
pnpm test:spacing # Validar las reglas, la sintaxis y la compatibilidad con Biome
```
También se puede actuar sobre un archivo concreto:
```bash
pnpm exec eslint modules/assets/src/api/domain/asset.aggregate.ts --fix
```
La incorporación de las reglas no reformatea automáticamente todo el código existente: `pnpm format:spacing` realiza esa adaptación cuando se ejecute. Los comandos habituales `lint` y `check` siguen siendo los de Biome; `lint:spacing` comprueba la política complementaria de forma explícita.
## Ejemplo
```ts
class Service {
private enabled = true;
private name = "Metis Desk";
constructor() {}
first() {
const name = this.name;
return name;
}
second() {
return this.enabled;
}
}
function first() {
return 1;
}
function second() {
return 2;
}
```
Fuentes de las reglas: [separación entre declaraciones](https://eslint.style/rules/padding-line-between-statements) y [separación entre miembros de clase](https://eslint.style/rules/lines-between-class-members).

32
eslint.config.mjs Normal file
View File

@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import stylistic from "@stylistic/eslint-plugin";
import parser from "@typescript-eslint/parser";
// .lintrc contiene solo espaciado vertical; Biome conserva el resto de reglas.
const spacing = JSON.parse(readFileSync(new URL("./.lintrc", import.meta.url), "utf8"));
export default [
{
ignores: [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/coverage/**",
"**/.turbo/**",
"**/out/**",
"**/public/**",
"**/*.d.ts",
"packages/shadcn-ui/**",
],
},
{
files: ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"],
languageOptions: {
parser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { "@stylistic": stylistic },
rules: spacing.rules,
},
];

View File

@ -3,6 +3,7 @@ import type { Maybe } from "@repo/rdx-utils";
import type { CatalogResponseDTO } from "../../common"; import type { CatalogResponseDTO } from "../../common";
import type { AssetCatalog } from "../domain/asset-catalog.aggregate"; import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
export function buildCatalogResponse( export function buildCatalogResponse(
catalog: AssetCatalog, catalog: AssetCatalog,
scope: Maybe<UniqueID> scope: Maybe<UniqueID>

View File

@ -3,6 +3,7 @@ import type { Maybe, Result } from "@repo/rdx-utils";
import type { AssetCatalog } from "../domain/asset-catalog.aggregate"; import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
import type { AssetCatalogError } from "../domain/asset-definition"; import type { AssetCatalogError } from "../domain/asset-definition";
export interface IAssetCatalogRepository { export interface IAssetCatalogRepository {
get(scope: Maybe<UniqueID>): Promise<Result<AssetCatalog, AssetCatalogError>>; get(scope: Maybe<UniqueID>): Promise<Result<AssetCatalog, AssetCatalogError>>;
update( update(

View File

@ -12,20 +12,26 @@ export function catalogScope(value: string) {
return Result.ok(Maybe.none<UniqueID>()); return Result.ok(Maybe.none<UniqueID>());
} }
const id = UniqueID.create(value); const id = UniqueID.create(value);
return id.isSuccess return id.isSuccess
? Result.ok(Maybe.some(id.data)) ? Result.ok(Maybe.some(id.data))
: Result.fail(new AssetCatalogError("INVALID_REQUEST")); : Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
export class AssetCatalogUseCases { export class AssetCatalogUseCases {
constructor(private readonly repository: IAssetCatalogRepository) {} constructor(private readonly repository: IAssetCatalogRepository) {}
async get(scope: string) { async get(scope: string) {
const parsed = catalogScope(scope); const parsed = catalogScope(scope);
return parsed.isSuccess ? this.repository.get(parsed.data) : parsed; return parsed.isSuccess ? this.repository.get(parsed.data) : parsed;
} }
async save(scope: string, identity: string | undefined, input: AssetDefinitionSpec) { async save(scope: string, identity: string | undefined, input: AssetDefinitionSpec) {
const parent = catalogScope(scope); const parent = catalogScope(scope);
const values = AssetDefinitionValues.create(input); const values = AssetDefinitionValues.create(input);
const id = identity ? UniqueID.create(identity) : Result.ok(undefined); const id = identity ? UniqueID.create(identity) : Result.ok(undefined);
if (!parent.isSuccess) { if (!parent.isSuccess) {
return parent; return parent;
} }
@ -35,19 +41,23 @@ export class AssetCatalogUseCases {
if (!id.isSuccess) { if (!id.isSuccess) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
return this.repository.update(parent.data, (catalog) => return this.repository.update(parent.data, (catalog) =>
catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data) catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data)
); );
} }
async setActive(scope: string, identity: string, active: boolean | null) { async setActive(scope: string, identity: string, active: boolean | null) {
const parent = catalogScope(scope); const parent = catalogScope(scope);
const id = UniqueID.create(identity); const id = UniqueID.create(identity);
if (!parent.isSuccess) { if (!parent.isSuccess) {
return parent; return parent;
} }
if (!id.isSuccess) { if (!id.isSuccess) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
return this.repository.update(parent.data, (catalog) => return this.repository.update(parent.data, (catalog) =>
catalog.setActive(parent.data, id.data, active) catalog.setActive(parent.data, id.data, active)
); );

View File

@ -18,10 +18,12 @@ export function mapAssetInput(
return Result.fail(new AssetCatalogError("TEMPLATE_FIXED")); return Result.fail(new AssetCatalogError("TEMPLATE_FIXED"));
} }
let specification = previous?.specification; let specification = previous?.specification;
if (!specification) { if (!specification) {
const template = context.catalog const template = context.catalog
.visible(Maybe.some(companyId)) .visible(Maybe.some(companyId))
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase()); .find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
if ( if (
!template || !template ||
template.values.data.kind !== "template" || template.values.data.kind !== "template" ||
@ -33,9 +35,11 @@ export function mapAssetInput(
const field = context.catalog.definitions.find( const field = context.catalog.definitions.find(
(item) => item.id.toPrimitive() === binding.fieldId (item) => item.id.toPrimitive() === binding.fieldId
); );
if (!field || field.values.data.kind !== "field") { if (!field || field.values.data.kind !== "field") {
throw new Error("Plantilla persistida inconsistente."); throw new Error("Plantilla persistida inconsistente.");
} }
return { return {
fieldId: field.id.toPrimitive(), fieldId: field.id.toPrimitive(),
name: field.values.data.name, name: field.values.data.name,
@ -52,16 +56,19 @@ export function mapAssetInput(
...(template.values.data.sections ? { sections: [...template.values.data.sections] } : {}), ...(template.values.data.sections ? { sections: [...template.values.data.sections] } : {}),
fields, fields,
}); });
if (!created.isSuccess) { if (!created.isSuccess) {
return created; return created;
} }
specification = created.data; specification = created.data;
} }
const mapped = mapBrandReference(input, specification, context, previous); const mapped = mapBrandReference(input, specification, context, previous);
if (!mapped.isSuccess) { if (!mapped.isSuccess) {
return mapped; return mapped;
} }
const values = AssetValues.create(mapped.data, specification); const values = AssetValues.create(mapped.data, specification);
return values.isSuccess ? Result.ok({ specification, values: values.data }) : values; return values.isSuccess ? Result.ok({ specification, values: values.data }) : values;
} }
@ -72,6 +79,7 @@ function mapBrandReference(
previous?: Asset previous?: Asset
): Result<AssetInput, AssetCatalogError> { ): Result<AssetInput, AssetCatalogError> {
let normalized = input; let normalized = input;
if (input.brandId) { if (input.brandId) {
const brand = context.brands.find( const brand = context.brands.find(
(item) => item.id.toPrimitive() === input.brandId?.toLowerCase() (item) => item.id.toPrimitive() === input.brandId?.toLowerCase()
@ -79,6 +87,7 @@ function mapBrandReference(
const field = context.catalog.definitions.find( const field = context.catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "field:brand" (item) => item.stockKey.getOrUndefined() === "field:brand"
); );
if ( if (
!brand || !brand ||
(!brand.active && previous?.values.toPrimitive().brandId !== brand.id.toPrimitive()) (!brand.active && previous?.values.toPrimitive().brandId !== brand.id.toPrimitive())
@ -101,5 +110,6 @@ function mapBrandReference(
}, },
}; };
} }
return Result.ok(normalized); return Result.ok(normalized);
} }

View File

@ -1,10 +1,12 @@
import type { RelationshipDTO } from "../../common/relationships"; import type { RelationshipDTO } from "../../common/relationships";
import type { AssetRelationship } from "../domain/asset-relationship.aggregate"; import type { AssetRelationship } from "../domain/asset-relationship.aggregate";
export function buildRelationshipResponse( export function buildRelationshipResponse(
item: AssetRelationship, item: AssetRelationship,
actors: ReadonlyMap<string, string> actors: ReadonlyMap<string, string>
): RelationshipDTO { ): RelationshipDTO {
const endedBy = item.endedBy.getOrUndefined()?.toPrimitive() ?? null; const endedBy = item.endedBy.getOrUndefined()?.toPrimitive() ?? null;
return { return {
id: item.id.toPrimitive(), id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(), companyId: item.companyId.toPrimitive(),

View File

@ -10,25 +10,31 @@ import type { IAssetRepository, InventoryContext } from "./asset.repository";
export class AssetRelationshipUseCases { export class AssetRelationshipUseCases {
constructor(private readonly repository: IAssetRepository) {} constructor(private readonly repository: IAssetRepository) {}
save(companyId: string, input: RelationshipInputDTO, actorId: string) { save(companyId: string, input: RelationshipInputDTO, actorId: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId); const actor = UniqueID.create(actorId);
if (!(company.isSuccess && actor.isSuccess)) { if (!(company.isSuccess && actor.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
} }
return this.repository.saveRelationship(company.data, (context) => { return this.repository.saveRelationship(company.data, (context) => {
const mapped = mapRelationshipInput(input, company.data, context); const mapped = mapRelationshipInput(input, company.data, context);
if (!mapped.isSuccess) { if (!mapped.isSuccess) {
return Result.fail(mapped.error); return Result.fail(mapped.error);
} }
const previous = context.relationships.find( const previous = context.relationships.find(
(item) => item.active && item.values.key() === mapped.data.key() (item) => item.active && item.values.key() === mapped.data.key()
); );
if (previous) { if (previous) {
return previous.values.notes === mapped.data.notes return previous.values.notes === mapped.data.notes
? Result.ok(previous) ? Result.ok(previous)
: Result.fail(new AssetCatalogError("RELATIONSHIP_EXISTS")); : Result.fail(new AssetCatalogError("RELATIONSHIP_EXISTS"));
} }
return AssetRelationship.create( return AssetRelationship.create(
company.data, company.data,
mapped.data, mapped.data,
@ -37,29 +43,37 @@ export class AssetRelationshipUseCases {
); );
}); });
} }
remove(companyId: string, id: string) { remove(companyId: string, id: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const identity = UniqueID.create(id); const identity = UniqueID.create(id);
if (!(company.isSuccess && identity.isSuccess)) { if (!(company.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
} }
return this.repository.removeRelationship(company.data, identity.data); return this.repository.removeRelationship(company.data, identity.data);
} }
end(companyId: string, id: string, actorId: string) { end(companyId: string, id: string, actorId: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const identity = UniqueID.create(id); const identity = UniqueID.create(id);
const actor = UniqueID.create(actorId); const actor = UniqueID.create(actorId);
if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) { if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
} }
return this.repository.saveRelationship(company.data, (context) => { return this.repository.saveRelationship(company.data, (context) => {
const item = context.relationships.find((entry) => entry.id.equals(identity.data)); const item = context.relationships.find((entry) => entry.id.equals(identity.data));
return item return item
? item.end(actor.data, new Date().toISOString()) ? item.end(actor.data, new Date().toISOString())
: Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND")); : Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND"));
}); });
} }
} }
export function mapRelationshipInput( export function mapRelationshipInput(
input: RelationshipInputDTO, input: RelationshipInputDTO,
company: UniqueID, company: UniqueID,
@ -76,9 +90,11 @@ export function mapRelationshipInput(
const type = context.catalog const type = context.catalog
.visible(scope) .visible(scope)
.find((item) => item.id.toPrimitive() === input.typeId); .find((item) => item.id.toPrimitive() === input.typeId);
if (!(type && type.values.data.kind === "relation" && context.catalog.active(type, scope))) { if (!(type && type.values.data.kind === "relation" && context.catalog.active(type, scope))) {
return Result.fail(new AssetCatalogError("RELATION_TYPE_UNAVAILABLE")); return Result.fail(new AssetCatalogError("RELATION_TYPE_UNAVAILABLE"));
} }
return AssetRelationshipValues.create({ return AssetRelationshipValues.create({
...input, ...input,
name: type.values.data.name, name: type.values.data.name,

View File

@ -1,5 +1,6 @@
import type { AssetDTO } from "../../common/inventory"; import type { AssetDTO } from "../../common/inventory";
import type { Asset } from "../domain/asset.aggregate"; import type { Asset } from "../domain/asset.aggregate";
export function buildAssetResponse(asset: Asset, actors: ReadonlyMap<string, string>): AssetDTO { export function buildAssetResponse(asset: Asset, actors: ReadonlyMap<string, string>): AssetDTO {
return { return {
id: asset.id.toPrimitive(), id: asset.id.toPrimitive(),

View File

@ -19,6 +19,7 @@ export type InventoryContext = Readonly<{
people: readonly { id: UniqueID; active: boolean }[]; people: readonly { id: UniqueID; active: boolean }[];
actorNames: ReadonlyMap<string, string>; actorNames: ReadonlyMap<string, string>;
}>; }>;
export interface IAssetRepository { export interface IAssetRepository {
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>; removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
saveWorkstation( saveWorkstation(
@ -42,10 +43,13 @@ export interface IAssetRepository {
action: (context: InventoryContext) => Result<Asset, AssetCatalogError> action: (context: InventoryContext) => Result<Asset, AssetCatalogError>
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>; ): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
} }
export function validateAssetReferences(context: InventoryContext, values: AssetValues) { export function validateAssetReferences(context: InventoryContext, values: AssetValues) {
const next = values.toPrimitive(); const next = values.toPrimitive();
if (next.siteId && !context.sites.some((site) => site.toPrimitive() === next.siteId)) { if (next.siteId && !context.sites.some((site) => site.toPrimitive() === next.siteId)) {
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND")); return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
} }
return Result.ok<true>(true); return Result.ok<true>(true);
} }

View File

@ -8,38 +8,49 @@ import type { AssetInput } from "../domain/asset-values";
import type { IAssetRepository } from "./asset.repository"; import type { IAssetRepository } from "./asset.repository";
import { validateAssetReferences } from "./asset.repository"; import { validateAssetReferences } from "./asset.repository";
import { mapAssetInput } from "./asset-input.mapper"; import { mapAssetInput } from "./asset-input.mapper";
export class AssetUseCases { export class AssetUseCases {
constructor(private readonly repository: IAssetRepository) {} constructor(private readonly repository: IAssetRepository) {}
get(companyId: string) { get(companyId: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
if (!company.isSuccess) { if (!company.isSuccess) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
} }
return this.repository.get(company.data); return this.repository.get(company.data);
} }
save(companyId: string, id: string | undefined, input: AssetInput, actorId: string) { save(companyId: string, id: string | undefined, input: AssetInput, actorId: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId); const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined; const identity = id ? UniqueID.create(id) : undefined;
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) { if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
} }
return this.repository.save(company.data, (context) => { return this.repository.save(company.data, (context) => {
const previous = identity?.isSuccess const previous = identity?.isSuccess
? context.assets.find((asset) => asset.id.equals(identity.data)) ? context.assets.find((asset) => asset.id.equals(identity.data))
: undefined; : undefined;
if (id && !previous) { if (id && !previous) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND")); return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
} }
const mapped = mapAssetInput(input, company.data, context, previous); const mapped = mapAssetInput(input, company.data, context, previous);
if (!mapped.isSuccess) { if (!mapped.isSuccess) {
return Result.fail(mapped.error); return Result.fail(mapped.error);
} }
const references = validateAssetReferences(context, mapped.data.values); const references = validateAssetReferences(context, mapped.data.values);
if (!references.isSuccess) { if (!references.isSuccess) {
return Result.fail(references.error); return Result.fail(references.error);
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
return previous return previous
? previous.update(mapped.data.values, actor.data, now) ? previous.update(mapped.data.values, actor.data, now)
: Asset.create( : Asset.create(

View File

@ -4,19 +4,24 @@ import { Maybe, Result } from "@repo/rdx-utils";
import type { BrandInput, BrandProps } from "../domain/brand.aggregate"; import type { BrandInput, BrandProps } from "../domain/brand.aggregate";
import { BrandError, BrandInputSchema } from "../domain/brand.aggregate"; import { BrandError, BrandInputSchema } from "../domain/brand.aggregate";
import { BrandLogo } from "../domain/value-objects/brand-logo.vo"; import { BrandLogo } from "../domain/value-objects/brand-logo.vo";
export function mapBrandInput(input: BrandInput, builtin = false): Result<BrandProps, BrandError> { export function mapBrandInput(input: BrandInput, builtin = false): Result<BrandProps, BrandError> {
const parsed = BrandInputSchema.safeParse(input); const parsed = BrandInputSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
return Result.fail(new BrandError("INVALID_REQUEST")); return Result.fail(new BrandError("INVALID_REQUEST"));
} }
const name = Name.create(parsed.data.name, 100); const name = Name.create(parsed.data.name, 100);
if (!name.isSuccess) { if (!name.isSuccess) {
return Result.fail(new BrandError("INVALID_REQUEST")); return Result.fail(new BrandError("INVALID_REQUEST"));
} }
const logo = parsed.data.logoSvg ? BrandLogo.create(parsed.data.logoSvg) : undefined; const logo = parsed.data.logoSvg ? BrandLogo.create(parsed.data.logoSvg) : undefined;
if (logo && !logo.isSuccess) { if (logo && !logo.isSuccess) {
return Result.fail(new BrandError("INVALID_REQUEST")); return Result.fail(new BrandError("INVALID_REQUEST"));
} }
return Result.ok({ return Result.ok({
name: name.data, name: name.data,
active: parsed.data.active, active: parsed.data.active,

View File

@ -1,5 +1,6 @@
import type { BrandDTO } from "../../common/brands"; import type { BrandDTO } from "../../common/brands";
import type { Brand } from "../domain/brand.aggregate"; import type { Brand } from "../domain/brand.aggregate";
export function buildBrand(brand: Brand): BrandDTO { export function buildBrand(brand: Brand): BrandDTO {
return { return {
id: brand.id.toPrimitive(), id: brand.id.toPrimitive(),

View File

@ -1,6 +1,7 @@
import type { Result } from "@repo/rdx-utils"; import type { Result } from "@repo/rdx-utils";
import type { Brand, BrandError } from "../domain/brand.aggregate"; import type { Brand, BrandError } from "../domain/brand.aggregate";
export interface IBrandRepository { export interface IBrandRepository {
list(): Promise<readonly Brand[]>; list(): Promise<readonly Brand[]>;
save( save(

View File

@ -6,39 +6,50 @@ import { Brand, BrandError } from "../domain/brand.aggregate";
import type { IBrandRepository } from "./brand.repository"; import type { IBrandRepository } from "./brand.repository";
import { mapBrandInput } from "./brand-input.mapper"; import { mapBrandInput } from "./brand-input.mapper";
export class BrandUseCases { export class BrandUseCases {
constructor(private readonly repository: IBrandRepository) {} constructor(private readonly repository: IBrandRepository) {}
list() { list() {
return this.repository.list(); return this.repository.list();
} }
remove(id: string): Promise<Result<Brand, BrandError>> { remove(id: string): Promise<Result<Brand, BrandError>> {
const identity = UniqueID.create(id); const identity = UniqueID.create(id);
if (!identity.isSuccess) { if (!identity.isSuccess) {
return Promise.resolve(Result.fail<BrandError>(new BrandError("INVALID_REQUEST"))); return Promise.resolve(Result.fail<BrandError>(new BrandError("INVALID_REQUEST")));
} }
return this.repository.remove((brands) => { return this.repository.remove((brands) => {
const brand = brands.find((item) => item.id.equals(identity.data)); const brand = brands.find((item) => item.id.equals(identity.data));
if (!brand) { if (!brand) {
return Result.fail(new BrandError("BRAND_NOT_FOUND")); return Result.fail(new BrandError("BRAND_NOT_FOUND"));
} }
if (brand.builtin) { if (brand.builtin) {
return Result.fail(new BrandError("BUILTIN_PROTECTED")); return Result.fail(new BrandError("BUILTIN_PROTECTED"));
} }
return Result.ok(brand); return Result.ok(brand);
}); });
} }
save(id: string | undefined, input: BrandInput): Promise<Result<Brand, BrandError>> { save(id: string | undefined, input: BrandInput): Promise<Result<Brand, BrandError>> {
if (id && !UniqueID.create(id).isSuccess) { if (id && !UniqueID.create(id).isSuccess) {
return Promise.resolve(Result.fail<BrandError>(new BrandError("INVALID_REQUEST"))); return Promise.resolve(Result.fail<BrandError>(new BrandError("INVALID_REQUEST")));
} }
return this.repository.save((brands) => { return this.repository.save((brands) => {
const previous = id const previous = id
? brands.find((brand) => brand.id.toPrimitive() === id.toLowerCase()) ? brands.find((brand) => brand.id.toPrimitive() === id.toLowerCase())
: undefined; : undefined;
if (id && !previous) { if (id && !previous) {
return Result.fail(new BrandError("BRAND_NOT_FOUND")); return Result.fail(new BrandError("BRAND_NOT_FOUND"));
} }
const mapped = mapBrandInput(input, previous?.builtin ?? false); const mapped = mapBrandInput(input, previous?.builtin ?? false);
if (!mapped.isSuccess) { if (!mapped.isSuccess) {
return Result.fail(mapped.error); return Result.fail(mapped.error);
} }
@ -53,6 +64,7 @@ export class BrandUseCases {
) { ) {
return Result.fail(new BrandError("NAME_EXISTS")); return Result.fail(new BrandError("NAME_EXISTS"));
} }
return previous return previous
? previous.update({ ? previous.update({
...mapped.data, ...mapped.data,

View File

@ -6,16 +6,19 @@ import { SupportError } from "../domain/support-values";
import { SupportValues } from "../domain/value-objects/support-values.vo"; import { SupportValues } from "../domain/value-objects/support-values.vo";
import type { SupportContext } from "./support.repository"; import type { SupportContext } from "./support.repository";
export function mapSupportInput( export function mapSupportInput(
input: SupportInput, input: SupportInput,
context: SupportContext, context: SupportContext,
previous?: SupportConnection previous?: SupportConnection
) { ) {
const values = SupportValues.create(input); const values = SupportValues.create(input);
if (!values.isSuccess) { if (!values.isSuccess) {
return values; return values;
} }
const tool = context.tools.find((item) => item.id.equals(values.data.toolId)); const tool = context.tools.find((item) => item.id.equals(values.data.toolId));
if (!(tool && (tool.active || previous?.values.toolId.equals(tool.id)))) { if (!(tool && (tool.active || previous?.values.toolId.equals(tool.id)))) {
return Result.fail(new SupportError("TOOL_UNAVAILABLE")); return Result.fail(new SupportError("TOOL_UNAVAILABLE"));
} }
@ -32,10 +35,12 @@ export function mapSupportInput(
return Result.fail(new SupportError("PREFERRED_EXISTS")); return Result.fail(new SupportError("PREFERRED_EXISTS"));
} }
const next = values.data.toPrimitive(); const next = values.data.toPrimitive();
if ( if (
next.state !== "retired" && next.state !== "retired" &&
context.connections.some((item) => { context.connections.some((item) => {
const other = item.values.toPrimitive(); const other = item.values.toPrimitive();
return ( return (
item.id.toPrimitive() !== previous?.id.toPrimitive() && item.id.toPrimitive() !== previous?.id.toPrimitive() &&
other.state !== "retired" && other.state !== "retired" &&
@ -46,5 +51,6 @@ export function mapSupportInput(
) { ) {
return Result.fail(new SupportError("CONNECTION_EXISTS")); return Result.fail(new SupportError("CONNECTION_EXISTS"));
} }
return values; return values;
} }

View File

@ -1,6 +1,7 @@
import type { SupportConnectionDTO, SupportToolDTO } from "../../common/support"; import type { SupportConnectionDTO, SupportToolDTO } from "../../common/support";
import type { SupportConnection } from "../domain/support-connection.aggregate"; import type { SupportConnection } from "../domain/support-connection.aggregate";
import type { SupportTool } from "../domain/support-tool.aggregate"; import type { SupportTool } from "../domain/support-tool.aggregate";
export function buildSupportTool(tool: SupportTool): SupportToolDTO { export function buildSupportTool(tool: SupportTool): SupportToolDTO {
return { return {
id: tool.id.toPrimitive(), id: tool.id.toPrimitive(),
@ -9,6 +10,7 @@ export function buildSupportTool(tool: SupportTool): SupportToolDTO {
builtin: tool.builtin, builtin: tool.builtin,
}; };
} }
export function buildSupport( export function buildSupport(
connection: SupportConnection, connection: SupportConnection,
names: ReadonlyMap<string, string> names: ReadonlyMap<string, string>

View File

@ -4,12 +4,14 @@ import type { Result } from "@repo/rdx-utils";
import type { SupportConnection } from "../domain/support-connection.aggregate"; import type { SupportConnection } from "../domain/support-connection.aggregate";
import type { SupportTool } from "../domain/support-tool.aggregate"; import type { SupportTool } from "../domain/support-tool.aggregate";
import type { SupportError } from "../domain/support-values"; import type { SupportError } from "../domain/support-values";
export type SupportContext = Readonly<{ export type SupportContext = Readonly<{
connections: readonly SupportConnection[]; connections: readonly SupportConnection[];
tools: readonly SupportTool[]; tools: readonly SupportTool[];
assetRetired: boolean; assetRetired: boolean;
actorNames: ReadonlyMap<string, string>; actorNames: ReadonlyMap<string, string>;
}>; }>;
export interface ISupportRepository { export interface ISupportRepository {
remove(company: UniqueID, asset: UniqueID, id: UniqueID): Promise<Result<void, SupportError>>; remove(company: UniqueID, asset: UniqueID, id: UniqueID): Promise<Result<void, SupportError>>;
get(company: UniqueID, asset: UniqueID): Promise<Result<SupportContext, SupportError>>; get(company: UniqueID, asset: UniqueID): Promise<Result<SupportContext, SupportError>>;

View File

@ -8,28 +8,37 @@ import { SupportError } from "../domain/support-values";
import type { ISupportRepository } from "./support.repository"; import type { ISupportRepository } from "./support.repository";
import { mapSupportInput } from "./support-input.mapper"; import { mapSupportInput } from "./support-input.mapper";
export class SupportUseCases { export class SupportUseCases {
constructor(private readonly repository: ISupportRepository) {} constructor(private readonly repository: ISupportRepository) {}
get(companyId: string, assetId: string) { get(companyId: string, assetId: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const asset = UniqueID.create(assetId); const asset = UniqueID.create(assetId);
if (!(company.isSuccess && asset.isSuccess)) { if (!(company.isSuccess && asset.isSuccess)) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
} }
return this.repository.get(company.data, asset.data); return this.repository.get(company.data, asset.data);
} }
remove(companyId: string, assetId: string, id: string) { remove(companyId: string, assetId: string, id: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const asset = UniqueID.create(assetId); const asset = UniqueID.create(assetId);
const identity = UniqueID.create(id); const identity = UniqueID.create(id);
if (!(company.isSuccess && asset.isSuccess && identity.isSuccess)) { if (!(company.isSuccess && asset.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
} }
return this.repository.remove(company.data, asset.data, identity.data); return this.repository.remove(company.data, asset.data, identity.data);
} }
tools() { tools() {
return this.repository.tools(); return this.repository.tools();
} }
save( save(
companyId: string, companyId: string,
assetId: string, assetId: string,
@ -41,38 +50,47 @@ export class SupportUseCases {
const asset = UniqueID.create(assetId); const asset = UniqueID.create(assetId);
const actor = UniqueID.create(actorId); const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined; const identity = id ? UniqueID.create(id) : undefined;
if ( if (
!(company.isSuccess && asset.isSuccess && actor.isSuccess) || !(company.isSuccess && asset.isSuccess && actor.isSuccess) ||
(identity && !identity.isSuccess) (identity && !identity.isSuccess)
) { ) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
} }
return this.repository.save(company.data, asset.data, (context) => { return this.repository.save(company.data, asset.data, (context) => {
const previous = identity?.isSuccess const previous = identity?.isSuccess
? context.connections.find((item) => item.id.equals(identity.data)) ? context.connections.find((item) => item.id.equals(identity.data))
: undefined; : undefined;
if (id && !previous) { if (id && !previous) {
return Result.fail(new SupportError("CONNECTION_NOT_FOUND")); return Result.fail(new SupportError("CONNECTION_NOT_FOUND"));
} }
const values = mapSupportInput(input, context, previous); const values = mapSupportInput(input, context, previous);
if (!values.isSuccess) { if (!values.isSuccess) {
return Result.fail(values.error); return Result.fail(values.error);
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
return previous return previous
? previous.update(values.data, actor.data, now) ? previous.update(values.data, actor.data, now)
: SupportConnection.create(company.data, asset.data, values.data, actor.data, now); : SupportConnection.create(company.data, asset.data, values.data, actor.data, now);
}); });
} }
saveTool(id: string | undefined, input: SupportToolInput) { saveTool(id: string | undefined, input: SupportToolInput) {
const identity = id ? UniqueID.create(id) : undefined; const identity = id ? UniqueID.create(id) : undefined;
if (identity && !identity.isSuccess) { if (identity && !identity.isSuccess) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
} }
return this.repository.saveTool((tools) => { return this.repository.saveTool((tools) => {
const previous = identity?.isSuccess const previous = identity?.isSuccess
? tools.find((item) => item.id.equals(identity.data)) ? tools.find((item) => item.id.equals(identity.data))
: undefined; : undefined;
if (id && !previous) { if (id && !previous) {
return Result.fail(new SupportError("TOOL_NOT_FOUND")); return Result.fail(new SupportError("TOOL_NOT_FOUND"));
} }
@ -87,6 +105,7 @@ export class SupportUseCases {
) { ) {
return Result.fail(new SupportError("TOOL_EXISTS")); return Result.fail(new SupportError("TOOL_EXISTS"));
} }
return previous ? previous.update(input) : SupportTool.create(input); return previous ? previous.update(input) : SupportTool.create(input);
}); });
} }

View File

@ -6,20 +6,24 @@ import type { Workstation } from "../domain/workstation.aggregate";
import type { WorkstationInput } from "../domain/workstation-values"; import type { WorkstationInput } from "../domain/workstation-values";
import type { InventoryContext } from "./asset.repository"; import type { InventoryContext } from "./asset.repository";
export function mapWorkstationInput( export function mapWorkstationInput(
input: WorkstationInput, input: WorkstationInput,
context: InventoryContext, context: InventoryContext,
previous?: Workstation previous?: Workstation
) { ) {
const mapped = WorkstationValues.create(input); const mapped = WorkstationValues.create(input);
if (!mapped.isSuccess) { if (!mapped.isSuccess) {
return mapped; return mapped;
} }
const values = mapped.data.toPrimitive(); const values = mapped.data.toPrimitive();
if (values.siteId && !context.sites.some((id) => id.toPrimitive() === values.siteId)) { if (values.siteId && !context.sites.some((id) => id.toPrimitive() === values.siteId)) {
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND")); return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
} }
const person = context.people.find((item) => item.id.toPrimitive() === values.personId); const person = context.people.find((item) => item.id.toPrimitive() === values.personId);
if ( if (
values.personId && values.personId &&
(!person || (!person.active && previous?.values.toPrimitive().personId !== values.personId)) (!person || (!person.active && previous?.values.toPrimitive().personId !== values.personId))
@ -28,6 +32,7 @@ export function mapWorkstationInput(
} }
for (const id of [...values.assetIds, ...values.sharedAssetIds]) { for (const id of [...values.assetIds, ...values.sharedAssetIds]) {
const asset = context.assets.find((item) => item.id.toPrimitive() === id); const asset = context.assets.find((item) => item.id.toPrimitive() === id);
if (!asset) { if (!asset) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND")); return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
} }
@ -45,5 +50,6 @@ export function mapWorkstationInput(
return Result.fail(new AssetCatalogError("ASSET_IN_WORKSTATION")); return Result.fail(new AssetCatalogError("ASSET_IN_WORKSTATION"));
} }
} }
return mapped; return mapped;
} }

View File

@ -1,5 +1,6 @@
import type { WorkstationDTO } from "../../common/workstations"; import type { WorkstationDTO } from "../../common/workstations";
import type { Workstation } from "../domain/workstation.aggregate"; import type { Workstation } from "../domain/workstation.aggregate";
export function buildWorkstationResponse( export function buildWorkstationResponse(
item: Workstation, item: Workstation,
names: ReadonlyMap<string, string> names: ReadonlyMap<string, string>

View File

@ -7,27 +7,34 @@ import type { WorkstationInput } from "../domain/workstation-values";
import type { IAssetRepository } from "./asset.repository"; import type { IAssetRepository } from "./asset.repository";
import { mapWorkstationInput } from "./workstation-input.mapper"; import { mapWorkstationInput } from "./workstation-input.mapper";
export class WorkstationUseCases { export class WorkstationUseCases {
constructor(private readonly repository: IAssetRepository) {} constructor(private readonly repository: IAssetRepository) {}
save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) { save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId); const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined; const identity = id ? UniqueID.create(id) : undefined;
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) { if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
} }
return this.repository.saveWorkstation(company.data, (context) => { return this.repository.saveWorkstation(company.data, (context) => {
const previous = identity?.isSuccess const previous = identity?.isSuccess
? context.workstations.find((item) => item.id.equals(identity.data)) ? context.workstations.find((item) => item.id.equals(identity.data))
: undefined; : undefined;
if (id && !previous) { if (id && !previous) {
return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND")); return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
} }
const values = mapWorkstationInput(input, context, previous); const values = mapWorkstationInput(input, context, previous);
if (!values.isSuccess) { if (!values.isSuccess) {
return Result.fail(values.error); return Result.fail(values.error);
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
return previous return previous
? previous.update(values.data, actor.data, now) ? previous.update(values.data, actor.data, now)
: Workstation.create(company.data, values.data, actor.data, now); : Workstation.create(company.data, values.data, actor.data, now);

View File

@ -88,17 +88,21 @@ const SECTION_FIELDS: readonly [string, readonly string[]][] = [
export function seedAssetCatalog(catalog: AssetCatalog) { export function seedAssetCatalog(catalog: AssetCatalog) {
let current = catalog; let current = catalog;
const add = (key: string, input: AssetDefinitionSpec) => { const add = (key: string, input: AssetDefinitionSpec) => {
const values = AssetDefinitionValues.create(input); const values = AssetDefinitionValues.create(input);
if (!values.isSuccess) { if (!values.isSuccess) {
throw new Error("Definición inicial de activos inválida."); throw new Error("Definición inicial de activos inválida.");
} }
const result = current.seed(key, values.data); const result = current.seed(key, values.data);
if (!result.isSuccess) { if (!result.isSuccess) {
throw new Error("Catálogo inicial de activos inconsistente."); throw new Error("Catálogo inicial de activos inconsistente.");
} }
current = result.data; current = result.data;
}; };
for (const [key, name, type, unit] of FIELDS) { for (const [key, name, type, unit] of FIELDS) {
add(`field:${key}`, { kind: "field", name, description: "", type, unit, choices: [] }); add(`field:${key}`, { kind: "field", name, description: "", type, unit, choices: [] });
} }
@ -116,12 +120,15 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
const id = current.definitions const id = current.definitions
.find((item) => item.stockKey.getOrUndefined() === `field:${field}`) .find((item) => item.stockKey.getOrUndefined() === `field:${field}`)
?.id.toPrimitive(); ?.id.toPrimitive();
if (!id) { if (!id) {
throw new Error("Campo inicial de activos ausente."); throw new Error("Campo inicial de activos ausente.");
} }
const sectionId = sections.find((section) => section.keys.includes(field))?.id; const sectionId = sections.find((section) => section.keys.includes(field))?.id;
return { fieldId: id, required: false, ...(sectionId ? { sectionId } : {}) }; return { fieldId: id, required: false, ...(sectionId ? { sectionId } : {}) };
}); });
add(`template:${key}`, { add(`template:${key}`, {
kind: "template", kind: "template",
name, name,
@ -141,5 +148,6 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
] as const) { ] as const) {
add(`relation:${key}`, { kind: "relation", name, inverseName, symmetric, description: "" }); add(`relation:${key}`, { kind: "relation", name, inverseName, symmetric, description: "" });
} }
return current; return current;
} }

View File

@ -10,6 +10,7 @@ type CatalogProps = {
definitions: readonly AssetDefinition[]; definitions: readonly AssetDefinition[];
availability: readonly CatalogAvailability[]; availability: readonly CatalogAvailability[];
}; };
export class AssetCatalog extends AggregateRoot<CatalogProps> { export class AssetCatalog extends AggregateRoot<CatalogProps> {
private constructor(props: CatalogProps, id?: UniqueID) { private constructor(props: CatalogProps, id?: UniqueID) {
super( super(
@ -20,18 +21,23 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
id id
); );
} }
static create() { static create() {
return new AssetCatalog({ definitions: [], availability: [] }); return new AssetCatalog({ definitions: [], availability: [] });
} }
static rehydrate(props: CatalogProps, id: UniqueID) { static rehydrate(props: CatalogProps, id: UniqueID) {
return new AssetCatalog(props, id); return new AssetCatalog(props, id);
} }
get definitions() { get definitions() {
return this.props.definitions; return this.props.definitions;
} }
get availability() { get availability() {
return this.props.availability; return this.props.availability;
} }
visible(companyId: Maybe<UniqueID>) { visible(companyId: Maybe<UniqueID>) {
return this.definitions.filter( return this.definitions.filter(
(item) => (item) =>
@ -39,13 +45,16 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
item.companyId.getOrUndefined()?.equals(companyId.getOrUndefined()) item.companyId.getOrUndefined()?.equals(companyId.getOrUndefined())
); );
} }
active(item: AssetDefinition, companyId: Maybe<UniqueID>) { active(item: AssetDefinition, companyId: Maybe<UniqueID>) {
const override = this.availability.find( const override = this.availability.find(
(entry) => (entry) =>
entry.companyId.equals(companyId.getOrUndefined()) && entry.definitionId.equals(item.id) entry.companyId.equals(companyId.getOrUndefined()) && entry.definitionId.equals(item.id)
); );
return override?.active.getOrUndefined() ?? item.active; return override?.active.getOrUndefined() ?? item.active;
} }
save( save(
companyId: Maybe<UniqueID>, companyId: Maybe<UniqueID>,
id: Maybe<UniqueID>, id: Maybe<UniqueID>,
@ -54,23 +63,28 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
const previous = id.getOrUndefined() const previous = id.getOrUndefined()
? this.definitions.find((item) => item.id.equals(id.getOrUndefined())) ? this.definitions.find((item) => item.id.equals(id.getOrUndefined()))
: undefined; : undefined;
if (id.getOrUndefined() && !(previous && sameScope(previous.companyId, companyId))) { if (id.getOrUndefined() && !(previous && sameScope(previous.companyId, companyId))) {
return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND")); return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND"));
} }
const candidate = previous const candidate = previous
? previous.update(values) ? previous.update(values)
: Result.ok(AssetDefinition.create(companyId, values, true)); : Result.ok(AssetDefinition.create(companyId, values, true));
if (!candidate.isSuccess) { if (!candidate.isSuccess) {
return candidate; return candidate;
} }
return this.replace(candidate.data, companyId, previous); return this.replace(candidate.data, companyId, previous);
} }
private replace( private replace(
candidate: AssetDefinition, candidate: AssetDefinition,
scope: Maybe<UniqueID>, scope: Maybe<UniqueID>,
previous?: AssetDefinition previous?: AssetDefinition
): Result<AssetCatalog, AssetCatalogError> { ): Result<AssetCatalog, AssetCatalogError> {
const conflicts = scope.getOrUndefined() ? this.visible(scope) : this.definitions; const conflicts = scope.getOrUndefined() ? this.visible(scope) : this.definitions;
if ( if (
conflicts.some( conflicts.some(
(item) => (item) =>
@ -83,12 +97,14 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
return Result.fail(new AssetCatalogError("NAME_EXISTS")); return Result.fail(new AssetCatalogError("NAME_EXISTS"));
} }
const invalidField = this.invalidField(candidate, scope, previous); const invalidField = this.invalidField(candidate, scope, previous);
if (invalidField) { if (invalidField) {
return Result.fail(new AssetCatalogError("FIELD_UNAVAILABLE")); return Result.fail(new AssetCatalogError("FIELD_UNAVAILABLE"));
} }
if (candidate === previous) { if (candidate === previous) {
return Result.ok(this); return Result.ok(this);
} }
return Result.ok( return Result.ok(
new AssetCatalog( new AssetCatalog(
{ {
@ -101,6 +117,7 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
) )
); );
} }
private invalidField( private invalidField(
candidate: AssetDefinition, candidate: AssetDefinition,
scope: Maybe<UniqueID>, scope: Maybe<UniqueID>,
@ -108,22 +125,28 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
) { ) {
return candidate.values.fieldIds.some((id) => { return candidate.values.fieldIds.some((id) => {
const field = this.visible(scope).find((item) => item.id.equals(id)); const field = this.visible(scope).find((item) => item.id.equals(id));
if (!field || field.values.data.kind !== "field") { if (!field || field.values.data.kind !== "field") {
return true; return true;
} }
return !( return !(
this.active(field, scope) || this.active(field, scope) ||
previous?.values.fieldIds.some((existing) => existing.equals(id)) previous?.values.fieldIds.some((existing) => existing.equals(id))
); );
}); });
} }
seed(stockKey: string, values: AssetDefinitionValues): Result<AssetCatalog, AssetCatalogError> { seed(stockKey: string, values: AssetDefinitionValues): Result<AssetCatalog, AssetCatalogError> {
const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey); const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey);
if (previous) { if (previous) {
const upgraded = previous.initializeSections(values); const upgraded = previous.initializeSections(values);
if (upgraded === previous) { if (upgraded === previous) {
return Result.ok(this); return Result.ok(this);
} }
return Result.ok( return Result.ok(
new AssetCatalog( new AssetCatalog(
{ {
@ -136,21 +159,25 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
) )
); );
} }
return this.replace( return this.replace(
AssetDefinition.create(Maybe.none(), values, true, Maybe.some(stockKey)), AssetDefinition.create(Maybe.none(), values, true, Maybe.some(stockKey)),
Maybe.none() Maybe.none()
); );
} }
setActive( setActive(
scope: Maybe<UniqueID>, scope: Maybe<UniqueID>,
id: UniqueID, id: UniqueID,
active: boolean | null active: boolean | null
): Result<AssetCatalog, AssetCatalogError> { ): Result<AssetCatalog, AssetCatalogError> {
const item = this.visible(scope).find((entry) => entry.id.equals(id)); const item = this.visible(scope).find((entry) => entry.id.equals(id));
if (!item) { if (!item) {
return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND")); return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND"));
} }
const company = scope.getOrUndefined(); const company = scope.getOrUndefined();
if (company && !item.companyId.getOrUndefined()) { if (company && !item.companyId.getOrUndefined()) {
const previous = this.availability.find( const previous = this.availability.find(
(entry) => entry.companyId.equals(company) && entry.definitionId.equals(id) (entry) => entry.companyId.equals(company) && entry.definitionId.equals(id)
@ -159,9 +186,11 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
const changed = previous const changed = previous
? previous.update(value) ? previous.update(value)
: CatalogAvailability.create(company, id, value); : CatalogAvailability.create(company, id, value);
if (previous === changed || (!previous && active === null)) { if (previous === changed || (!previous && active === null)) {
return Result.ok(this); return Result.ok(this);
} }
return Result.ok( return Result.ok(
new AssetCatalog( new AssetCatalog(
{ {
@ -178,9 +207,11 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
const changed = item.setActive(active); const changed = item.setActive(active);
if (changed === item) { if (changed === item) {
return Result.ok(this); return Result.ok(this);
} }
return Result.ok( return Result.ok(
new AssetCatalog( new AssetCatalog(
{ {
@ -192,6 +223,7 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
); );
} }
} }
function sameScope(a: Maybe<UniqueID>, b: Maybe<UniqueID>) { function sameScope(a: Maybe<UniqueID>, b: Maybe<UniqueID>) {
return a.getOrUndefined()?.toPrimitive() === b.getOrUndefined()?.toPrimitive(); return a.getOrUndefined()?.toPrimitive() === b.getOrUndefined()?.toPrimitive();
} }

View File

@ -10,20 +10,28 @@ import type { AssetDefinitionSpec } from "./asset-definition";
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
const global = Maybe.none<UniqueID>(); const global = Maybe.none<UniqueID>();
function values(spec: AssetDefinitionSpec) { function values(spec: AssetDefinitionSpec) {
const result = AssetDefinitionValues.create(spec); const result = AssetDefinitionValues.create(spec);
assert.ok(result.isSuccess); assert.ok(result.isSuccess);
return result.data; return result.data;
} }
function field(name: string) { function field(name: string) {
return values({ kind: "field", name, type: "text", unit: "", choices: [], description: "" }); return values({ kind: "field", name, type: "text", unit: "", choices: [], description: "" });
} }
function changed(result: ReturnType<AssetCatalog["save"]>) { function changed(result: ReturnType<AssetCatalog["save"]>) {
assert.ok(result.isSuccess); assert.ok(result.isSuccess);
return result.data; return result.data;
} }
test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => { test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => {
const catalog = seedAssetCatalog(AssetCatalog.create()); const catalog = seedAssetCatalog(AssetCatalog.create());
assert.equal(catalog.definitions.length, 47); assert.equal(catalog.definitions.length, 47);
assert.equal(seedAssetCatalog(catalog), catalog); assert.equal(seedAssetCatalog(catalog), catalog);
const ids = [ const ids = [
@ -31,20 +39,25 @@ test("el catálogo inicial es idempotente, con UUID propios y características o
...catalog.definitions.map((item) => item.id), ...catalog.definitions.map((item) => item.id),
...catalog.definitions.flatMap((item) => item.bindings.map((binding) => binding.id)), ...catalog.definitions.flatMap((item) => item.bindings.map((binding) => binding.id)),
].map((id) => id.toPrimitive()); ].map((id) => id.toPrimitive());
assert.equal(new Set(ids).size, ids.length); assert.equal(new Set(ids).size, ids.length);
assert.ok(ids.every((id) => id.charAt(14) === "7")); assert.ok(ids.every((id) => id.charAt(14) === "7"));
const templates = catalog.definitions.filter((item) => item.values.data.kind === "template"); const templates = catalog.definitions.filter((item) => item.values.data.kind === "template");
assert.equal(templates.length, 12); assert.equal(templates.length, 12);
assert.ok(templates.every((item) => item.bindings.every((field) => !field.required))); assert.ok(templates.every((item) => item.bindings.every((field) => !field.required)));
const rehydrated = AssetCatalog.rehydrate( const rehydrated = AssetCatalog.rehydrate(
{ definitions: catalog.definitions, availability: catalog.availability }, { definitions: catalog.definitions, availability: catalog.availability },
catalog.id catalog.id
); );
assert.ok(rehydrated.id.equals(catalog.id)); assert.ok(rehydrated.id.equals(catalog.id));
assert.ok(rehydrated.definitions[0]?.id.equals(catalog.definitions[0]?.id)); assert.ok(rehydrated.definitions[0]?.id.equals(catalog.definitions[0]?.id));
const stock = catalog.definitions[0]; const stock = catalog.definitions[0];
assert.ok(stock); assert.ok(stock);
const protectedResult = catalog.save(global, Maybe.some(stock.id), field("Cambio")); const protectedResult = catalog.save(global, Maybe.some(stock.id), field("Cambio"));
assert.ok(!protectedResult.isSuccess); assert.ok(!protectedResult.isSuccess);
assert.equal(protectedResult.error.code, "BUILTIN_PROTECTED"); assert.equal(protectedResult.error.code, "BUILTIN_PROTECTED");
}); });
@ -53,6 +66,7 @@ test("la disponibilidad por compañía conserva el global y los campos desactiva
const first = Maybe.some(UniqueID.generateNewID()); const first = Maybe.some(UniqueID.generateNewID());
const second = Maybe.some(UniqueID.generateNewID()); const second = Maybe.some(UniqueID.generateNewID());
const stock = catalog.definitions[0]; const stock = catalog.definitions[0];
assert.ok(stock); assert.ok(stock);
catalog = changed(catalog.setActive(first, stock.id, false)); catalog = changed(catalog.setActive(first, stock.id, false));
assert.equal(catalog.active(stock, first), false); assert.equal(catalog.active(stock, first), false);
@ -60,6 +74,7 @@ test("la disponibilidad por compañía conserva el global y los campos desactiva
assert.equal(stock.active, true); assert.equal(stock.active, true);
assert.equal(catalog.definitions.length, 47); assert.equal(catalog.definitions.length, 47);
const overrideId = catalog.availability[0]?.id; const overrideId = catalog.availability[0]?.id;
assert.ok(overrideId); assert.ok(overrideId);
assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog); assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog);
catalog = changed(catalog.setActive(first, stock.id, null)); catalog = changed(catalog.setActive(first, stock.id, null));
@ -73,8 +88,10 @@ test("las plantillas validan ámbito, campos activos e identidad estable al edit
let catalog = seedAssetCatalog(AssetCatalog.create()); let catalog = seedAssetCatalog(AssetCatalog.create());
const company = Maybe.some(UniqueID.generateNewID()); const company = Maybe.some(UniqueID.generateNewID());
const other = Maybe.some(UniqueID.generateNewID()); const other = Maybe.some(UniqueID.generateNewID());
catalog = changed(catalog.save(company, Maybe.none(), field("Campo local"))); catalog = changed(catalog.save(company, Maybe.none(), field("Campo local")));
const local = catalog.definitions.at(-1); const local = catalog.definitions.at(-1);
assert.ok(local); assert.ok(local);
const template = values({ const template = values({
kind: "template", kind: "template",
@ -82,15 +99,19 @@ test("las plantillas validan ámbito, campos activos e identidad estable al edit
description: "", description: "",
fields: [{ fieldId: local.id.toPrimitive(), required: false }], fields: [{ fieldId: local.id.toPrimitive(), required: false }],
}); });
assert.ok(!catalog.save(global, Maybe.none(), template).isSuccess); assert.ok(!catalog.save(global, Maybe.none(), template).isSuccess);
assert.ok(!catalog.save(other, Maybe.none(), template).isSuccess); assert.ok(!catalog.save(other, Maybe.none(), template).isSuccess);
catalog = changed(catalog.save(company, Maybe.none(), template)); catalog = changed(catalog.save(company, Maybe.none(), template));
const saved = catalog.definitions.at(-1); const saved = catalog.definitions.at(-1);
assert.ok(saved); assert.ok(saved);
assert.equal(changed(catalog.save(company, Maybe.some(saved.id), template)), catalog); assert.equal(changed(catalog.save(company, Maybe.some(saved.id), template)), catalog);
const updated = values({ ...template.data, name: "Otra denominación" }); const updated = values({ ...template.data, name: "Otra denominación" });
catalog = changed(catalog.save(company, Maybe.some(saved.id), updated)); catalog = changed(catalog.save(company, Maybe.some(saved.id), updated));
const edited = catalog.definitions.at(-1); const edited = catalog.definitions.at(-1);
assert.ok(edited); assert.ok(edited);
assert.ok(edited.id.equals(saved.id)); assert.ok(edited.id.equals(saved.id));
assert.ok(edited.bindings[0]?.id.equals(saved.bindings[0]?.id)); assert.ok(edited.bindings[0]?.id.equals(saved.bindings[0]?.id));
@ -110,17 +131,21 @@ test("los campos validan listas, igualdad semántica, duplicados y tipo fijo", (
unit: "", unit: "",
choices: ["Buena", "Sustituir"], choices: ["Buena", "Sustituir"],
}; };
assert.ok(!AssetDefinitionValues.create({ ...list, choices: [] }).isSuccess); assert.ok(!AssetDefinitionValues.create({ ...list, choices: [] }).isSuccess);
assert.ok(!AssetDefinitionValues.create({ ...list, choices: ["Buena", " buena "] }).isSuccess); assert.ok(!AssetDefinitionValues.create({ ...list, choices: ["Buena", " buena "] }).isSuccess);
assert.ok(!AssetDefinitionValues.create({ ...list, type: "text" }).isSuccess); assert.ok(!AssetDefinitionValues.create({ ...list, type: "text" }).isSuccess);
const frozen = values(list); const frozen = values(list);
assert.ok(Object.isFrozen(frozen.data)); assert.ok(Object.isFrozen(frozen.data));
if (frozen.data.kind === "field") { if (frozen.data.kind === "field") {
assert.ok(Object.isFrozen(frozen.data.choices)); assert.ok(Object.isFrozen(frozen.data.choices));
} }
let catalog = AssetCatalog.create(); let catalog = AssetCatalog.create();
catalog = changed(catalog.save(global, Maybe.none(), frozen)); catalog = changed(catalog.save(global, Maybe.none(), frozen));
const saved = catalog.definitions[0]; const saved = catalog.definitions[0];
assert.ok(saved); assert.ok(saved);
assert.ok(!catalog.save(global, Maybe.none(), values({ ...list, name: " batería " })).isSuccess); assert.ok(!catalog.save(global, Maybe.none(), values({ ...list, name: " batería " })).isSuccess);
assert.ok(!catalog.save(global, Maybe.some(saved.id), field("Batería")).isSuccess); assert.ok(!catalog.save(global, Maybe.some(saved.id), field("Batería")).isSuccess);

View File

@ -10,12 +10,15 @@ export class TemplateField extends DomainEntity<{ fieldId: UniqueID; required: b
static create(fieldId: UniqueID, required: boolean, id?: UniqueID) { static create(fieldId: UniqueID, required: boolean, id?: UniqueID) {
return new TemplateField({ fieldId, required }, id); return new TemplateField({ fieldId, required }, id);
} }
static rehydrate(fieldId: UniqueID, required: boolean, id: UniqueID) { static rehydrate(fieldId: UniqueID, required: boolean, id: UniqueID) {
return new TemplateField({ fieldId, required }, id); return new TemplateField({ fieldId, required }, id);
} }
get fieldId() { get fieldId() {
return this.props.fieldId; return this.props.fieldId;
} }
get required() { get required() {
return this.props.required; return this.props.required;
} }
@ -28,10 +31,12 @@ type DefinitionProps = {
active: boolean; active: boolean;
bindings: readonly TemplateField[]; bindings: readonly TemplateField[];
}; };
export class AssetDefinition extends DomainEntity<DefinitionProps> { export class AssetDefinition extends DomainEntity<DefinitionProps> {
private constructor(props: DefinitionProps, id?: UniqueID) { private constructor(props: DefinitionProps, id?: UniqueID) {
super({ ...props, bindings: Object.freeze([...props.bindings]) }, id); super({ ...props, bindings: Object.freeze([...props.bindings]) }, id);
} }
static create( static create(
companyId: Maybe<UniqueID>, companyId: Maybe<UniqueID>,
values: AssetDefinitionValues, values: AssetDefinitionValues,
@ -44,37 +49,47 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
values.data.kind === "template" && (values.data.fields[index]?.required ?? false) values.data.kind === "template" && (values.data.fields[index]?.required ?? false)
) )
); );
return new AssetDefinition({ companyId, stockKey, values, active, bindings }); return new AssetDefinition({ companyId, stockKey, values, active, bindings });
} }
static rehydrate(props: DefinitionProps, id: UniqueID) { static rehydrate(props: DefinitionProps, id: UniqueID) {
return new AssetDefinition(props, id); return new AssetDefinition(props, id);
} }
get companyId() { get companyId() {
return this.props.companyId; return this.props.companyId;
} }
get stockKey() { get stockKey() {
return this.props.stockKey; return this.props.stockKey;
} }
get values() { get values() {
return this.props.values; return this.props.values;
} }
get active() { get active() {
return this.props.active; return this.props.active;
} }
get sections() { get sections() {
return this.values.data.kind === "template" return this.values.data.kind === "template"
? (this.values.data.sections ?? []).map((section) => TemplateSection.rehydrate(section)) ? (this.values.data.sections ?? []).map((section) => TemplateSection.rehydrate(section))
: []; : [];
} }
get bindings() { get bindings() {
return this.props.bindings; return this.props.bindings;
} }
update(values: AssetDefinitionValues): Result<AssetDefinition, AssetCatalogError> { update(values: AssetDefinitionValues): Result<AssetDefinition, AssetCatalogError> {
if (this.stockKey.getOrUndefined()) { if (this.stockKey.getOrUndefined()) {
return Result.fail(new AssetCatalogError("BUILTIN_PROTECTED")); return Result.fail(new AssetCatalogError("BUILTIN_PROTECTED"));
} }
const previous = this.values.data; const previous = this.values.data;
const next = values.data; const next = values.data;
if ( if (
previous.kind !== next.kind || previous.kind !== next.kind ||
(previous.kind === "field" && (previous.kind === "field" &&
@ -101,12 +116,15 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
this.bindings.find((field) => field.fieldId.equals(fieldId))?.id this.bindings.find((field) => field.fieldId.equals(fieldId))?.id
) )
); );
return Result.ok(new AssetDefinition({ ...this.props, values, bindings }, this.id)); return Result.ok(new AssetDefinition({ ...this.props, values, bindings }, this.id));
} }
/** Ampliación idempotente de presentación de serie; conserva datos y disponibilidad. */ /** Ampliación idempotente de presentación de serie; conserva datos y disponibilidad. */
initializeSections(source: AssetDefinitionValues) { initializeSections(source: AssetDefinitionValues) {
const previous = this.values.data; const previous = this.values.data;
const next = source.data; const next = source.data;
if ( if (
!this.stockKey.getOrUndefined() || !this.stockKey.getOrUndefined() ||
previous.kind !== "template" || previous.kind !== "template" ||
@ -124,14 +142,18 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
const sectionIds = new Set(sections.map((section) => section.id)); const sectionIds = new Set(sections.map((section) => section.id));
const fields = previous.fields.map((field) => { const fields = previous.fields.map((field) => {
const sectionId = next.fields.find((entry) => entry.fieldId === field.fieldId)?.sectionId; const sectionId = next.fields.find((entry) => entry.fieldId === field.fieldId)?.sectionId;
return { ...field, ...(sectionId && sectionIds.has(sectionId) ? { sectionId } : {}) }; return { ...field, ...(sectionId && sectionIds.has(sectionId) ? { sectionId } : {}) };
}); });
const values = AssetDefinitionValues.create({ ...previous, sections, fields }); const values = AssetDefinitionValues.create({ ...previous, sections, fields });
if (!values.isSuccess) { if (!values.isSuccess) {
throw new Error("Secciones iniciales de plantilla inconsistentes."); throw new Error("Secciones iniciales de plantilla inconsistentes.");
} }
return new AssetDefinition({ ...this.props, values: values.data }, this.id); return new AssetDefinition({ ...this.props, values: values.data }, this.id);
} }
setActive(active: boolean) { setActive(active: boolean) {
return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id); return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id);
} }
@ -150,6 +172,7 @@ export class CatalogAvailability extends DomainEntity<{
) { ) {
return new CatalogAvailability({ companyId, definitionId, active }, id); return new CatalogAvailability({ companyId, definitionId, active }, id);
} }
static rehydrate( static rehydrate(
companyId: UniqueID, companyId: UniqueID,
definitionId: UniqueID, definitionId: UniqueID,
@ -158,15 +181,19 @@ export class CatalogAvailability extends DomainEntity<{
) { ) {
return new CatalogAvailability({ companyId, definitionId, active }, id); return new CatalogAvailability({ companyId, definitionId, active }, id);
} }
get companyId() { get companyId() {
return this.props.companyId; return this.props.companyId;
} }
get definitionId() { get definitionId() {
return this.props.definitionId; return this.props.definitionId;
} }
get active() { get active() {
return this.props.active; return this.props.active;
} }
update(active: Maybe<boolean>) { update(active: Maybe<boolean>) {
return active.getOrUndefined() === this.active.getOrUndefined() return active.getOrUndefined() === this.active.getOrUndefined()
? this ? this

View File

@ -3,17 +3,21 @@ import { z } from "zod/v4";
import { validTemplateSections } from "./template-sections"; import { validTemplateSections } from "./template-sections";
const title = z.string().min(1).max(100); const title = z.string().min(1).max(100);
export const FieldTypeSchema = z.enum(["text", "number", "date", "boolean", "choice"]); export const FieldTypeSchema = z.enum(["text", "number", "date", "boolean", "choice"]);
export const TemplateFieldSchema = z.strictObject({ export const TemplateFieldSchema = z.strictObject({
fieldId: z.uuid(), fieldId: z.uuid(),
required: z.boolean(), required: z.boolean(),
sectionId: z.uuid().optional(), sectionId: z.uuid().optional(),
}); });
export const TemplateSectionSchema = z.strictObject({ export const TemplateSectionSchema = z.strictObject({
id: z.uuid(), id: z.uuid(),
name: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(100),
description: z.string().trim().max(1000), description: z.string().trim().max(1000),
}); });
export const AssetDefinitionSchema = z.discriminatedUnion("kind", [ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [
z.strictObject({ z.strictObject({
kind: z.literal("field"), kind: z.literal("field"),
@ -43,8 +47,11 @@ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [
symmetric: z.boolean(), symmetric: z.boolean(),
}), }),
]); ]);
export type AssetDefinitionSpec = z.infer<typeof AssetDefinitionSchema>; export type AssetDefinitionSpec = z.infer<typeof AssetDefinitionSchema>;
export type AssetFieldType = z.infer<typeof FieldTypeSchema>; export type AssetFieldType = z.infer<typeof FieldTypeSchema>;
export class AssetCatalogError extends Error { export class AssetCatalogError extends Error {
constructor(public readonly code: string) { constructor(public readonly code: string) {
super(code); super(code);

View File

@ -14,10 +14,12 @@ type RelationshipProps = Readonly<{
endedBy: Maybe<UniqueID>; endedBy: Maybe<UniqueID>;
endedAt: Maybe<string>; endedAt: Maybe<string>;
}>; }>;
export class AssetRelationship extends AggregateRoot<RelationshipProps> { export class AssetRelationship extends AggregateRoot<RelationshipProps> {
private constructor(props: RelationshipProps, id?: UniqueID) { private constructor(props: RelationshipProps, id?: UniqueID) {
super(Object.freeze({ ...props }), id); super(Object.freeze({ ...props }), id);
} }
static create( static create(
companyId: UniqueID, companyId: UniqueID,
values: AssetRelationshipValues, values: AssetRelationshipValues,
@ -27,6 +29,7 @@ export class AssetRelationship extends AggregateRoot<RelationshipProps> {
if (!z.iso.datetime().safeParse(now).success) { if (!z.iso.datetime().safeParse(now).success) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
return Result.ok( return Result.ok(
new AssetRelationship({ new AssetRelationship({
companyId, companyId,
@ -38,30 +41,39 @@ export class AssetRelationship extends AggregateRoot<RelationshipProps> {
}) })
); );
} }
static rehydrate(props: RelationshipProps, id: UniqueID) { static rehydrate(props: RelationshipProps, id: UniqueID) {
return new AssetRelationship(props, id); return new AssetRelationship(props, id);
} }
get companyId() { get companyId() {
return this.props.companyId; return this.props.companyId;
} }
get values() { get values() {
return this.props.values; return this.props.values;
} }
get createdBy() { get createdBy() {
return this.props.createdBy; return this.props.createdBy;
} }
get createdAt() { get createdAt() {
return this.props.createdAt; return this.props.createdAt;
} }
get endedBy() { get endedBy() {
return this.props.endedBy; return this.props.endedBy;
} }
get endedAt() { get endedAt() {
return this.props.endedAt; return this.props.endedAt;
} }
get active() { get active() {
return !this.endedAt.getOrUndefined(); return !this.endedAt.getOrUndefined();
} }
end(actor: UniqueID, now: string): Result<AssetRelationship, AssetCatalogError> { end(actor: UniqueID, now: string): Result<AssetRelationship, AssetCatalogError> {
if (!this.active) { if (!this.active) {
return Result.ok(this); return Result.ok(this);
@ -69,6 +81,7 @@ export class AssetRelationship extends AggregateRoot<RelationshipProps> {
if (!z.iso.datetime().safeParse(now).success || now < this.createdAt) { if (!z.iso.datetime().safeParse(now).success || now < this.createdAt) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
return Result.ok( return Result.ok(
new AssetRelationship( new AssetRelationship(
{ ...this.props, endedBy: Maybe.some(actor), endedAt: Maybe.some(now) }, { ...this.props, endedBy: Maybe.some(actor), endedAt: Maybe.some(now) },

View File

@ -14,6 +14,7 @@ import { AssetRelationshipValues } from "./value-objects/asset-relationship-valu
const sourceId = UniqueID.generateNewID().toPrimitive(); const sourceId = UniqueID.generateNewID().toPrimitive();
const targetId = UniqueID.generateNewID().toPrimitive(); const targetId = UniqueID.generateNewID().toPrimitive();
const typeId = UniqueID.generateNewID().toPrimitive(); const typeId = UniqueID.generateNewID().toPrimitive();
function input(symmetric = false): RelationshipValuesData { function input(symmetric = false): RelationshipValuesData {
return { return {
sourceId, sourceId,
@ -25,13 +26,18 @@ function input(symmetric = false): RelationshipValuesData {
notes: " rack 1 ", notes: " rack 1 ",
}; };
} }
function values(data = input()) { function values(data = input()) {
const result = AssetRelationshipValues.create(data); const result = AssetRelationshipValues.create(data);
assert.ok(result.isSuccess); assert.ok(result.isSuccess);
return result.data; return result.data;
} }
test("Las relaciones validan sus extremos, normalizan nombres y distinguen dirección", () => { test("Las relaciones validan sus extremos, normalizan nombres y distinguen dirección", () => {
const first = values(); const first = values();
assert.equal(first.toPrimitive().name, "Alojado en"); assert.equal(first.toPrimitive().name, "Alojado en");
assert.equal(first.notes, "rack 1"); assert.equal(first.notes, "rack 1");
assert.notEqual( assert.notEqual(
@ -39,12 +45,14 @@ test("Las relaciones validan sus extremos, normalizan nombres y distinguen direc
values({ ...input(), sourceId: targetId, targetId: sourceId }).key() values({ ...input(), sourceId: targetId, targetId: sourceId }).key()
); );
const symmetric = values(input(true)); const symmetric = values(input(true));
assert.equal( assert.equal(
symmetric.key(), symmetric.key(),
values({ ...input(true), sourceId: targetId, targetId: sourceId }).key() values({ ...input(true), sourceId: targetId, targetId: sourceId }).key()
); );
assert.equal(symmetric.toPrimitive().inverseName, "Alojado en"); assert.equal(symmetric.toPrimitive().inverseName, "Alojado en");
const self = AssetRelationshipValues.create({ ...input(), targetId: sourceId }); const self = AssetRelationshipValues.create({ ...input(), targetId: sourceId });
assert.ok(!self.isSuccess); assert.ok(!self.isSuccess);
assert.equal(self.error.code, "SELF_RELATIONSHIP"); assert.equal(self.error.code, "SELF_RELATIONSHIP");
assert.ok(!AssetRelationshipValues.create({ ...input(), targetId: "bad" }).isSuccess); assert.ok(!AssetRelationshipValues.create({ ...input(), targetId: "bad" }).isSuccess);
@ -57,12 +65,15 @@ test("Retirar conserva UUID, extremos y auditoría; repetir la retirada no modif
const actor = UniqueID.generateNewID(); const actor = UniqueID.generateNewID();
const otherActor = UniqueID.generateNewID(); const otherActor = UniqueID.generateNewID();
const created = AssetRelationship.create(company, values(), actor, "2026-10-04T10:00:00.000Z"); const created = AssetRelationship.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
assert.ok(created.isSuccess); assert.ok(created.isSuccess);
const link = created.data; const link = created.data;
assert.equal(link.id.toPrimitive().charAt(14), "7"); assert.equal(link.id.toPrimitive().charAt(14), "7");
assert.ok(link.active); assert.ok(link.active);
assert.ok(!link.end(otherActor, "2026-10-03T10:00:00.000Z").isSuccess); assert.ok(!link.end(otherActor, "2026-10-03T10:00:00.000Z").isSuccess);
const ended = link.end(otherActor, "2026-10-04T11:00:00.000Z"); const ended = link.end(otherActor, "2026-10-04T11:00:00.000Z");
assert.ok(ended.isSuccess); assert.ok(ended.isSuccess);
assert.equal(ended.data.active, false); assert.equal(ended.data.active, false);
assert.ok(ended.data.id.equals(link.id)); assert.ok(ended.data.id.equals(link.id));
@ -70,6 +81,7 @@ test("Retirar conserva UUID, extremos y auditoría; repetir la retirada no modif
assert.ok(ended.data.createdBy.equals(actor)); assert.ok(ended.data.createdBy.equals(actor));
assert.ok(ended.data.endedBy.getOrUndefined()?.equals(otherActor)); assert.ok(ended.data.endedBy.getOrUndefined()?.equals(otherActor));
const retry = ended.data.end(actor, "2026-10-04T12:00:00.000Z"); const retry = ended.data.end(actor, "2026-10-04T12:00:00.000Z");
assert.ok(retry.isSuccess); assert.ok(retry.isSuccess);
assert.equal(retry.data, ended.data); assert.equal(retry.data, ended.data);
assert.ok(link.active); assert.ok(link.active);
@ -79,6 +91,7 @@ test("Tipos de relación de serie protegidos, ámbito de compañía y dirección
const other = Maybe.some(UniqueID.generateNewID()); const other = Maybe.some(UniqueID.generateNewID());
let catalog = seedAssetCatalog(AssetCatalog.create()); let catalog = seedAssetCatalog(AssetCatalog.create());
const stock = catalog.definitions.find((item) => item.values.data.kind === "relation"); const stock = catalog.definitions.find((item) => item.values.data.kind === "relation");
assert.ok(stock); assert.ok(stock);
const spec = { const spec = {
kind: "relation", kind: "relation",
@ -93,30 +106,38 @@ test("Tipos de relación de serie protegidos, ámbito de compañía y dirección
inverseName: "Replicado desde", inverseName: "Replicado desde",
symmetric: false, symmetric: false,
}); });
assert.ok(custom.isSuccess); assert.ok(custom.isSuccess);
const protectedChange = catalog.save(Maybe.none(), Maybe.some(stock.id), custom.data); const protectedChange = catalog.save(Maybe.none(), Maybe.some(stock.id), custom.data);
assert.ok(!protectedChange.isSuccess); assert.ok(!protectedChange.isSuccess);
assert.equal(protectedChange.error.code, "BUILTIN_PROTECTED"); assert.equal(protectedChange.error.code, "BUILTIN_PROTECTED");
const status = catalog.setActive(scope, stock.id, false); const status = catalog.setActive(scope, stock.id, false);
assert.ok(status.isSuccess); assert.ok(status.isSuccess);
catalog = status.data; catalog = status.data;
assert.equal(catalog.active(stock, scope), false); assert.equal(catalog.active(stock, scope), false);
assert.equal(catalog.active(stock, other), true); assert.equal(catalog.active(stock, other), true);
const added = catalog.save(scope, Maybe.none(), custom.data); const added = catalog.save(scope, Maybe.none(), custom.data);
assert.ok(added.isSuccess); assert.ok(added.isSuccess);
catalog = added.data; catalog = added.data;
const item = catalog.definitions.at(-1); const item = catalog.definitions.at(-1);
assert.ok(item); assert.ok(item);
assert.equal( assert.equal(
catalog.visible(other).some((entry) => entry.id.equals(item.id)), catalog.visible(other).some((entry) => entry.id.equals(item.id)),
false false
); );
const converted = AssetDefinitionValues.create({ ...spec, name: "Replica a" }); const converted = AssetDefinitionValues.create({ ...spec, name: "Replica a" });
assert.ok(converted.isSuccess); assert.ok(converted.isSuccess);
const rejected = catalog.save(scope, Maybe.some(item.id), converted.data); const rejected = catalog.save(scope, Maybe.some(item.id), converted.data);
assert.ok(!rejected.isSuccess); assert.ok(!rejected.isSuccess);
assert.equal(rejected.error.code, "RELATION_DIRECTION_FIXED"); assert.equal(rejected.error.code, "RELATION_DIRECTION_FIXED");
const unchanged = catalog.save(scope, Maybe.some(item.id), custom.data); const unchanged = catalog.save(scope, Maybe.some(item.id), custom.data);
assert.ok(unchanged.isSuccess); assert.ok(unchanged.isSuccess);
assert.equal(unchanged.data, catalog); assert.equal(unchanged.data, catalog);
}); });

View File

@ -2,24 +2,30 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { DomainEntity } from "@repo/rdx-ddd"; import { DomainEntity } from "@repo/rdx-ddd";
import type { AssetValues } from "./value-objects/asset-values.vo"; import type { AssetValues } from "./value-objects/asset-values.vo";
export type AssetRevisionProps = Readonly<{ export type AssetRevisionProps = Readonly<{
values: AssetValues; values: AssetValues;
actorId: UniqueID; actorId: UniqueID;
recordedAt: string; recordedAt: string;
}>; }>;
export class AssetRevision extends DomainEntity<AssetRevisionProps> { export class AssetRevision extends DomainEntity<AssetRevisionProps> {
static create(props: AssetRevisionProps) { static create(props: AssetRevisionProps) {
return new AssetRevision(props); return new AssetRevision(props);
} }
static rehydrate(props: AssetRevisionProps, id: UniqueID) { static rehydrate(props: AssetRevisionProps, id: UniqueID) {
return new AssetRevision(props, id); return new AssetRevision(props, id);
} }
get values() { get values() {
return this.props.values; return this.props.values;
} }
get actorId() { get actorId() {
return this.props.actorId; return this.props.actorId;
} }
get recordedAt() { get recordedAt() {
return this.props.recordedAt; return this.props.recordedAt;
} }

View File

@ -3,6 +3,7 @@ import { z } from "zod/v4";
import { FieldTypeSchema, TemplateSectionSchema } from "./asset-definition"; import { FieldTypeSchema, TemplateSectionSchema } from "./asset-definition";
export const AssetStatusSchema = z.enum(["available", "in_use", "maintenance", "retired"]); export const AssetStatusSchema = z.enum(["available", "in_use", "maintenance", "retired"]);
export const AssetCharacteristicSchema = z.strictObject({ export const AssetCharacteristicSchema = z.strictObject({
fieldId: z.uuid(), fieldId: z.uuid(),
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
@ -12,7 +13,9 @@ export const AssetCharacteristicSchema = z.strictObject({
required: z.boolean(), required: z.boolean(),
sectionId: z.uuid().optional(), sectionId: z.uuid().optional(),
}); });
export const AssetValueSchema = z.union([z.string().max(10000), z.number().finite(), z.boolean()]); export const AssetValueSchema = z.union([z.string().max(10000), z.number().finite(), z.boolean()]);
export const AssetInputSchema = z.strictObject({ export const AssetInputSchema = z.strictObject({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
templateId: z.uuid(), templateId: z.uuid(),
@ -22,12 +25,16 @@ export const AssetInputSchema = z.strictObject({
notes: z.string().max(10000), notes: z.string().max(10000),
characteristics: z.record(z.uuid(), AssetValueSchema), characteristics: z.record(z.uuid(), AssetValueSchema),
}); });
export const AssetSpecificationSchema = z.strictObject({ export const AssetSpecificationSchema = z.strictObject({
templateId: z.uuid(), templateId: z.uuid(),
templateName: z.string().min(1).max(100), templateName: z.string().min(1).max(100),
sections: z.array(TemplateSectionSchema).max(30).optional(), sections: z.array(TemplateSectionSchema).max(30).optional(),
fields: z.array(AssetCharacteristicSchema).min(1).max(100), fields: z.array(AssetCharacteristicSchema).min(1).max(100),
}); });
export type AssetInput = z.infer<typeof AssetInputSchema>; export type AssetInput = z.infer<typeof AssetInputSchema>;
export type AssetSpecificationData = z.infer<typeof AssetSpecificationSchema>; export type AssetSpecificationData = z.infer<typeof AssetSpecificationSchema>;
export type AssetCharacteristic = z.infer<typeof AssetCharacteristicSchema>; export type AssetCharacteristic = z.infer<typeof AssetCharacteristicSchema>;

View File

@ -12,10 +12,12 @@ type AssetProps = Readonly<{
values: AssetValues; values: AssetValues;
history: readonly AssetRevision[]; history: readonly AssetRevision[];
}>; }>;
export class Asset extends AggregateRoot<AssetProps> { export class Asset extends AggregateRoot<AssetProps> {
private constructor(props: AssetProps, id?: UniqueID) { private constructor(props: AssetProps, id?: UniqueID) {
super({ ...props, history: Object.freeze([...props.history]) }, id); super({ ...props, history: Object.freeze([...props.history]) }, id);
} }
static create( static create(
companyId: UniqueID, companyId: UniqueID,
specification: AssetSpecification, specification: AssetSpecification,
@ -32,21 +34,27 @@ export class Asset extends AggregateRoot<AssetProps> {
}) })
); );
} }
static rehydrate(props: AssetProps, id: UniqueID) { static rehydrate(props: AssetProps, id: UniqueID) {
return new Asset(props, id); return new Asset(props, id);
} }
get companyId() { get companyId() {
return this.props.companyId; return this.props.companyId;
} }
get specification() { get specification() {
return this.props.specification; return this.props.specification;
} }
get values() { get values() {
return this.props.values; return this.props.values;
} }
get history() { get history() {
return this.props.history; return this.props.history;
} }
update( update(
values: AssetValues, values: AssetValues,
actorId: UniqueID, actorId: UniqueID,
@ -55,6 +63,7 @@ export class Asset extends AggregateRoot<AssetProps> {
if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) { if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) {
return Result.fail(new AssetCatalogError("TEMPLATE_FIXED")); return Result.fail(new AssetCatalogError("TEMPLATE_FIXED"));
} }
return Result.ok( return Result.ok(
this.values.equals(values) this.values.equals(values)
? this ? this

View File

@ -11,6 +11,7 @@ const company = UniqueID.generateNewID();
const actor = UniqueID.generateNewID(); const actor = UniqueID.generateNewID();
const templateId = UniqueID.generateNewID().toPrimitive(); const templateId = UniqueID.generateNewID().toPrimitive();
const ids = Array.from({ length: 5 }, () => UniqueID.generateNewID().toPrimitive()); const ids = Array.from({ length: 5 }, () => UniqueID.generateNewID().toPrimitive());
function specification(required = false) { function specification(required = false) {
const types = ["text", "number", "date", "boolean", "choice"] as const; const types = ["text", "number", "date", "boolean", "choice"] as const;
const result = AssetSpecification.create({ const result = AssetSpecification.create({
@ -25,9 +26,12 @@ function specification(required = false) {
required: required && index === 3, required: required && index === 3,
})), })),
}); });
assert.ok(result.isSuccess); assert.ok(result.isSuccess);
return result.data; return result.data;
} }
function input(characteristics: AssetInput["characteristics"] = {}): AssetInput { function input(characteristics: AssetInput["characteristics"] = {}): AssetInput {
return { return {
name: " Equipo ", name: " Equipo ",
@ -38,13 +42,16 @@ function input(characteristics: AssetInput["characteristics"] = {}): AssetInput
characteristics, characteristics,
}; };
} }
test("Características opcionales, tipos y límites; cero y falso son valores presentes", () => { test("Características opcionales, tipos y límites; cero y falso son valores presentes", () => {
const spec = specification(); const spec = specification();
assert.ok(AssetValues.create(input(), spec).isSuccess); assert.ok(AssetValues.create(input(), spec).isSuccess);
const typed = Object.fromEntries( const typed = Object.fromEntries(
ids.map((id, index) => [id, ["texto", 0, "2026-02-28", false, "A"][index] ?? ""]) ids.map((id, index) => [id, ["texto", 0, "2026-02-28", false, "A"][index] ?? ""])
); );
const valid = AssetValues.create(input(typed), spec); const valid = AssetValues.create(input(typed), spec);
assert.ok(valid.isSuccess); assert.ok(valid.isSuccess);
assert.equal(valid.data.toPrimitive().name, "Equipo"); assert.equal(valid.data.toPrimitive().name, "Equipo");
assert.equal( assert.equal(
@ -59,8 +66,10 @@ test("Características opcionales, tipos y límites; cero y falso son valores pr
[4, "C"], [4, "C"],
] as const) { ] as const) {
const id = ids[index]; const id = ids[index];
assert.ok(id); assert.ok(id);
const failed = AssetValues.create(input({ [id]: value }), spec); const failed = AssetValues.create(input({ [id]: value }), spec);
assert.equal(failed.isSuccess, false); assert.equal(failed.isSuccess, false);
} }
assert.equal( assert.equal(
@ -69,8 +78,10 @@ test("Características opcionales, tipos y límites; cero y falso son valores pr
false false
); );
const required = specification(true); const required = specification(true);
assert.equal(AssetValues.create(input(), required).isSuccess, false); assert.equal(AssetValues.create(input(), required).isSuccess, false);
const boolean = ids[3]; const boolean = ids[3];
assert.ok(boolean); assert.ok(boolean);
assert.ok(AssetValues.create(input({ [boolean]: false }), required).isSuccess); assert.ok(AssetValues.create(input({ [boolean]: false }), required).isSuccess);
assert.equal( assert.equal(
@ -82,18 +93,24 @@ test("Características opcionales, tipos y límites; cero y falso son valores pr
test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes", () => { test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes", () => {
const spec = specification(); const spec = specification();
const first = AssetValues.create(input(), spec); const first = AssetValues.create(input(), spec);
assert.ok(first.isSuccess); assert.ok(first.isSuccess);
const created = Asset.create(company, spec, first.data, actor, "2026-10-04T12:00:00.000Z"); const created = Asset.create(company, spec, first.data, actor, "2026-10-04T12:00:00.000Z");
assert.ok(created.isSuccess); assert.ok(created.isSuccess);
const asset = created.data; const asset = created.data;
assert.equal(asset.id.toPrimitive().charAt(14), "7"); assert.equal(asset.id.toPrimitive().charAt(14), "7");
assert.equal(asset.history[0]?.id.toPrimitive().charAt(14), "7"); assert.equal(asset.history[0]?.id.toPrimitive().charAt(14), "7");
const same = asset.update(first.data, actor, "2026-10-04T13:00:00.000Z"); const same = asset.update(first.data, actor, "2026-10-04T13:00:00.000Z");
assert.ok(same.isSuccess); assert.ok(same.isSuccess);
assert.equal(same.data, asset); assert.equal(same.data, asset);
const next = AssetValues.create({ ...input(), status: "in_use" }, spec); const next = AssetValues.create({ ...input(), status: "in_use" }, spec);
assert.ok(next.isSuccess); assert.ok(next.isSuccess);
const changed = asset.update(next.data, actor, "2026-10-04T13:00:00.000Z"); const changed = asset.update(next.data, actor, "2026-10-04T13:00:00.000Z");
assert.ok(changed.isSuccess); assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(asset.id)); assert.ok(changed.data.id.equals(asset.id));
assert.equal(changed.data.history.length, 2); assert.equal(changed.data.history.length, 2);

View File

@ -12,40 +12,51 @@ export const BrandInputSchema = z.strictObject({
active: z.boolean(), active: z.boolean(),
logoSvg: BrandLogoSchema.nullable().optional(), logoSvg: BrandLogoSchema.nullable().optional(),
}); });
export type BrandInput = z.infer<typeof BrandInputSchema>; export type BrandInput = z.infer<typeof BrandInputSchema>;
export class BrandError extends Error { export class BrandError extends Error {
constructor(public readonly code: string) { constructor(public readonly code: string) {
super(code); super(code);
} }
} }
export type BrandProps = Readonly<{ export type BrandProps = Readonly<{
name: Name; name: Name;
active: boolean; active: boolean;
builtin: boolean; builtin: boolean;
logo: Maybe<BrandLogo>; logo: Maybe<BrandLogo>;
}>; }>;
export class Brand extends AggregateRoot<BrandProps> { export class Brand extends AggregateRoot<BrandProps> {
private constructor(props: BrandProps, id?: UniqueID) { private constructor(props: BrandProps, id?: UniqueID) {
super(Object.freeze({ ...props }), id); super(Object.freeze({ ...props }), id);
} }
static create(props: BrandProps): Result<Brand, BrandError> { static create(props: BrandProps): Result<Brand, BrandError> {
return Result.ok(new Brand(props)); return Result.ok(new Brand(props));
} }
static rehydrate(props: BrandProps, id: UniqueID) { static rehydrate(props: BrandProps, id: UniqueID) {
return new Brand(props, id); return new Brand(props, id);
} }
get name() { get name() {
return this.props.name; return this.props.name;
} }
get active() { get active() {
return this.props.active; return this.props.active;
} }
get builtin() { get builtin() {
return this.props.builtin; return this.props.builtin;
} }
get logo() { get logo() {
return this.props.logo; return this.props.logo;
} }
update(props: BrandProps): Result<Brand, BrandError> { update(props: BrandProps): Result<Brand, BrandError> {
if (props.builtin !== this.builtin || (this.builtin && !this.name.equals(props.name))) { if (props.builtin !== this.builtin || (this.builtin && !this.name.equals(props.name))) {
return Result.fail(new BrandError("BUILTIN_PROTECTED")); return Result.fail(new BrandError("BUILTIN_PROTECTED"));
@ -57,9 +68,11 @@ export class Brand extends AggregateRoot<BrandProps> {
) { ) {
return Result.ok(this); return Result.ok(this);
} }
return Result.ok(new Brand(props, this.id)); return Result.ok(new Brand(props, this.id));
} }
} }
export const BUILTIN_BRANDS = [ export const BUILTIN_BRANDS = [
"Acer", "Acer",
"Apple", "Apple",

View File

@ -17,16 +17,21 @@ const logoSvg =
function brand(name = "LG", builtin = true) { function brand(name = "LG", builtin = true) {
const props = mapBrandInput({ name, active: true }, builtin); const props = mapBrandInput({ name, active: true }, builtin);
assert.ok(props.isSuccess); assert.ok(props.isSuccess);
const created = Brand.create(props.data); const created = Brand.create(props.data);
assert.ok(created.isSuccess); assert.ok(created.isSuccess);
return created.data; return created.data;
} }
test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempotencia", () => { test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempotencia", () => {
for (const name of ["", " ", "x".repeat(101)]) { for (const name of ["", " ", "x".repeat(101)]) {
assert.equal(mapBrandInput({ name, active: true }).isSuccess, false); assert.equal(mapBrandInput({ name, active: true }).isSuccess, false);
} }
const original = brand(" LG "); const original = brand(" LG ");
assert.equal(original.id.toPrimitive()[14], "7"); assert.equal(original.id.toPrimitive()[14], "7");
assert.equal(original.name.toPrimitive(), "LG"); assert.equal(original.name.toPrimitive(), "LG");
const same = original.update({ const same = original.update({
@ -35,6 +40,7 @@ test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempo
builtin: true, builtin: true,
logo: original.logo, logo: original.logo,
}); });
assert.ok(same.isSuccess); assert.ok(same.isSuccess);
assert.equal(same.data, original); assert.equal(same.data, original);
const inactive = original.update({ const inactive = original.update({
@ -43,24 +49,29 @@ test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempo
builtin: true, builtin: true,
logo: original.logo, logo: original.logo,
}); });
assert.ok(inactive.isSuccess); assert.ok(inactive.isSuccess);
assert.equal(inactive.data.id.toPrimitive(), original.id.toPrimitive()); assert.equal(inactive.data.id.toPrimitive(), original.id.toPrimitive());
assert.equal(inactive.data.active, false); assert.equal(inactive.data.active, false);
const renamed = mapBrandInput({ name: "Otra", active: true }, true); const renamed = mapBrandInput({ name: "Otra", active: true }, true);
assert.ok(renamed.isSuccess); assert.ok(renamed.isSuccess);
const blocked = original.update(renamed.data); const blocked = original.update(renamed.data);
assert.equal(blocked.isSuccess, false); assert.equal(blocked.isSuccess, false);
if (!blocked.isSuccess) { if (!blocked.isSuccess) {
assert.equal(blocked.error.code, "BUILTIN_PROTECTED"); assert.equal(blocked.error.code, "BUILTIN_PROTECTED");
} }
assert.equal(original.update({ ...renamed.data, builtin: false }).isSuccess, false); assert.equal(original.update({ ...renamed.data, builtin: false }).isSuccess, false);
const custom = brand("Propia", false); const custom = brand("Propia", false);
assert.ok(custom.update({ ...renamed.data, builtin: false }).isSuccess); assert.ok(custom.update({ ...renamed.data, builtin: false }).isSuccess);
}); });
test("Marcas: SVG estáticos con transparencia, límites y contenido activo rechazado", () => { test("Marcas: SVG estáticos con transparencia, límites y contenido activo rechazado", () => {
assert.ok(BrandLogoSchema.safeParse(logoSvg).success); assert.ok(BrandLogoSchema.safeParse(logoSvg).success);
const prefix = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'; const prefix = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">';
for (const svg of [ for (const svg of [
"", "",
"<svg>", "<svg>",
@ -88,6 +99,7 @@ test("Marcas: SVG estáticos con transparencia, límites y contenido activo rech
assert.equal(BrandLogo.create(svg).isSuccess, false); assert.equal(BrandLogo.create(svg).isSuccess, false);
} }
const padding = BRAND_LOGO_MAX_BYTES - new TextEncoder().encode(logoSvg).length - 7; const padding = BRAND_LOGO_MAX_BYTES - new TextEncoder().encode(logoSvg).length - 7;
assert.ok( assert.ok(
BrandLogoSchema.safeParse(logoSvg.replace("</svg>", `<!--${"x".repeat(padding)}--></svg>`)) BrandLogoSchema.safeParse(logoSvg.replace("</svg>", `<!--${"x".repeat(padding)}--></svg>`))
.success .success
@ -104,6 +116,7 @@ test("Marcas: los 23 fabricantes tienen SVG local validado; Clónico no represen
assert.equal(new Set(builtinLogos.map((item) => item.name)).size, builtinLogos.length); assert.equal(new Set(builtinLogos.map((item) => item.name)).size, builtinLogos.length);
for (const name of BUILTIN_BRANDS.filter((name) => name !== "Clónico")) { for (const name of BUILTIN_BRANDS.filter((name) => name !== "Clónico")) {
const logo = builtinLogos.find((item) => item.name === name); const logo = builtinLogos.find((item) => item.name === name);
assert.ok(logo, name); assert.ok(logo, name);
assert.ok(BrandLogoSchema.safeParse(logo.svg).success, name); assert.ok(BrandLogoSchema.safeParse(logo.svg).success, name);
assert.ok(logo.source.startsWith("https://")); assert.ok(logo.source.startsWith("https://"));
@ -113,15 +126,19 @@ test("Marcas: los 23 fabricantes tienen SVG local validado; Clónico no represen
test("Marcas: añadir, sustituir y quitar SVG conserva identidad, origen y actualización idempotente", () => { test("Marcas: añadir, sustituir y quitar SVG conserva identidad, origen y actualización idempotente", () => {
const original = brand(); const original = brand();
const withLogo = mapBrandInput({ name: "LG", active: true, logoSvg }, true); const withLogo = mapBrandInput({ name: "LG", active: true, logoSvg }, true);
assert.ok(withLogo.isSuccess); assert.ok(withLogo.isSuccess);
const updated = original.update(withLogo.data); const updated = original.update(withLogo.data);
assert.ok(updated.isSuccess); assert.ok(updated.isSuccess);
assert.ok(updated.data.id.equals(original.id)); assert.ok(updated.data.id.equals(original.id));
assert.equal(updated.data.logo.getOrUndefined()?.toPrimitive(), logoSvg); assert.equal(updated.data.logo.getOrUndefined()?.toPrimitive(), logoSvg);
const same = updated.data.update(withLogo.data); const same = updated.data.update(withLogo.data);
assert.ok(same.isSuccess); assert.ok(same.isSuccess);
assert.equal(same.data, updated.data); assert.equal(same.data, updated.data);
const mapper = new BrandPersistenceMapper(); const mapper = new BrandPersistenceMapper();
assert.equal( assert.equal(
mapper.toDomain(mapper.toPersistence(updated.data)).logo.getOrUndefined()?.toPrimitive(), mapper.toDomain(mapper.toPersistence(updated.data)).logo.getOrUndefined()?.toPrimitive(),
logoSvg logoSvg
@ -131,14 +148,17 @@ test("Marcas: añadir, sustituir y quitar SVG conserva identidad, origen y actua
{ name: "LG", active: true, logoSvg: logoSvg.replace("#005CAB", "#000000") }, { name: "LG", active: true, logoSvg: logoSvg.replace("#005CAB", "#000000") },
true true
); );
assert.ok(replacement.isSuccess); assert.ok(replacement.isSuccess);
const replaced = updated.data.update(replacement.data); const replaced = updated.data.update(replacement.data);
assert.ok(replaced.isSuccess); assert.ok(replaced.isSuccess);
assert.equal( assert.equal(
replaced.data.logo.getOrUndefined()?.toPrimitive(), replaced.data.logo.getOrUndefined()?.toPrimitive(),
logoSvg.replace("#005CAB", "#000000") logoSvg.replace("#005CAB", "#000000")
); );
const removed = replaced.data.update({ ...withLogo.data, logo: original.logo }); const removed = replaced.data.update({ ...withLogo.data, logo: original.logo });
assert.ok(removed.isSuccess); assert.ok(removed.isSuccess);
assert.equal(removed.data.logo.getOrUndefined(), undefined); assert.equal(removed.data.logo.getOrUndefined(), undefined);
assert.ok(removed.data.id.equals(original.id)); assert.ok(removed.data.id.equals(original.id));
@ -148,6 +168,7 @@ test("Marcas: mapper bidireccional conserva identidad y rechaza persistencia inv
const original = brand(); const original = brand();
const row = mapper.toPersistence(original); const row = mapper.toPersistence(original);
const restored = mapper.toDomain({ ...row, active: 1, builtin: 1 }); const restored = mapper.toDomain({ ...row, active: 1, builtin: 1 });
assert.ok(restored.id.equals(original.id)); assert.ok(restored.id.equals(original.id));
assert.ok(restored.name.equals(original.name)); assert.ok(restored.name.equals(original.name));
assert.throws(() => mapper.toDomain({ ...row, active: 2 })); assert.throws(() => mapper.toDomain({ ...row, active: 2 }));
@ -160,38 +181,48 @@ test("Marcas: unicidad, edición y eliminación de personalizadas, con protecci
list: async () => brands, list: async () => brands,
remove: async (action) => { remove: async (action) => {
const result = action(brands); const result = action(brands);
if (result.isSuccess) { if (result.isSuccess) {
brands = brands.filter((item) => !item.id.equals(result.data.id)); brands = brands.filter((item) => !item.id.equals(result.data.id));
} }
return result; return result;
}, },
save: async (action) => { save: async (action) => {
const result = action(brands); const result = action(brands);
if (result.isSuccess) { if (result.isSuccess) {
brands = [...brands.filter((item) => !item.id.equals(result.data.id)), result.data]; brands = [...brands.filter((item) => !item.id.equals(result.data.id)), result.data];
} }
return result; return result;
}, },
}; };
const useCases = new BrandUseCases(repository); const useCases = new BrandUseCases(repository);
const existing = brands[1]; const existing = brands[1];
assert.ok(existing); assert.ok(existing);
const disabled = await useCases.save(existing.id.toPrimitive(), { const disabled = await useCases.save(existing.id.toPrimitive(), {
name: "Propia", name: "Propia",
active: false, active: false,
}); });
assert.ok(disabled.isSuccess); assert.ok(disabled.isSuccess);
const duplicate = await useCases.save(undefined, { name: " PROPIA ", active: true }); const duplicate = await useCases.save(undefined, { name: " PROPIA ", active: true });
assert.equal(duplicate.isSuccess, false); assert.equal(duplicate.isSuccess, false);
if (!duplicate.isSuccess) { if (!duplicate.isSuccess) {
assert.equal(duplicate.error.code, "NAME_EXISTS"); assert.equal(duplicate.error.code, "NAME_EXISTS");
} }
const renamed = await useCases.save(existing.id.toPrimitive(), { name: "Nueva", active: true }); const renamed = await useCases.save(existing.id.toPrimitive(), { name: "Nueva", active: true });
assert.ok(renamed.isSuccess); assert.ok(renamed.isSuccess);
assert.equal(renamed.data.id.toPrimitive(), existing.id.toPrimitive()); assert.equal(renamed.data.id.toPrimitive(), existing.id.toPrimitive());
const invalid = await useCases.save("invalid", { name: "Nueva", active: true }); const invalid = await useCases.save("invalid", { name: "Nueva", active: true });
assert.equal(invalid.isSuccess, false); assert.equal(invalid.isSuccess, false);
const builtin = brands.find((item) => item.builtin); const builtin = brands.find((item) => item.builtin);
assert.ok(builtin); assert.ok(builtin);
for (const [id, code] of [ for (const [id, code] of [
["invalid", "INVALID_REQUEST"], ["invalid", "INVALID_REQUEST"],
@ -200,6 +231,7 @@ test("Marcas: unicidad, edición y eliminación de personalizadas, con protecci
]) { ]) {
assert.ok(id); assert.ok(id);
const result = await useCases.remove(id); const result = await useCases.remove(id);
assert.equal(result.isSuccess, false); assert.equal(result.isSuccess, false);
if (!result.isSuccess) { if (!result.isSuccess) {
assert.equal(result.error.code, code); assert.equal(result.error.code, code);
@ -207,10 +239,12 @@ test("Marcas: unicidad, edición y eliminación de personalizadas, con protecci
assert.equal(brands.length, 2); assert.equal(brands.length, 2);
} }
const removed = await useCases.remove(existing.id.toPrimitive().toUpperCase()); const removed = await useCases.remove(existing.id.toPrimitive().toUpperCase());
assert.ok(removed.isSuccess); assert.ok(removed.isSuccess);
assert.ok(removed.data.id.equals(existing.id)); assert.ok(removed.data.id.equals(existing.id));
assert.deepEqual(brands, [builtin]); assert.deepEqual(brands, [builtin]);
const repeated = await useCases.remove(existing.id.toPrimitive()); const repeated = await useCases.remove(existing.id.toPrimitive());
assert.equal(repeated.isSuccess, false); assert.equal(repeated.isSuccess, false);
if (!repeated.isSuccess) { if (!repeated.isSuccess) {
assert.equal(repeated.error.code, "BRAND_NOT_FOUND"); assert.equal(repeated.error.code, "BRAND_NOT_FOUND");

View File

@ -2,24 +2,30 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { DomainEntity } from "@repo/rdx-ddd"; import { DomainEntity } from "@repo/rdx-ddd";
import type { SupportValues } from "./value-objects/support-values.vo"; import type { SupportValues } from "./value-objects/support-values.vo";
export type SupportConnectionRevisionProps = Readonly<{ export type SupportConnectionRevisionProps = Readonly<{
values: SupportValues; values: SupportValues;
actorId: UniqueID; actorId: UniqueID;
recordedAt: string; recordedAt: string;
}>; }>;
export class SupportConnectionRevision extends DomainEntity<SupportConnectionRevisionProps> { export class SupportConnectionRevision extends DomainEntity<SupportConnectionRevisionProps> {
static create(props: SupportConnectionRevisionProps) { static create(props: SupportConnectionRevisionProps) {
return new SupportConnectionRevision(Object.freeze({ ...props })); return new SupportConnectionRevision(Object.freeze({ ...props }));
} }
static rehydrate(props: SupportConnectionRevisionProps, id: UniqueID) { static rehydrate(props: SupportConnectionRevisionProps, id: UniqueID) {
return new SupportConnectionRevision(Object.freeze({ ...props }), id); return new SupportConnectionRevision(Object.freeze({ ...props }), id);
} }
get values() { get values() {
return this.props.values; return this.props.values;
} }
get actorId() { get actorId() {
return this.props.actorId; return this.props.actorId;
} }
get recordedAt() { get recordedAt() {
return this.props.recordedAt; return this.props.recordedAt;
} }

View File

@ -13,10 +13,12 @@ type SupportConnectionProps = Readonly<{
values: SupportValues; values: SupportValues;
history: readonly SupportConnectionRevision[]; history: readonly SupportConnectionRevision[];
}>; }>;
export class SupportConnection extends AggregateRoot<SupportConnectionProps> { export class SupportConnection extends AggregateRoot<SupportConnectionProps> {
private constructor(props: SupportConnectionProps, id?: UniqueID) { private constructor(props: SupportConnectionProps, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
} }
static create( static create(
companyId: UniqueID, companyId: UniqueID,
assetId: UniqueID, assetId: UniqueID,
@ -27,6 +29,7 @@ export class SupportConnection extends AggregateRoot<SupportConnectionProps> {
if (!z.iso.datetime().safeParse(recordedAt).success) { if (!z.iso.datetime().safeParse(recordedAt).success) {
return Result.fail(new SupportError("INVALID_REQUEST")); return Result.fail(new SupportError("INVALID_REQUEST"));
} }
return Result.ok( return Result.ok(
new SupportConnection({ new SupportConnection({
companyId, companyId,
@ -36,21 +39,27 @@ export class SupportConnection extends AggregateRoot<SupportConnectionProps> {
}) })
); );
} }
static rehydrate(props: SupportConnectionProps, id: UniqueID) { static rehydrate(props: SupportConnectionProps, id: UniqueID) {
return new SupportConnection(props, id); return new SupportConnection(props, id);
} }
get assetId() { get assetId() {
return this.props.assetId; return this.props.assetId;
} }
get companyId() { get companyId() {
return this.props.companyId; return this.props.companyId;
} }
get values() { get values() {
return this.props.values; return this.props.values;
} }
get history() { get history() {
return this.props.history; return this.props.history;
} }
update( update(
values: SupportValues, values: SupportValues,
actorId: UniqueID, actorId: UniqueID,
@ -65,6 +74,7 @@ export class SupportConnection extends AggregateRoot<SupportConnectionProps> {
) { ) {
return Result.fail(new SupportError("INVALID_REQUEST")); return Result.fail(new SupportError("INVALID_REQUEST"));
} }
return Result.ok( return Result.ok(
new SupportConnection( new SupportConnection(
{ {

View File

@ -6,31 +6,41 @@ import type { SupportToolInput } from "./support-values";
import { SupportError, SupportToolInputSchema } from "./support-values"; import { SupportError, SupportToolInputSchema } from "./support-values";
type Props = Readonly<{ name: Name; active: boolean; builtin: boolean }>; type Props = Readonly<{ name: Name; active: boolean; builtin: boolean }>;
export class SupportTool extends AggregateRoot<Props> { export class SupportTool extends AggregateRoot<Props> {
private constructor(props: Props, id?: UniqueID) { private constructor(props: Props, id?: UniqueID) {
super(Object.freeze({ ...props }), id); super(Object.freeze({ ...props }), id);
} }
static create(input: SupportToolInput, builtin = false): Result<SupportTool, SupportError> { static create(input: SupportToolInput, builtin = false): Result<SupportTool, SupportError> {
const name = Name.create(input.name, 100); const name = Name.create(input.name, 100);
if (!(SupportToolInputSchema.safeParse(input).success && name.isSuccess)) { if (!(SupportToolInputSchema.safeParse(input).success && name.isSuccess)) {
return Result.fail(new SupportError("INVALID_REQUEST")); return Result.fail(new SupportError("INVALID_REQUEST"));
} }
return Result.ok(new SupportTool({ name: name.data, active: input.active, builtin })); return Result.ok(new SupportTool({ name: name.data, active: input.active, builtin }));
} }
static rehydrate(props: Props, id: UniqueID) { static rehydrate(props: Props, id: UniqueID) {
return new SupportTool(props, id); return new SupportTool(props, id);
} }
get name() { get name() {
return this.props.name; return this.props.name;
} }
get active() { get active() {
return this.props.active; return this.props.active;
} }
get builtin() { get builtin() {
return this.props.builtin; return this.props.builtin;
} }
update(input: SupportToolInput): Result<SupportTool, SupportError> { update(input: SupportToolInput): Result<SupportTool, SupportError> {
const name = Name.create(input.name, 100); const name = Name.create(input.name, 100);
if (!(SupportToolInputSchema.safeParse(input).success && name.isSuccess)) { if (!(SupportToolInputSchema.safeParse(input).success && name.isSuccess)) {
return Result.fail(new SupportError("INVALID_REQUEST")); return Result.fail(new SupportError("INVALID_REQUEST"));
} }
@ -40,6 +50,7 @@ export class SupportTool extends AggregateRoot<Props> {
if (this.name.equals(name.data) && this.active === input.active) { if (this.name.equals(name.data) && this.active === input.active) {
return Result.ok(this); return Result.ok(this);
} }
return Result.ok( return Result.ok(
new SupportTool({ ...this.props, name: name.data, active: input.active }, this.id) new SupportTool({ ...this.props, name: name.data, active: input.active }, this.id)
); );

View File

@ -1,4 +1,5 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
export const SupportValuesSchema = z export const SupportValuesSchema = z
.strictObject({ .strictObject({
toolId: z.uuid(), toolId: z.uuid(),
@ -9,12 +10,16 @@ export const SupportValuesSchema = z
notes: z.string().trim().max(2000), notes: z.string().trim().max(2000),
}) })
.refine((value) => !value.preferred || value.state === "available"); .refine((value) => !value.preferred || value.state === "available");
export type SupportInput = z.infer<typeof SupportValuesSchema>; export type SupportInput = z.infer<typeof SupportValuesSchema>;
export const SupportToolInputSchema = z.strictObject({ export const SupportToolInputSchema = z.strictObject({
name: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(100),
active: z.boolean(), active: z.boolean(),
}); });
export type SupportToolInput = z.infer<typeof SupportToolInputSchema>; export type SupportToolInput = z.infer<typeof SupportToolInputSchema>;
export class SupportError extends Error { export class SupportError extends Error {
constructor(public readonly code: string) { constructor(public readonly code: string) {
super(code); super(code);

View File

@ -15,6 +15,7 @@ const company = UniqueID.generateNewID();
const asset = UniqueID.generateNewID(); const asset = UniqueID.generateNewID();
const actor = UniqueID.generateNewID(); const actor = UniqueID.generateNewID();
const toolResult = SupportTool.create({ name: "AnyDesk", active: true }, true); const toolResult = SupportTool.create({ name: "AnyDesk", active: true }, true);
assert.ok(toolResult.isSuccess); assert.ok(toolResult.isSuccess);
const tool = toolResult.data; const tool = toolResult.data;
const input = { const input = {
@ -25,8 +26,10 @@ const input = {
state: "available" as const, state: "available" as const,
notes: "", notes: "",
}; };
function connection() { function connection() {
const values = SupportValues.create(input); const values = SupportValues.create(input);
assert.ok(values.isSuccess); assert.ok(values.isSuccess);
const result = SupportConnection.create( const result = SupportConnection.create(
company, company,
@ -35,20 +38,27 @@ function connection() {
actor, actor,
"2026-10-07T10:00:00.000Z" "2026-10-07T10:00:00.000Z"
); );
assert.ok(result.isSuccess); assert.ok(result.isSuccess);
return result.data; return result.data;
} }
test("asistencia: UUID v7, historial, cambios semánticos y fechas", () => { test("asistencia: UUID v7, historial, cambios semánticos y fechas", () => {
const item = connection(); const item = connection();
assert.equal(item.id.toPrimitive()[14], "7"); assert.equal(item.id.toPrimitive()[14], "7");
assert.equal(item.history[0]?.id.toPrimitive()[14], "7"); assert.equal(item.history[0]?.id.toPrimitive()[14], "7");
const same = item.update(item.values, actor, "2026-10-07T11:00:00.000Z"); const same = item.update(item.values, actor, "2026-10-07T11:00:00.000Z");
assert.ok(same.isSuccess); assert.ok(same.isSuccess);
assert.equal(same.data, item); assert.equal(same.data, item);
const values = SupportValues.create({ ...input, requiresVpn: true }); const values = SupportValues.create({ ...input, requiresVpn: true });
assert.ok(values.isSuccess); assert.ok(values.isSuccess);
assert.equal(item.update(values.data, actor, "2026-10-06T10:00:00.000Z").isSuccess, false); assert.equal(item.update(values.data, actor, "2026-10-06T10:00:00.000Z").isSuccess, false);
const changed = item.update(values.data, actor, "2026-10-07T11:00:00.000Z"); const changed = item.update(values.data, actor, "2026-10-07T11:00:00.000Z");
assert.ok(changed.isSuccess); assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(item.id)); assert.ok(changed.data.id.equals(item.id));
assert.equal(changed.data.history.length, 2); assert.equal(changed.data.history.length, 2);
@ -75,12 +85,16 @@ test("asistencia: varios métodos, duplicados, preferido único y activo retirad
actorNames: new Map(), actorNames: new Map(),
}; };
const duplicate = mapSupportInput(input, context); const duplicate = mapSupportInput(input, context);
assert.equal(duplicate.isSuccess, false); assert.equal(duplicate.isSuccess, false);
const distinct = mapSupportInput({ ...input, destination: "otro" }, context); const distinct = mapSupportInput({ ...input, destination: "otro" }, context);
assert.ok(distinct.isSuccess); assert.ok(distinct.isSuccess);
const preferred = SupportValues.create({ ...input, preferred: true }); const preferred = SupportValues.create({ ...input, preferred: true });
assert.ok(preferred.isSuccess); assert.ok(preferred.isSuccess);
const updated = first.update(preferred.data, actor, "2026-10-07T11:00:00.000Z"); const updated = first.update(preferred.data, actor, "2026-10-07T11:00:00.000Z");
assert.ok(updated.isSuccess); assert.ok(updated.isSuccess);
assert.equal( assert.equal(
mapSupportInput( mapSupportInput(
@ -98,6 +112,7 @@ test("asistencia: varios métodos, duplicados, preferido único y activo retirad
test("herramientas: protección de serie y desactivación sin perder referencias", () => { test("herramientas: protección de serie y desactivación sin perder referencias", () => {
assert.equal(tool.update({ name: "Otra", active: true }).isSuccess, false); assert.equal(tool.update({ name: "Otra", active: true }).isSuccess, false);
const disabled = tool.update({ name: "AnyDesk", active: false }); const disabled = tool.update({ name: "AnyDesk", active: false });
assert.ok(disabled.isSuccess); assert.ok(disabled.isSuccess);
const context = { const context = {
connections: [], connections: [],
@ -105,6 +120,7 @@ test("herramientas: protección de serie y desactivación sin perder referencias
assetRetired: false, assetRetired: false,
actorNames: new Map(), actorNames: new Map(),
}; };
assert.equal(mapSupportInput(input, context).isSuccess, false); assert.equal(mapSupportInput(input, context).isSuccess, false);
assert.ok(mapSupportInput({ ...input, state: "retired" }, context, connection()).isSuccess); assert.ok(mapSupportInput({ ...input, state: "retired" }, context, connection()).isSuccess);
}); });
@ -113,6 +129,7 @@ test("asistencia: mapper bidireccional y rechazo de historial inconsistente", ()
const mapper = new SupportPersistenceMapper(); const mapper = new SupportPersistenceMapper();
const rows = mapper.toPersistence(item); const rows = mapper.toPersistence(item);
const read = mapper.toDomain(rows.root, rows.revisions); const read = mapper.toDomain(rows.root, rows.revisions);
assert.ok(read.id.equals(item.id)); assert.ok(read.id.equals(item.id));
assert.ok(read.values.equals(item.values)); assert.ok(read.values.equals(item.values));
assert.throws(() => mapper.toDomain(rows.root, [])); assert.throws(() => mapper.toDomain(rows.root, []));

View File

@ -7,14 +7,18 @@ export class TemplateSection extends DomainEntity<{ name: Name; description: str
static rehydrate(input: TemplateSectionData) { static rehydrate(input: TemplateSectionData) {
const id = UniqueID.create(input.id); const id = UniqueID.create(input.id);
const name = Name.create(input.name, 100); const name = Name.create(input.name, 100);
if (!(id.isSuccess && name.isSuccess)) { if (!(id.isSuccess && name.isSuccess)) {
throw new Error("Sección de plantilla persistida inconsistente."); throw new Error("Sección de plantilla persistida inconsistente.");
} }
return new TemplateSection({ name: name.data, description: input.description }, id.data); return new TemplateSection({ name: name.data, description: input.description }, id.data);
} }
get name() { get name() {
return this.props.name; return this.props.name;
} }
get description() { get description() {
return this.props.description; return this.props.description;
} }

View File

@ -36,9 +36,12 @@ const template = {
sections: [section, otherSection], sections: [section, otherSection],
fields: [{ fieldId, required: false, sectionId: section.id }], fields: [{ fieldId, required: false, sectionId: section.id }],
}; };
function valid(input = template) { function valid(input = template) {
const result = AssetDefinitionValues.create(input); const result = AssetDefinitionValues.create(input);
assert.ok(result.isSuccess); assert.ok(result.isSuccess);
return result.data; return result.data;
} }
@ -72,6 +75,7 @@ test("secciones: nombres, identidades, referencias y un solo nivel", () => {
test("secciones: igualdad, orden, identidad y persistencia JSON", () => { test("secciones: igualdad, orden, identidad y persistencia JSON", () => {
const first = valid(); const first = valid();
assert.ok(first.equals(valid())); assert.ok(first.equals(valid()));
assert.equal(first.equals(valid({ ...template, sections: [otherSection, section] })), false); assert.equal(first.equals(valid({ ...template, sections: [otherSection, section] })), false);
assert.equal( assert.equal(
@ -84,10 +88,12 @@ test("secciones: igualdad, orden, identidad y persistencia JSON", () => {
false false
); );
const definition = AssetDefinition.create(Maybe.none(), first, true); const definition = AssetDefinition.create(Maybe.none(), first, true);
assert.equal(definition.sections[0]?.id.toPrimitive(), section.id); assert.equal(definition.sections[0]?.id.toPrimitive(), section.id);
assert.equal(definition.sections[0]?.name.toPrimitive(), "Hardware"); assert.equal(definition.sections[0]?.name.toPrimitive(), "Hardware");
const parsed = AssetDefinitionSchema.parse(JSON.parse(JSON.stringify(first.data))); const parsed = AssetDefinitionSchema.parse(JSON.parse(JSON.stringify(first.data)));
const restored = AssetDefinitionValues.create(parsed); const restored = AssetDefinitionValues.create(parsed);
assert.ok(restored.isSuccess && first.equals(restored.data)); assert.ok(restored.isSuccess && first.equals(restored.data));
assert.ok(Object.isFrozen(first.data.kind === "template" ? first.data.sections : [])); assert.ok(Object.isFrozen(first.data.kind === "template" ? first.data.sections : []));
}); });
@ -101,26 +107,35 @@ test("secciones: plantillas globales y locales conservan separación y UUID al e
unit: "", unit: "",
choices: [], choices: [],
}); });
assert.ok(field.isSuccess); assert.ok(field.isSuccess);
const root = AssetCatalog.create().save(Maybe.none(), Maybe.none(), field.data); const root = AssetCatalog.create().save(Maybe.none(), Maybe.none(), field.data);
assert.ok(root.isSuccess); assert.ok(root.isSuccess);
const id = root.data.definitions[0]?.id.toPrimitive(); const id = root.data.definitions[0]?.id.toPrimitive();
assert.ok(id); assert.ok(id);
const grouped = valid({ const grouped = valid({
...template, ...template,
fields: [{ fieldId: id, required: false, sectionId: section.id }], fields: [{ fieldId: id, required: false, sectionId: section.id }],
}); });
for (const scope of [Maybe.none<UniqueID>(), Maybe.some(company)]) { for (const scope of [Maybe.none<UniqueID>(), Maybe.some(company)]) {
const saved = root.data.save(scope, Maybe.none(), grouped); const saved = root.data.save(scope, Maybe.none(), grouped);
assert.ok(saved.isSuccess); assert.ok(saved.isSuccess);
const definition = saved.data.definitions.at(-1); const definition = saved.data.definitions.at(-1);
assert.ok(definition); assert.ok(definition);
assert.equal(definition.sections[0]?.id.toPrimitive(), section.id); assert.equal(definition.sections[0]?.id.toPrimitive(), section.id);
const edited = AssetDefinitionValues.create({ ...grouped.data, name: "Editada" }); const edited = AssetDefinitionValues.create({ ...grouped.data, name: "Editada" });
assert.ok(edited.isSuccess); assert.ok(edited.isSuccess);
const updated = saved.data.save(scope, Maybe.some(definition.id), edited.data); const updated = saved.data.save(scope, Maybe.some(definition.id), edited.data);
assert.ok(updated.isSuccess); assert.ok(updated.isSuccess);
const next = updated.data.definitions.at(-1); const next = updated.data.definitions.at(-1);
assert.ok(next); assert.ok(next);
assert.equal(next.id.toPrimitive(), definition.id.toPrimitive()); assert.equal(next.id.toPrimitive(), definition.id.toPrimitive());
assert.equal(next.sections[0]?.id.toPrimitive(), section.id); assert.equal(next.sections[0]?.id.toPrimitive(), section.id);
@ -129,12 +144,15 @@ test("secciones: plantillas globales y locales conservan separación y UUID al e
const serialized: (typeof rows.definitions)[number] | undefined = rows.definitions.find( const serialized: (typeof rows.definitions)[number] | undefined = rows.definitions.find(
(item) => item.id === definition.id.toPrimitive() (item) => item.id === definition.id.toPrimitive()
); );
assert.ok(serialized?.values.kind === "template"); assert.ok(serialized?.values.kind === "template");
assert.deepEqual(serialized.values.sections, template.sections); assert.deepEqual(serialized.values.sections, template.sections);
} }
const local = root.data.save(Maybe.some(company), Maybe.none(), field.data); const local = root.data.save(Maybe.some(company), Maybe.none(), field.data);
assert.ok(!local.isSuccess); // nombre visible duplicado assert.ok(!local.isSuccess); // nombre visible duplicado
const unrelated = root.data.save(Maybe.some(company), Maybe.none(), valid()); const unrelated = root.data.save(Maybe.some(company), Maybe.none(), valid());
assert.ok(!unrelated.isSuccess); // no admite campos de otro catálogo o compañía assert.ok(!unrelated.isSuccess); // no admite campos de otro catálogo o compañía
}); });
@ -143,13 +161,16 @@ test("secciones de serie: ampliación idempotente sin cambiar disponibilidad ni
const grouped = seeded.definitions.find( const grouped = seeded.definitions.find(
(item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0 (item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0
); );
assert.ok(grouped && grouped.values.data.kind === "template"); assert.ok(grouped && grouped.values.data.kind === "template");
const { sections: omitted, ...flat } = grouped.values.data; const { sections: omitted, ...flat } = grouped.values.data;
assert.ok(omitted); assert.ok(omitted);
const oldValues = AssetDefinitionValues.create({ const oldValues = AssetDefinitionValues.create({
...flat, ...flat,
fields: flat.fields.map(({ fieldId, required }) => ({ fieldId, required })), fields: flat.fields.map(({ fieldId, required }) => ({ fieldId, required })),
}); });
assert.ok(oldValues.isSuccess); assert.ok(oldValues.isSuccess);
const old = AssetDefinition.rehydrate( const old = AssetDefinition.rehydrate(
{ {
@ -169,9 +190,11 @@ test("secciones de serie: ampliación idempotente sin cambiar disponibilidad ni
seeded.id seeded.id
); );
const disabled = catalog.setActive(Maybe.some(company), old.id, false); const disabled = catalog.setActive(Maybe.some(company), old.id, false);
assert.ok(disabled.isSuccess); assert.ok(disabled.isSuccess);
const upgraded = seedAssetCatalog(disabled.data); const upgraded = seedAssetCatalog(disabled.data);
const next = upgraded.definitions.find((item) => item.id.equals(old.id)); const next = upgraded.definitions.find((item) => item.id.equals(old.id));
assert.ok(next); assert.ok(next);
assert.equal(next.active, false); assert.equal(next.active, false);
assert.equal(upgraded.active(next, Maybe.some(company)), false); assert.equal(upgraded.active(next, Maybe.some(company)), false);
@ -189,6 +212,7 @@ test("especificación: secciones capturadas, edición posterior y obligatoriedad
const selected = catalog.definitions.find( const selected = catalog.definitions.find(
(item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0 (item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0
); );
assert.ok(selected && selected.values.data.kind === "template"); assert.ok(selected && selected.values.data.kind === "template");
const input: AssetInput = { const input: AssetInput = {
name: "Registro", name: "Registro",
@ -209,6 +233,7 @@ test("especificación: secciones capturadas, edición posterior y obligatoriedad
actorNames: new Map(), actorNames: new Map(),
}; };
const mapped = mapAssetInput(input, company, context); const mapped = mapAssetInput(input, company, context);
assert.ok(mapped.isSuccess); assert.ok(mapped.isSuccess);
assert.deepEqual(mapped.data.specification.toPrimitive().sections, selected.values.data.sections); assert.deepEqual(mapped.data.specification.toPrimitive().sections, selected.values.data.sections);
assert.deepEqual( assert.deepEqual(
@ -222,6 +247,7 @@ test("especificación: secciones capturadas, edición posterior y obligatoriedad
actor, actor,
"2026-10-09T12:00:00.000Z" "2026-10-09T12:00:00.000Z"
); );
assert.ok(created.isSuccess); assert.ok(created.isSuccess);
const flatSpec = AssetSpecification.create({ const flatSpec = AssetSpecification.create({
templateId: selected.id.toPrimitive(), templateId: selected.id.toPrimitive(),
@ -230,11 +256,14 @@ test("especificación: secciones capturadas, edición posterior y obligatoriedad
.toPrimitive() .toPrimitive()
.fields.map(({ sectionId: omitted, ...field }) => { .fields.map(({ sectionId: omitted, ...field }) => {
assert.ok(omitted); assert.ok(omitted);
return field; return field;
}), }),
}); });
assert.ok(flatSpec.isSuccess); assert.ok(flatSpec.isSuccess);
const previousValues = AssetValues.create(input, flatSpec.data); const previousValues = AssetValues.create(input, flatSpec.data);
assert.ok(previousValues.isSuccess); assert.ok(previousValues.isSuccess);
const previous = Asset.create( const previous = Asset.create(
company, company,
@ -243,13 +272,16 @@ test("especificación: secciones capturadas, edición posterior y obligatoriedad
actor, actor,
"2026-10-09T11:00:00.000Z" "2026-10-09T11:00:00.000Z"
); );
assert.ok(previous.isSuccess); assert.ok(previous.isSuccess);
const edit = mapAssetInput({ ...input, name: "Cambio" }, company, context, previous.data); const edit = mapAssetInput({ ...input, name: "Cambio" }, company, context, previous.data);
assert.ok(edit.isSuccess); assert.ok(edit.isSuccess);
assert.equal(edit.data.specification, previous.data.specification); assert.equal(edit.data.specification, previous.data.specification);
assert.equal(edit.data.specification.toPrimitive().sections, undefined); assert.equal(edit.data.specification.toPrimitive().sections, undefined);
const spec = mapped.data.specification.toPrimitive(); const spec = mapped.data.specification.toPrimitive();
const firstField = spec.fields[0]; const firstField = spec.fields[0];
assert.ok(firstField); assert.ok(firstField);
const required = AssetSpecification.create({ const required = AssetSpecification.create({
...spec, ...spec,
@ -258,6 +290,7 @@ test("especificación: secciones capturadas, edición posterior y obligatoriedad
required: field.fieldId === firstField.fieldId, required: field.fieldId === firstField.fieldId,
})), })),
}); });
assert.ok(required.isSuccess); assert.ok(required.isSuccess);
assert.equal(AssetValues.create(input, required.data).isSuccess, false); assert.equal(AssetValues.create(input, required.data).isSuccess, false);
assert.ok( assert.ok(

View File

@ -11,6 +11,7 @@ export function validTemplateSections(
) { ) {
const ids = new Set(sections.map((section) => section.id.toLowerCase())); const ids = new Set(sections.map((section) => section.id.toLowerCase()));
const names = new Set(sections.map((section) => section.name.trim().toLocaleLowerCase("es"))); const names = new Set(sections.map((section) => section.name.trim().toLocaleLowerCase("es")));
return ( return (
ids.size === sections.length && ids.size === sections.length &&
names.size === sections.length && names.size === sections.length &&
@ -26,7 +27,9 @@ export function freezeTemplateSections(sections: readonly TemplateSectionData[])
description: section.description.trim(), description: section.description.trim(),
}) })
); );
Object.freeze(frozen); Object.freeze(frozen);
return frozen; return frozen;
} }
@ -38,6 +41,7 @@ export function sameTemplateSections(
first.length === second.length && first.length === second.length &&
first.every((section, index) => { first.every((section, index) => {
const candidate = second[index]; const candidate = second[index];
return ( return (
candidate !== undefined && candidate !== undefined &&
section.id === candidate.id && section.id === candidate.id &&

View File

@ -14,15 +14,19 @@ export class AssetDefinitionValues {
) { ) {
Object.freeze(this); Object.freeze(this);
} }
static create(input: AssetDefinitionSpec): Result<AssetDefinitionValues, AssetCatalogError> { static create(input: AssetDefinitionSpec): Result<AssetDefinitionValues, AssetCatalogError> {
const parsed = AssetDefinitionSchema.safeParse(input); const parsed = AssetDefinitionSchema.safeParse(input);
const name = Name.create(input.name, 100); const name = Name.create(input.name, 100);
if (!(parsed.success && name.isSuccess)) { if (!(parsed.success && name.isSuccess)) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
const data = parsed.data; const data = parsed.data;
if (data.kind === "field") { if (data.kind === "field") {
const choices = data.choices.map((value) => value.trim()); const choices = data.choices.map((value) => value.trim());
if ( if (
(data.type === "choice" && !choices.length) || (data.type === "choice" && !choices.length) ||
(data.type !== "choice" && choices.length) || (data.type !== "choice" && choices.length) ||
@ -38,16 +42,20 @@ export class AssetDefinitionValues {
unit: data.unit.trim(), unit: data.unit.trim(),
choices, choices,
}; };
Object.freeze(values.choices); Object.freeze(values.choices);
Object.freeze(values); Object.freeze(values);
return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([]))); return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([])));
} }
if (data.kind === "relation") { if (data.kind === "relation") {
return AssetDefinitionValues.relation(data, name.data); return AssetDefinitionValues.relation(data, name.data);
} }
const ids: UniqueID[] = []; const ids: UniqueID[] = [];
for (const field of data.fields) { for (const field of data.fields) {
const id = UniqueID.create(field.fieldId); const id = UniqueID.create(field.fieldId);
if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) { if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) {
return Result.fail(new AssetCatalogError("INVALID_FIELDS")); return Result.fail(new AssetCatalogError("INVALID_FIELDS"));
} }
@ -58,8 +66,10 @@ export class AssetDefinitionValues {
required: field.required, required: field.required,
...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}), ...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}),
})); }));
fields.forEach(Object.freeze); fields.forEach(Object.freeze);
Object.freeze(fields); Object.freeze(fields);
return Result.ok( return Result.ok(
new AssetDefinitionValues( new AssetDefinitionValues(
Object.freeze({ Object.freeze({
@ -74,11 +84,14 @@ export class AssetDefinitionValues {
) )
); );
} }
private static relation(data: Extract<AssetDefinitionSpec, { kind: "relation" }>, name: Name) { private static relation(data: Extract<AssetDefinitionSpec, { kind: "relation" }>, name: Name) {
const inverse = Name.create(data.symmetric ? data.name : data.inverseName, 100); const inverse = Name.create(data.symmetric ? data.name : data.inverseName, 100);
if (!inverse.isSuccess) { if (!inverse.isSuccess) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
return Result.ok( return Result.ok(
new AssetDefinitionValues( new AssetDefinitionValues(
Object.freeze({ Object.freeze({
@ -92,9 +105,11 @@ export class AssetDefinitionValues {
) )
); );
} }
equals(other: AssetDefinitionValues): boolean { equals(other: AssetDefinitionValues): boolean {
const a = this.data; const a = this.data;
const b = other.data; const b = other.data;
if (a.kind !== b.kind || a.name !== b.name || a.description !== b.description) { if (a.kind !== b.kind || a.name !== b.name || a.description !== b.description) {
return false; return false;
} }
@ -109,6 +124,7 @@ export class AssetDefinitionValues {
if (a.kind === "relation" && b.kind === "relation") { if (a.kind === "relation" && b.kind === "relation") {
return a.inverseName === b.inverseName && a.symmetric === b.symmetric; return a.inverseName === b.inverseName && a.symmetric === b.symmetric;
} }
return ( return (
a.kind === "template" && a.kind === "template" &&
b.kind === "template" && b.kind === "template" &&

View File

@ -13,6 +13,7 @@ export const RelationshipValuesSchema = z.strictObject({
symmetric: z.boolean(), symmetric: z.boolean(),
notes: z.string().max(10000), notes: z.string().max(10000),
}); });
export type RelationshipValuesData = z.infer<typeof RelationshipValuesSchema>; export type RelationshipValuesData = z.infer<typeof RelationshipValuesSchema>;
export class AssetRelationshipValues { export class AssetRelationshipValues {
@ -27,6 +28,7 @@ export class AssetRelationshipValues {
) { ) {
Object.freeze(this); Object.freeze(this);
} }
static create(input: RelationshipValuesData): Result<AssetRelationshipValues, AssetCatalogError> { static create(input: RelationshipValuesData): Result<AssetRelationshipValues, AssetCatalogError> {
const parsed = RelationshipValuesSchema.safeParse(input); const parsed = RelationshipValuesSchema.safeParse(input);
const source = UniqueID.create(input.sourceId); const source = UniqueID.create(input.sourceId);
@ -34,6 +36,7 @@ export class AssetRelationshipValues {
const type = UniqueID.create(input.typeId); const type = UniqueID.create(input.typeId);
const name = Name.create(input.name, 100); const name = Name.create(input.name, 100);
const inverse = Name.create(input.symmetric ? input.name : input.inverseName, 100); const inverse = Name.create(input.symmetric ? input.name : input.inverseName, 100);
if ( if (
!( !(
parsed.success && parsed.success &&
@ -49,6 +52,7 @@ export class AssetRelationshipValues {
if (source.data.equals(target.data)) { if (source.data.equals(target.data)) {
return Result.fail(new AssetCatalogError("SELF_RELATIONSHIP")); return Result.fail(new AssetCatalogError("SELF_RELATIONSHIP"));
} }
return Result.ok( return Result.ok(
new AssetRelationshipValues( new AssetRelationshipValues(
source.data, source.data,
@ -61,6 +65,7 @@ export class AssetRelationshipValues {
) )
); );
} }
toPrimitive(): RelationshipValuesData { toPrimitive(): RelationshipValuesData {
return { return {
sourceId: this.sourceId.toPrimitive(), sourceId: this.sourceId.toPrimitive(),
@ -72,6 +77,7 @@ export class AssetRelationshipValues {
notes: this.notes, notes: this.notes,
}; };
} }
equals(other: AssetRelationshipValues): boolean { equals(other: AssetRelationshipValues): boolean {
return ( return (
this.key() === other.key() && this.key() === other.key() &&
@ -81,12 +87,15 @@ export class AssetRelationshipValues {
this.notes === other.notes this.notes === other.notes
); );
} }
/** Identifica un vínculo; las relaciones simétricas normalizan el orden de sus extremos. */ /** Identifica un vínculo; las relaciones simétricas normalizan el orden de sus extremos. */
key(): string { key(): string {
const ids = [this.sourceId.toPrimitive(), this.targetId.toPrimitive()]; const ids = [this.sourceId.toPrimitive(), this.targetId.toPrimitive()];
if (this.symmetric) { if (this.symmetric) {
ids.sort(); ids.sort();
} }
return [this.typeId.toPrimitive(), ...ids].join(":"); return [this.typeId.toPrimitive(), ...ids].join(":");
} }
} }

View File

@ -19,11 +19,14 @@ export class AssetSpecification {
) { ) {
Object.freeze(this); Object.freeze(this);
} }
toPrimitive() { toPrimitive() {
return this.data; return this.data;
} }
static create(input: AssetSpecificationData): Result<AssetSpecification, AssetCatalogError> { static create(input: AssetSpecificationData): Result<AssetSpecification, AssetCatalogError> {
const parsed = AssetSpecificationSchema.safeParse(input); const parsed = AssetSpecificationSchema.safeParse(input);
if ( if (
!(parsed.success && validTemplateSections(input.sections ?? [], input.fields)) || !(parsed.success && validTemplateSections(input.sections ?? [], input.fields)) ||
new Set(input.fields.map((field) => field.fieldId.toLowerCase())).size !== input.fields.length new Set(input.fields.map((field) => field.fieldId.toLowerCase())).size !== input.fields.length
@ -32,12 +35,15 @@ export class AssetSpecification {
} }
const template = UniqueID.create(input.templateId); const template = UniqueID.create(input.templateId);
const name = Name.create(input.templateName, 100); const name = Name.create(input.templateName, 100);
if (!(template.isSuccess && name.isSuccess)) { if (!(template.isSuccess && name.isSuccess)) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
const frozenFields = input.fields.map((field) => { const frozenFields = input.fields.map((field) => {
const choices = [...field.choices]; const choices = [...field.choices];
Object.freeze(choices); Object.freeze(choices);
return Object.freeze({ return Object.freeze({
...field, ...field,
fieldId: field.fieldId.toLowerCase(), fieldId: field.fieldId.toLowerCase(),
@ -45,7 +51,9 @@ export class AssetSpecification {
...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}), ...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}),
}); });
}); });
Object.freeze(frozenFields); Object.freeze(frozenFields);
return Result.ok( return Result.ok(
new AssetSpecification( new AssetSpecification(
Object.freeze({ Object.freeze({
@ -59,12 +67,14 @@ export class AssetSpecification {
) )
); );
} }
equals(other: AssetSpecification | undefined) { equals(other: AssetSpecification | undefined) {
if (!(other instanceof AssetSpecification)) { if (!(other instanceof AssetSpecification)) {
return false; return false;
} }
const first = this.toPrimitive(); const first = this.toPrimitive();
const second = other.toPrimitive(); const second = other.toPrimitive();
return ( return (
first.templateId === second.templateId && first.templateId === second.templateId &&
first.templateName === second.templateName && first.templateName === second.templateName &&
@ -72,6 +82,7 @@ export class AssetSpecification {
first.fields.length === second.fields.length && first.fields.length === second.fields.length &&
first.fields.every((field, index) => { first.fields.every((field, index) => {
const candidate = second.fields[index]; const candidate = second.fields[index];
return ( return (
candidate !== undefined && candidate !== undefined &&
field.fieldId === candidate.fieldId && field.fieldId === candidate.fieldId &&
@ -87,6 +98,7 @@ export class AssetSpecification {
); );
} }
} }
function validCharacteristic(field: AssetCharacteristic, value: unknown) { function validCharacteristic(field: AssetCharacteristic, value: unknown) {
switch (field.type) { switch (field.type) {
case "text": case "text":
@ -101,6 +113,7 @@ function validCharacteristic(field: AssetCharacteristic, value: unknown) {
return z.iso.date().safeParse(value).success; return z.iso.date().safeParse(value).success;
} }
} }
export class AssetValues { export class AssetValues {
private constructor( private constructor(
private readonly data: AssetInput, private readonly data: AssetInput,
@ -110,15 +123,18 @@ export class AssetValues {
) { ) {
Object.freeze(this); Object.freeze(this);
} }
toPrimitive() { toPrimitive() {
return this.data; return this.data;
} }
static create( static create(
input: AssetInput, input: AssetInput,
specification: AssetSpecification specification: AssetSpecification
): Result<AssetValues, AssetCatalogError> { ): Result<AssetValues, AssetCatalogError> {
const parsed = AssetInputSchema.safeParse(input); const parsed = AssetInputSchema.safeParse(input);
const name = Name.create(input.name, 100); const name = Name.create(input.name, 100);
if ( if (
!(parsed.success && name.isSuccess) || !(parsed.success && name.isSuccess) ||
input.templateId.toLowerCase() !== specification.toPrimitive().templateId input.templateId.toLowerCase() !== specification.toPrimitive().templateId
@ -128,10 +144,12 @@ export class AssetValues {
const data = parsed.data; const data = parsed.data;
const site = optionalIdentity(data.siteId); const site = optionalIdentity(data.siteId);
const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification); const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification);
if (!parsedCharacteristics.isSuccess) { if (!parsedCharacteristics.isSuccess) {
return parsedCharacteristics; return parsedCharacteristics;
} }
const characteristics = parsedCharacteristics.data; const characteristics = parsedCharacteristics.data;
return Result.ok( return Result.ok(
new AssetValues( new AssetValues(
Object.freeze({ Object.freeze({
@ -149,12 +167,14 @@ export class AssetValues {
) )
); );
} }
equals(other: AssetValues | undefined) { equals(other: AssetValues | undefined) {
if (!(other instanceof AssetValues)) { if (!(other instanceof AssetValues)) {
return false; return false;
} }
const first = this.toPrimitive(); const first = this.toPrimitive();
const second = other.toPrimitive(); const second = other.toPrimitive();
return ( return (
first.name === second.name && first.name === second.name &&
first.templateId === second.templateId && first.templateId === second.templateId &&
@ -175,11 +195,13 @@ function normalizeCharacteristics(
specification: AssetSpecification specification: AssetSpecification
): Result<AssetInput["characteristics"], AssetCatalogError> { ): Result<AssetInput["characteristics"], AssetCatalogError> {
const characteristics: AssetInput["characteristics"] = {}; const characteristics: AssetInput["characteristics"] = {};
for (const [key, raw] of Object.entries(input)) { for (const [key, raw] of Object.entries(input)) {
const field = specification const field = specification
.toPrimitive() .toPrimitive()
.fields.find((item) => item.fieldId === key.toLowerCase()); .fields.find((item) => item.fieldId === key.toLowerCase());
const value = typeof raw === "string" ? raw.trim() : raw; const value = typeof raw === "string" ? raw.trim() : raw;
if (!field || key.toLowerCase() in characteristics || !validCharacteristic(field, value)) { if (!field || key.toLowerCase() in characteristics || !validCharacteristic(field, value)) {
return Result.fail(new AssetCatalogError("INVALID_CHARACTERISTICS")); return Result.fail(new AssetCatalogError("INVALID_CHARACTERISTICS"));
} }
@ -194,6 +216,7 @@ function normalizeCharacteristics(
) { ) {
return Result.fail(new AssetCatalogError("REQUIRED_CHARACTERISTICS")); return Result.fail(new AssetCatalogError("REQUIRED_CHARACTERISTICS"));
} }
return Result.ok(characteristics); return Result.ok(characteristics);
} }
@ -202,8 +225,10 @@ function optionalIdentity(value: string | null): Maybe<UniqueID> {
return Maybe.none(); return Maybe.none();
} }
const parsed = UniqueID.create(value); const parsed = UniqueID.create(value);
if (!parsed.isSuccess) { if (!parsed.isSuccess) {
throw new Error("Referencia validada inconsistente."); throw new Error("Referencia validada inconsistente.");
} }
return Maybe.some(parsed.data); return Maybe.some(parsed.data);
} }

View File

@ -106,6 +106,7 @@ function safeStyle(style: string): boolean {
return true; return true;
} }
const [property, value, extra] = declaration.split(":"); const [property, value, extra] = declaration.split(":");
return ( return (
property !== undefined && property !== undefined &&
value !== undefined && value !== undefined &&
@ -123,6 +124,7 @@ function safeAttribute(attribute: SaxesAttributeNS): boolean {
if (attribute.name === "style") { if (attribute.name === "style") {
return safeStyle(attribute.value); return safeStyle(attribute.value);
} }
return !attribute.prefix && ATTRIBUTES.has(attribute.name) && safeValue(attribute.value); return !attribute.prefix && ATTRIBUTES.has(attribute.name) && safeValue(attribute.value);
} }
@ -131,6 +133,7 @@ function validRoot(tag: SaxesTagNS): boolean {
.trim() .trim()
.split(/[\s,]+/) .split(/[\s,]+/)
.map(Number); .map(Number);
return ( return (
tag.local === "svg" && tag.local === "svg" &&
viewBox !== undefined && viewBox !== undefined &&
@ -150,6 +153,7 @@ export function isSafeBrandLogo(input: string): boolean {
let elements = 0; let elements = 0;
let hasRoot = false; let hasRoot = false;
const parser = new SaxesParser({ xmlns: true }); const parser = new SaxesParser({ xmlns: true });
parser.on("error", () => { parser.on("error", () => {
valid = false; valid = false;
}); });
@ -187,6 +191,7 @@ export function isSafeBrandLogo(input: string): boolean {
} catch { } catch {
return false; return false;
} }
return valid && hasRoot; return valid && hasRoot;
} }
@ -201,6 +206,7 @@ export const BrandLogoSchema = z
export class BrandLogo extends ValueObject<string> { export class BrandLogo extends ValueObject<string> {
static create(input: unknown): Result<BrandLogo, Error> { static create(input: unknown): Result<BrandLogo, Error> {
const parsed = BrandLogoSchema.safeParse(input); const parsed = BrandLogoSchema.safeParse(input);
return parsed.success return parsed.success
? Result.ok(new BrandLogo(parsed.data)) ? Result.ok(new BrandLogo(parsed.data))
: Result.fail(new Error("Logotipo SVG inválido.")); : Result.fail(new Error("Logotipo SVG inválido."));

View File

@ -12,21 +12,27 @@ export class SupportValues {
Object.freeze(data); Object.freeze(data);
Object.freeze(this); Object.freeze(this);
} }
static create(input: SupportInput): Result<SupportValues, SupportError> { static create(input: SupportInput): Result<SupportValues, SupportError> {
const parsed = SupportValuesSchema.safeParse(input); const parsed = SupportValuesSchema.safeParse(input);
const tool = UniqueID.create(input.toolId); const tool = UniqueID.create(input.toolId);
if (!(parsed.success && tool.isSuccess)) { if (!(parsed.success && tool.isSuccess)) {
return Result.fail(new SupportError("INVALID_REQUEST")); return Result.fail(new SupportError("INVALID_REQUEST"));
} }
const { toolId: _, ...data } = parsed.data; const { toolId: _, ...data } = parsed.data;
return Result.ok(new SupportValues(tool.data, data)); return Result.ok(new SupportValues(tool.data, data));
} }
toPrimitive(): SupportInput { toPrimitive(): SupportInput {
return { ...this.data, toolId: this.toolId.toPrimitive() }; return { ...this.data, toolId: this.toolId.toPrimitive() };
} }
equals(other: SupportValues) { equals(other: SupportValues) {
const a = this.data; const a = this.data;
const b = other.data; const b = other.data;
return ( return (
this.toolId.equals(other.toolId) && this.toolId.equals(other.toolId) &&
a.destination === b.destination && a.destination === b.destination &&

View File

@ -4,6 +4,7 @@ import { Maybe, Result } from "@repo/rdx-utils";
import { AssetCatalogError } from "../asset-definition"; import { AssetCatalogError } from "../asset-definition";
import type { WorkstationInput } from "../workstation-values"; import type { WorkstationInput } from "../workstation-values";
import { WorkstationInputSchema } from "../workstation-values"; import { WorkstationInputSchema } from "../workstation-values";
export class WorkstationValues { export class WorkstationValues {
private constructor( private constructor(
public readonly name: Name, public readonly name: Name,
@ -15,18 +16,22 @@ export class WorkstationValues {
) { ) {
Object.freeze(this); Object.freeze(this);
} }
static create(input: WorkstationInput): Result<WorkstationValues, AssetCatalogError> { static create(input: WorkstationInput): Result<WorkstationValues, AssetCatalogError> {
const parsed = WorkstationInputSchema.safeParse(input); const parsed = WorkstationInputSchema.safeParse(input);
const name = Name.create(input.name, 100); const name = Name.create(input.name, 100);
const site = optionalIdentity(input.siteId); const site = optionalIdentity(input.siteId);
const person = optionalIdentity(input.personId); const person = optionalIdentity(input.personId);
if (!(parsed.success && name.isSuccess && site.isSuccess && person.isSuccess)) { if (!(parsed.success && name.isSuccess && site.isSuccess && person.isSuccess)) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
const ids: UniqueID[] = []; const ids: UniqueID[] = [];
const shared: UniqueID[] = []; const shared: UniqueID[] = [];
for (const value of parsed.data.assetIds) { for (const value of parsed.data.assetIds) {
const id = UniqueID.create(value); const id = UniqueID.create(value);
if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) { if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) {
return Result.fail(new AssetCatalogError("INVALID_MEMBERS")); return Result.fail(new AssetCatalogError("INVALID_MEMBERS"));
} }
@ -34,11 +39,13 @@ export class WorkstationValues {
} }
for (const value of parsed.data.sharedAssetIds) { for (const value of parsed.data.sharedAssetIds) {
const id = UniqueID.create(value); const id = UniqueID.create(value);
if (!id.isSuccess || [...ids, ...shared].some((previous) => previous.equals(id.data))) { if (!id.isSuccess || [...ids, ...shared].some((previous) => previous.equals(id.data))) {
return Result.fail(new AssetCatalogError("INVALID_MEMBERS")); return Result.fail(new AssetCatalogError("INVALID_MEMBERS"));
} }
shared.push(id.data); shared.push(id.data);
} }
return Result.ok( return Result.ok(
new WorkstationValues( new WorkstationValues(
name.data, name.data,
@ -50,6 +57,7 @@ export class WorkstationValues {
) )
); );
} }
toPrimitive(): WorkstationInput { toPrimitive(): WorkstationInput {
return { return {
name: this.name.toPrimitive(), name: this.name.toPrimitive(),
@ -60,9 +68,11 @@ export class WorkstationValues {
sharedAssetIds: this.sharedAssetIds.map((id) => id.toPrimitive()), sharedAssetIds: this.sharedAssetIds.map((id) => id.toPrimitive()),
}; };
} }
equals(other: WorkstationValues) { equals(other: WorkstationValues) {
const a = this.toPrimitive(); const a = this.toPrimitive();
const b = other.toPrimitive(); const b = other.toPrimitive();
return ( return (
a.name === b.name && a.name === b.name &&
a.active === b.active && a.active === b.active &&
@ -81,6 +91,7 @@ function optionalIdentity(value: string | null): Result<Maybe<UniqueID>, AssetCa
return Result.ok(Maybe.none()); return Result.ok(Maybe.none());
} }
const id = UniqueID.create(value); const id = UniqueID.create(value);
return id.isSuccess return id.isSuccess
? Result.ok(Maybe.some(id.data)) ? Result.ok(Maybe.some(id.data))
: Result.fail(new AssetCatalogError("INVALID_REQUEST")); : Result.fail(new AssetCatalogError("INVALID_REQUEST"));

View File

@ -2,24 +2,30 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { DomainEntity } from "@repo/rdx-ddd"; import { DomainEntity } from "@repo/rdx-ddd";
import type { WorkstationValues } from "./value-objects/workstation-values.vo"; import type { WorkstationValues } from "./value-objects/workstation-values.vo";
export type WorkstationRevisionProps = Readonly<{ export type WorkstationRevisionProps = Readonly<{
values: WorkstationValues; values: WorkstationValues;
actorId: UniqueID; actorId: UniqueID;
recordedAt: string; recordedAt: string;
}>; }>;
export class WorkstationRevision extends DomainEntity<WorkstationRevisionProps> { export class WorkstationRevision extends DomainEntity<WorkstationRevisionProps> {
static create(props: WorkstationRevisionProps) { static create(props: WorkstationRevisionProps) {
return new WorkstationRevision(Object.freeze({ ...props })); return new WorkstationRevision(Object.freeze({ ...props }));
} }
static rehydrate(props: WorkstationRevisionProps, id: UniqueID) { static rehydrate(props: WorkstationRevisionProps, id: UniqueID) {
return new WorkstationRevision(Object.freeze({ ...props }), id); return new WorkstationRevision(Object.freeze({ ...props }), id);
} }
get values() { get values() {
return this.props.values; return this.props.values;
} }
get actorId() { get actorId() {
return this.props.actorId; return this.props.actorId;
} }
get recordedAt() { get recordedAt() {
return this.props.recordedAt; return this.props.recordedAt;
} }

View File

@ -1,4 +1,5 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
export const WorkstationInputSchema = z.strictObject({ export const WorkstationInputSchema = z.strictObject({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
active: z.boolean(), active: z.boolean(),
@ -7,4 +8,5 @@ export const WorkstationInputSchema = z.strictObject({
assetIds: z.array(z.uuid()).max(200), assetIds: z.array(z.uuid()).max(200),
sharedAssetIds: z.array(z.uuid()).max(200), sharedAssetIds: z.array(z.uuid()).max(200),
}); });
export type WorkstationInput = z.infer<typeof WorkstationInputSchema>; export type WorkstationInput = z.infer<typeof WorkstationInputSchema>;

View File

@ -12,10 +12,12 @@ type WorkstationProps = Readonly<{
values: WorkstationValues; values: WorkstationValues;
history: readonly WorkstationRevision[]; history: readonly WorkstationRevision[];
}>; }>;
export class Workstation extends AggregateRoot<WorkstationProps> { export class Workstation extends AggregateRoot<WorkstationProps> {
private constructor(props: WorkstationProps, id?: UniqueID) { private constructor(props: WorkstationProps, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id); super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
} }
static create( static create(
companyId: UniqueID, companyId: UniqueID,
values: WorkstationValues, values: WorkstationValues,
@ -25,6 +27,7 @@ export class Workstation extends AggregateRoot<WorkstationProps> {
if (!z.iso.datetime().safeParse(recordedAt).success) { if (!z.iso.datetime().safeParse(recordedAt).success) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
return Result.ok( return Result.ok(
new Workstation({ new Workstation({
companyId, companyId,
@ -33,18 +36,23 @@ export class Workstation extends AggregateRoot<WorkstationProps> {
}) })
); );
} }
static rehydrate(props: WorkstationProps, id: UniqueID) { static rehydrate(props: WorkstationProps, id: UniqueID) {
return new Workstation(props, id); return new Workstation(props, id);
} }
get companyId() { get companyId() {
return this.props.companyId; return this.props.companyId;
} }
get values() { get values() {
return this.props.values; return this.props.values;
} }
get history() { get history() {
return this.props.history; return this.props.history;
} }
update( update(
values: WorkstationValues, values: WorkstationValues,
actorId: UniqueID, actorId: UniqueID,
@ -59,6 +67,7 @@ export class Workstation extends AggregateRoot<WorkstationProps> {
) { ) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST")); return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
} }
return Result.ok( return Result.ok(
new Workstation( new Workstation(
{ {

View File

@ -8,6 +8,7 @@ import { Workstation } from "./workstation.aggregate";
import type { WorkstationInput } from "./workstation-values"; import type { WorkstationInput } from "./workstation-values";
const assetIds = [UniqueID.generateNewID().toPrimitive(), UniqueID.generateNewID().toPrimitive()]; const assetIds = [UniqueID.generateNewID().toPrimitive(), UniqueID.generateNewID().toPrimitive()];
function input(): WorkstationInput { function input(): WorkstationInput {
return { return {
name: " Puesto Ana ", name: " Puesto Ana ",
@ -18,11 +19,15 @@ function input(): WorkstationInput {
assetIds, assetIds,
}; };
} }
function values(data = input()) { function values(data = input()) {
const result = WorkstationValues.create(data); const result = WorkstationValues.create(data);
assert.ok(result.isSuccess); assert.ok(result.isSuccess);
return result.data; return result.data;
} }
test("Puestos: composición opcional, referencias válidas y límites sin duplicados", () => { test("Puestos: composición opcional, referencias válidas y límites sin duplicados", () => {
assert.ok(WorkstationValues.create({ ...input(), assetIds: [] }).isSuccess); assert.ok(WorkstationValues.create({ ...input(), assetIds: [] }).isSuccess);
assert.equal(values().name.toPrimitive(), "Puesto Ana"); assert.equal(values().name.toPrimitive(), "Puesto Ana");
@ -33,6 +38,7 @@ test("Puestos: composición opcional, referencias válidas y límites sin duplic
...input(), ...input(),
assetIds: [assetIds[0] ?? "", assetIds[0] ?? ""], assetIds: [assetIds[0] ?? "", assetIds[0] ?? ""],
}); });
assert.ok(!duplicate.isSuccess); assert.ok(!duplicate.isSuccess);
assert.equal(duplicate.error.code, "INVALID_MEMBERS"); assert.equal(duplicate.error.code, "INVALID_MEMBERS");
assert.ok( assert.ok(
@ -47,8 +53,10 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
const company = UniqueID.generateNewID(); const company = UniqueID.generateNewID();
const actor = UniqueID.generateNewID(); const actor = UniqueID.generateNewID();
const created = Workstation.create(company, values(), actor, "2026-10-04T10:00:00.000Z"); const created = Workstation.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
assert.ok(created.isSuccess); assert.ok(created.isSuccess);
const item = created.data; const item = created.data;
assert.equal(item.id.toPrimitive().charAt(14), "7"); assert.equal(item.id.toPrimitive().charAt(14), "7");
assert.equal(item.history[0]?.id.toPrimitive().charAt(14), "7"); assert.equal(item.history[0]?.id.toPrimitive().charAt(14), "7");
const same = item.update( const same = item.update(
@ -56,6 +64,7 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
actor, actor,
"2026-10-04T11:00:00.000Z" "2026-10-04T11:00:00.000Z"
); );
assert.ok(same.isSuccess); assert.ok(same.isSuccess);
assert.equal(same.data, item); assert.equal(same.data, item);
const changed = item.update( const changed = item.update(
@ -63,6 +72,7 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
actor, actor,
"2026-10-04T11:00:00.000Z" "2026-10-04T11:00:00.000Z"
); );
assert.ok(changed.isSuccess); assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(item.id)); assert.ok(changed.data.id.equals(item.id));
assert.equal(changed.data.history.length, 2); assert.equal(changed.data.history.length, 2);
@ -81,6 +91,7 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
test("Puestos: recursos compartidos separados, sin duplicados y cambios con historial", () => { test("Puestos: recursos compartidos separados, sin duplicados y cambios con historial", () => {
const sharedId = UniqueID.generateNewID().toPrimitive(); const sharedId = UniqueID.generateNewID().toPrimitive();
const original = values({ ...input(), sharedAssetIds: [sharedId] }); const original = values({ ...input(), sharedAssetIds: [sharedId] });
assert.ok(Object.isFrozen(original.sharedAssetIds)); assert.ok(Object.isFrozen(original.sharedAssetIds));
assert.ok( assert.ok(
!WorkstationValues.create({ ...input(), sharedAssetIds: [assetIds[0] ?? ""] }).isSuccess !WorkstationValues.create({ ...input(), sharedAssetIds: [assetIds[0] ?? ""] }).isSuccess
@ -94,12 +105,14 @@ test("Puestos: recursos compartidos separados, sin duplicados y cambios con hist
UniqueID.generateNewID(), UniqueID.generateNewID(),
"2026-10-04T10:00:00.000Z" "2026-10-04T10:00:00.000Z"
); );
assert.ok(created.isSuccess); assert.ok(created.isSuccess);
const changed = created.data.update( const changed = created.data.update(
values(), values(),
UniqueID.generateNewID(), UniqueID.generateNewID(),
"2026-10-04T11:00:00.000Z" "2026-10-04T11:00:00.000Z"
); );
assert.ok(changed.isSuccess); assert.ok(changed.isSuccess);
assert.deepEqual(changed.data.history[0]?.values.toPrimitive().sharedAssetIds, [sharedId]); assert.deepEqual(changed.data.history[0]?.values.toPrimitive().sharedAssetIds, [sharedId]);
assert.equal(changed.data.history.length, 2); assert.equal(changed.data.history.length, 2);

View File

@ -17,8 +17,10 @@ import type { AssetCatalogError } from "../../domain/asset-definition";
function sendError(response: Response, code: string) { function sendError(response: Response, code: string) {
const status = code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409; const status = code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409;
response.status(status).json({ code, requestId: response.getHeader("x-request-id") }); response.status(status).json({ code, requestId: response.getHeader("x-request-id") });
} }
function sendResult( function sendResult(
response: Response, response: Response,
result: Result<AssetCatalog, AssetCatalogError>, result: Result<AssetCatalog, AssetCatalogError>,
@ -28,16 +30,21 @@ function sendResult(
) { ) {
if (!result.isSuccess) { if (!result.isSuccess) {
sendError(response, result.error.code); sendError(response, result.error.code);
return; return;
} }
const parsed = catalogScope(scope); const parsed = catalogScope(scope);
if (!parsed.isSuccess) { if (!parsed.isSuccess) {
sendError(response, "INVALID_REQUEST"); sendError(response, "INVALID_REQUEST");
return; return;
} }
const body = buildCatalogResponse(result.data, parsed.data); const body = buildCatalogResponse(result.data, parsed.data);
if (!savedName) { if (!savedName) {
response.status(status).json(body); response.status(status).json(body);
return; return;
} }
const savedId = const savedId =
@ -48,21 +55,26 @@ function sendResult(
item.values.kind === savedName.kind && item.values.kind === savedName.kind &&
item.values.name === savedName.name.trim() item.values.name === savedName.name.trim()
)?.id; )?.id;
if (!savedId) { if (!savedId) {
throw new Error("Resultado del catálogo inconsistente."); throw new Error("Resultado del catálogo inconsistente.");
} }
response.status(status).json({ ...body, savedId }); response.status(status).json({ ...body, savedId });
} }
export function registerAssetCatalogRoutes( export function registerAssetCatalogRoutes(
app: Application, app: Application,
useCases: AssetCatalogUseCases, useCases: AssetCatalogUseCases,
authenticate: RequestHandler authenticate: RequestHandler
) { ) {
const router = Router(); const router = Router();
router.get("/:scope", (request, response, next) => { router.get("/:scope", (request, response, next) => {
const params = CatalogParamsSchema.safeParse(request.params); const params = CatalogParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) { if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
sendError(response, "INVALID_REQUEST"); sendError(response, "INVALID_REQUEST");
return; return;
} }
useCases useCases
@ -75,12 +87,15 @@ export function registerAssetCatalogRoutes(
const schema = path.endsWith("update") ? CatalogItemParamsSchema : CatalogParamsSchema; const schema = path.endsWith("update") ? CatalogItemParamsSchema : CatalogParamsSchema;
const params = schema.safeParse(request.params); const params = schema.safeParse(request.params);
const body = SaveDefinitionRequestSchema.safeParse(request.body); const body = SaveDefinitionRequestSchema.safeParse(request.body);
if (!(params.success && body.success)) { if (!(params.success && body.success)) {
sendError(response, "INVALID_REQUEST"); sendError(response, "INVALID_REQUEST");
return; return;
} }
const identity = const identity =
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
useCases useCases
.save(params.data.scope, identity, body.data.values) .save(params.data.scope, identity, body.data.values)
.then((result) => .then((result) =>
@ -96,8 +111,10 @@ export function registerAssetCatalogRoutes(
router.post("/:scope/:id/status", (request, response, next) => { router.post("/:scope/:id/status", (request, response, next) => {
const params = CatalogItemParamsSchema.safeParse(request.params); const params = CatalogItemParamsSchema.safeParse(request.params);
const body = AvailabilityRequestSchema.safeParse(request.body); const body = AvailabilityRequestSchema.safeParse(request.body);
if (!(params.success && body.success)) { if (!(params.success && body.success)) {
sendError(response, "INVALID_REQUEST"); sendError(response, "INVALID_REQUEST");
return; return;
} }
useCases useCases

View File

@ -12,18 +12,21 @@ function fail(response: Response, code: string) {
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409) .status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
.json({ code, requestId: response.getHeader("x-request-id") }); .json({ code, requestId: response.getHeader("x-request-id") });
} }
export function registerRelationshipRoutes( export function registerRelationshipRoutes(
app: Application, app: Application,
useCases: AssetRelationshipUseCases, useCases: AssetRelationshipUseCases,
authenticate: RequestHandler authenticate: RequestHandler
) { ) {
const router = Router(); const router = Router();
router.post("/:companyId/relationships", (request, response, next) => { router.post("/:companyId/relationships", (request, response, next) => {
const params = InventoryParamsSchema.safeParse(request.params); const params = InventoryParamsSchema.safeParse(request.params);
const body = RelationshipRequestSchema.safeParse(request.body); const body = RelationshipRequestSchema.safeParse(request.body);
const actor = z const actor = z
.object({ userId: z.uuid() }) .object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined); .safeParse("session" in request ? request.session : undefined);
if ( if (
!( !(
params.success && params.success &&
@ -33,6 +36,7 @@ export function registerRelationshipRoutes(
) )
) { ) {
fail(response, "INVALID_REQUEST"); fail(response, "INVALID_REQUEST");
return; return;
} }
useCases useCases
@ -40,6 +44,7 @@ export function registerRelationshipRoutes(
.then((result) => { .then((result) => {
if (!result.isSuccess) { if (!result.isSuccess) {
fail(response, result.error.code); fail(response, result.error.code);
return; return;
} }
response.status(201).json({ response.status(201).json({
@ -53,6 +58,7 @@ export function registerRelationshipRoutes(
const actor = z const actor = z
.object({ userId: z.uuid() }) .object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined); .safeParse("session" in request ? request.session : undefined);
if ( if (
!( !(
params.success && params.success &&
@ -62,6 +68,7 @@ export function registerRelationshipRoutes(
) )
) { ) {
fail(response, "INVALID_REQUEST"); fail(response, "INVALID_REQUEST");
return; return;
} }
useCases useCases
@ -69,6 +76,7 @@ export function registerRelationshipRoutes(
.then((result) => { .then((result) => {
if (!result.isSuccess) { if (!result.isSuccess) {
fail(response, result.error.code); fail(response, result.error.code);
return; return;
} }
response.json({ response.json({
@ -79,6 +87,7 @@ export function registerRelationshipRoutes(
}); });
router.post("/:companyId/relationships/:id/delete", (request, response, next) => { router.post("/:companyId/relationships/:id/delete", (request, response, next) => {
const params = AssetParamsSchema.safeParse(request.params); const params = AssetParamsSchema.safeParse(request.params);
if ( if (
!( !(
params.success && params.success &&
@ -87,6 +96,7 @@ export function registerRelationshipRoutes(
) )
) { ) {
fail(response, "INVALID_REQUEST"); fail(response, "INVALID_REQUEST");
return; return;
} }
useCases useCases
@ -94,6 +104,7 @@ export function registerRelationshipRoutes(
.then((result) => { .then((result) => {
if (!result.isSuccess) { if (!result.isSuccess) {
fail(response, result.error.code); fail(response, result.error.code);
return; return;
} }
response.json({ deleted: true }); response.json({ deleted: true });

View File

@ -17,16 +17,20 @@ function fail(response: Response, code: string) {
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409) .status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
.json({ code, requestId: response.getHeader("x-request-id") }); .json({ code, requestId: response.getHeader("x-request-id") });
} }
export function registerAssetRoutes( export function registerAssetRoutes(
app: Application, app: Application,
useCases: AssetUseCases, useCases: AssetUseCases,
authenticate: RequestHandler authenticate: RequestHandler
) { ) {
const router = Router(); const router = Router();
router.get("/:companyId", (request, response, next) => { router.get("/:companyId", (request, response, next) => {
const params = InventoryParamsSchema.safeParse(request.params); const params = InventoryParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) { if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST"); fail(response, "INVALID_REQUEST");
return; return;
} }
useCases useCases
@ -34,6 +38,7 @@ export function registerAssetRoutes(
.then((result) => { .then((result) => {
if (!result.isSuccess) { if (!result.isSuccess) {
fail(response, result.error.code); fail(response, result.error.code);
return; return;
} }
response.json({ response.json({
@ -59,17 +64,21 @@ export function registerAssetRoutes(
const actor = z const actor = z
.object({ userId: z.uuid() }) .object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined); .safeParse("session" in request ? request.session : undefined);
if (!(params.success && body.success && actor.success)) { if (!(params.success && body.success && actor.success)) {
fail(response, "INVALID_REQUEST"); fail(response, "INVALID_REQUEST");
return; return;
} }
const id = const id =
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
useCases useCases
.save(params.data.companyId, id, body.data.values, actor.data.userId) .save(params.data.companyId, id, body.data.values, actor.data.userId)
.then((result) => { .then((result) => {
if (!result.isSuccess) { if (!result.isSuccess) {
fail(response, result.error.code); fail(response, result.error.code);
return; return;
} }
response response

Some files were not shown because too many files have changed in this diff Show More