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
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.
- 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.

View File

@ -2,28 +2,37 @@ import assert from "node:assert/strict";
import type { AccessFieldsDTO, AccessItemDTO, CompanyDTO } from "@metis/companies/common";
import { AccessResponseSchema } from "@metis/companies/common";
export async function verifyAccess(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
company: CompanyDTO,
other: CompanyDTO
) {
const base = `/access/${company.id}`;
async function create(scope: string, values: AccessFieldsDTO) {
const response = await request(`/access/${scope}`, { values });
assert.equal(response.status, 201, JSON.stringify(await response.clone().json()));
const result = AccessResponseSchema.parse(await response.json());
const item = result.items.at(-1);
assert.ok(item);
assert.equal(item.values.kind, values.kind);
assert.equal(item.id[14], "7");
assert.equal(item.history.length, 1);
return item;
}
async function update(item: AccessItemDTO, values: AccessFieldsDTO) {
const response = await request(`${base}/${item.id}/update`, { values });
assert.equal(response.status, 200, JSON.stringify(await response.clone().json()));
return AccessResponseSchema.parse(await response.json());
}
const system = await create("global", { kind: "system", name: "Directorio", active: true });
const resourceType = await create("global", {
kind: "resource_type",
@ -44,6 +53,7 @@ export async function verifyAccess(
systemId: system.id,
active: true,
});
assert.equal(
(await request(base, { values: { kind: "system", name: " DIRECTORIO ", active: false } }))
.status,
@ -110,6 +120,7 @@ export async function verifyAccess(
notes: null,
};
const account = await create(company.id, accountValues);
assert.equal((await request(base, { values: accountValues })).status, 409);
assert.equal(
(
@ -141,10 +152,13 @@ export async function verifyAccess(
);
const renamed = { ...accountValues, login: "ana.garcia" };
const changed = await update(account, renamed);
assert.equal(changed.items.find((item) => item.id === account.id)?.history.length, 2);
const repeated = await update(account, renamed);
assert.equal(repeated.items.find((item) => item.id === account.id)?.history.length, 2);
const person = company.people.find((item) => item.active);
assert.ok(person);
const relationValues: AccessFieldsDTO = {
kind: "account_person",
@ -155,8 +169,10 @@ export async function verifyAccess(
configuration: "configured",
};
const relation = await create(company.id, relationValues);
assert.equal((await request(base, { values: relationValues })).status, 409);
const foreignPerson = other.people[0];
if (foreignPerson) {
assert.equal(
(await request(base, { values: { ...relationValues, personId: foreignPerson.id } })).status,
@ -215,6 +231,7 @@ export async function verifyAccess(
};
const need = await create(company.id, needValues);
let response = AccessResponseSchema.parse(await (await request(base)).json());
assert.equal(response.coverage.find((item) => item.needId === need.id)?.covered, false);
assert.ok(
response.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified")
@ -238,6 +255,7 @@ export async function verifyAccess(
...relationValues,
configuration: "pending",
});
assert.equal(
awaitingConfiguration.coverage.find((item) => item.needId === need.id)?.covered,
false
@ -257,6 +275,7 @@ export async function verifyAccess(
active: true,
});
const differentNeed = await update(need, { ...needValues, permissionIds: [readPermission.id] });
assert.equal(differentNeed.coverage.find((item) => item.needId === need.id)?.covered, false);
assert.ok(
differentNeed.reviews.some((item) => item.itemId === grant.id && item.reason === "unjustified")
@ -267,6 +286,7 @@ export async function verifyAccess(
endDate: "2026-10-03",
configuration: "removal_pending",
});
assert.equal(closed.coverage.find((item) => item.needId === need.id)?.covered, false);
assert.ok(
closed.reviews.some(
@ -278,6 +298,7 @@ export async function verifyAccess(
endDate: "2026-10-03",
configuration: "removed",
});
assert.equal(
removed.reviews.some(
(item) => item.itemId === membership.id && item.reason === "pending_removal"
@ -292,6 +313,7 @@ export async function verifyAccess(
true
);
const expired = await update(account, { ...renamed, expiresOn: "2000-01-01" });
assert.ok(
expired.reviews.some((item) => item.itemId === account.id && item.reason === "expired")
);
@ -306,6 +328,7 @@ export async function verifyAccess(
active: false,
expiresOn: "2000-01-01",
});
await create(company.id, { ...relationValues, accountId: technical.id, configuration: null });
const otherTechnical = await create(company.id, {
...accountValues,
@ -313,6 +336,7 @@ export async function verifyAccess(
usage: "technical",
active: true,
});
assert.equal(
(
await request(base, {
@ -323,6 +347,7 @@ export async function verifyAccess(
);
const shared = await create(company.id, { ...accountValues, login: "shared", usage: "shared" });
const sharedResponse = AccessResponseSchema.parse(await (await request(base)).json());
assert.ok(
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" } }),
]);
assert.deepEqual(concurrent.map((item) => item.status).sort(), [201, 409]);
const otherScope = AccessResponseSchema.parse(
await (await request(`/access/${other.id}`)).json()
);
assert.equal(
otherScope.items.some((item) => item.id === account.id),
false
@ -343,6 +370,7 @@ export async function verifyAccess(
true
);
const global = AccessResponseSchema.parse(await (await request("/access/global")).json());
assert.equal(
global.items.every((item) => item.companyId === null),
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 () => {
const entries: HttpLogEntry[] = [];
const server = createApp((entry) => entries.push(entry)).listen(0, "127.0.0.1");
try {
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}`;
const response = await fetch(`${baseUrl}/api/__health`);
assert.equal(response.status, 200);
const health = GetHealthResponseSchema.parse(await response.json());
assert.equal(health.status, "ok");
assert.equal(response.headers.get("x-powered-by"), null);
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"],
] as const) {
const failure = await fetch(`${baseUrl}${path}`);
assert.equal(failure.status, status);
const body = HttpErrorResponseSchema.parse(await failure.json());
assert.equal(body.code, code);
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");
try {
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}`;
for (const [body, contentType, status] of [
['{"token":"secret-test-value",', "application/json", 400],
[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 },
body,
});
assert.equal(response.status, status);
const error = HttpErrorResponseSchema.parse(await response.json());
assert.equal(error.code, "INVALID_REQUEST");
assert.equal(error.requestId, response.headers.get("x-request-id"));
}
const response = await fetch(`${baseUrl}/api/failure`);
assert.equal(response.status, 500);
const error = HttpErrorResponseSchema.parse(await response.json());
assert.equal(error.code, "INTERNAL_ERROR");
assert.equal(error.requestId, response.headers.get("x-request-id"));
assert.equal(entries.length, 4);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyCertificates(
request: Request,
companyId: string,
@ -28,11 +29,14 @@ export async function verifyCertificates(
active: true,
isContact: false,
});
assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person);
const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json());
const host = assets.assets.find((item) => item.values.status !== "retired");
assert.ok(host);
const profileValues = {
name: "Perfil certificados",
@ -43,27 +47,35 @@ export async function verifyCertificates(
notes: null,
};
const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues });
assert.equal(profileResponse.status, 201);
const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile;
const catalog = CatalogResponseSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json()
);
const template = catalog.items.find((item) => item.builtinKey === "template:certificate");
assert.ok(template);
assert.equal(template.values.kind, "template");
const add = async (templateId: string, name: string) => {
const response = await request(`/resources/${companyId}`, {
values: { name, templateId, active: true, notes: "", characteristics: {} },
});
assert.equal(response.status, 201);
return ResourceResponseSchema.parse(await response.json()).resource;
};
const app = await add(template.id, "Certificado empresa prueba");
const certificateTemplate = catalog.items.find(
(item) => item.builtinKey === "template:application"
);
assert.ok(certificateTemplate);
const certificate = await add(certificateTemplate.id, "Aplicación ajena al certificado");
assert.ok(template.values.kind === "template");
const customValues = {
...template.values,
@ -71,15 +83,18 @@ export async function verifyCertificates(
usage: "certificate" as const,
};
const customResponse = await request(`/resource-catalog/${companyId}`, { values: customValues });
assert.equal(customResponse.status, 201);
const custom = CatalogResponseSchema.parse(
await (await request(`/resource-catalog/${companyId}`)).json()
).items.find((item) => item.values.name === customValues.name);
assert.ok(custom);
const customApp = await add(custom.id, "Certificado colaboración prueba");
const immutable = await request(`/resource-catalog/${companyId}/${custom.id}/update`, {
values: { ...customValues, usage: "application" },
});
assert.equal(immutable.status, 409);
const path = `/certificate-configurations/${companyId}`;
const input: CertificateConfigurationInput = {
@ -89,16 +104,22 @@ export async function verifyCertificates(
state: "pending",
notes: null,
};
const save = async (values: CertificateConfigurationInput, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201);
return CertificateConfigurationResponseSchema.parse(await response.json()).configuration;
};
const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status);
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, resourceId: randomUUID() },
@ -119,10 +140,13 @@ export async function verifyCertificates(
request(path, { values: input }),
request(path, { values: input }),
]);
assert.deepEqual(attempts.map((r) => r.status).sort(), [201, 409]);
const response = attempts.find((r) => r.status === 201);
assert.ok(response);
const created = CertificateConfigurationResponseSchema.parse(await response.json()).configuration;
assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7");
assert.deepEqual(await save(input, created.id), created);
@ -135,6 +159,7 @@ export async function verifyCertificates(
},
created.id
);
assert.equal(configured.history.length, 2);
assert.equal(configured.history[0]?.values.location, null);
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");
assert.ok(expiry);
const expiredResource = await request(`/resources/${companyId}/${app.id}/update`, {
values: {
@ -158,10 +184,12 @@ export async function verifyCertificates(
characteristics: { ...app.values.characteristics, [expiry.id]: "2000-01-01" },
},
});
assert.equal(expiredResource.status, 200);
const list = CertificateConfigurationsResponseSchema.parse(
await (await request(path)).json()
).configurations;
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)?.expiresOn, "2000-01-01");
@ -171,9 +199,11 @@ export async function verifyCertificates(
created.id
);
const noted = await save({ ...configured.values, notes: "Retirar al salir" }, created.id);
assert.equal(noted.history.length, 3);
const removal = await save({ ...noted.values, state: "removal_pending" }, created.id);
const removed = await save({ ...removal.values, state: "removed" }, created.id);
assert.equal(removed.needsReview, false);
await error({ ...removed.values, notes: "Otra" }, "INVALID_CERTIFICATE_TRANSITION", created.id);
assert.equal(
@ -202,12 +232,15 @@ export async function verifyCertificates(
const secondPerson = CompanyResponseSchema.parse(
await secondPersonResponse.json()
).company.people.at(-1);
assert.ok(secondPerson);
const secondProfile = await request(`/work-profiles/${companyId}`, {
values: { ...profileValues, name: "Otro perfil certificados", personId: secondPerson.id },
});
assert.equal(secondProfile.status, 201);
const secondId = WorkProfileResponseSchema.parse(await secondProfile.json()).profile.id;
await save({
...input,
profileId: secondId,
@ -222,9 +255,11 @@ export async function verifyCertificates(
active: false,
},
});
assert.equal(archive.status, 200);
const reviewed = CertificateConfigurationsResponseSchema.parse(
await (await request(path)).json()
).configurations;
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",
"base64"
);
assert.equal((await fetch(`${baseUrl}${path}?version=${"a".repeat(64)}`)).status, 401);
assert.equal(
(
@ -56,15 +57,19 @@ export async function verifyCompanyLogos(
413
);
const upload = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: png });
assert.equal(upload.status, 200);
const logo = CompanyLogoResponseSchema.parse(await upload.json());
assert.ok(logo.logoVersion);
const url = `${baseUrl}${path}?version=${logo.logoVersion}`;
const image = await fetch(url, { headers: { cookie } });
assert.equal(image.status, 200);
assert.equal(image.headers.get("content-type"), "image/webp");
assert.match(image.headers.get("cache-control") ?? "", /private/);
const bytes = Buffer.from(await image.arrayBuffer());
assert.ok(bytes.length <= 64 * 1024);
assert.equal(bytes.subarray(8, 12).toString(), "WEBP");
assert.equal(
@ -77,13 +82,16 @@ export async function verifyCompanyLogos(
404
);
const repeated = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: png });
assert.deepEqual(CompanyLogoResponseSchema.parse(await repeated.json()), logo);
const detail = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
assert.equal(
CompanyResponseSchema.parse(await detail.json()).company.logoVersion,
logo.logoVersion
);
const list = await fetch(`${baseUrl}/companies`, { headers: { cookie } });
assert.equal(
CompaniesResponseSchema.parse(await list.json()).companies.find((item) => item.id === companyId)
?.logoVersion,
@ -97,11 +105,14 @@ export async function verifyCompanyLogos(
"base64"
),
});
assert.equal(replacement.status, 200);
const replaced = CompanyLogoResponseSchema.parse(await replacement.json());
assert.notEqual(replaced.logoVersion, logo.logoVersion);
assert.equal((await fetch(url, { headers: { cookie } })).status, 404);
const updatedUrl = `${baseUrl}${path}?version=${replaced.logoVersion}`;
assert.equal((await fetch(updatedUrl, { headers: { cookie } })).status, 200);
const current = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
const company = CompanyResponseSchema.parse(await current.json()).company;
@ -110,6 +121,7 @@ export async function verifyCompanyLogos(
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ name: company.name, active: company.active }),
});
assert.equal(update.status, 200);
assert.equal(
CompanyResponseSchema.parse(await update.json()).company.logoVersion,
@ -121,11 +133,13 @@ export async function verifyCompanyLogos(
headers: { ...headers, "content-type": "application/json" },
body: "{}",
});
assert.equal(removed.status, 200);
assert.equal(CompanyLogoResponseSchema.parse(await removed.json()).logoVersion, null);
}
assert.equal((await fetch(url, { headers: { cookie } })).status, 404);
assert.equal((await fetch(updatedUrl, { headers: { cookie } })).status, 404);
const cleared = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
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) {
const parsed = DatabaseEnvironmentSchema.safeParse(source);
if (!parsed.success) {
throw new Error("Configuración de persistencia o sesión incompleta.");
}
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. */
export function readServerEnvironment(environment: NodeJS.ProcessEnv) {
const result = ServerEnvironmentSchema.safeParse({ PORT: environment.PORT ?? "3001" });
if (!result.success) {
throw new Error("Invalid server configuration: PORT must be between 1 and 65535");
}
return result.data;
}

View File

@ -14,14 +14,19 @@ import {
import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common";
type Request = (path: string, body?: unknown) => Promise<Response>;
export async function verifyDashboard(request: Request, companyId: string, otherId: string) {
async function get(path: string) {
const response = await request(path);
assert.equal(response.status, 200, await response.clone().text());
return response.json();
}
const summary = DashboardSummarySchema.parse(await get("/dashboard/summary"));
const companies = DashboardCompaniesSchema.parse(await get("/dashboard/companies"));
assert.equal(
summary.assets,
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 interventions = InterventionListSchema.parse(await get("/interventions"));
assert.equal(
summary.tickets,
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]) {
const scoped = DashboardSummarySchema.parse(await get(`/dashboard/summary?companyId=${scope}`));
const row = companies.items.find((item) => item.id === scope);
assert.ok(row);
assert.equal(scoped.assets, row.assets);
assert.equal(scoped.resources, row.resources);
@ -62,20 +69,25 @@ export async function verifyDashboard(request: Request, companyId: string, other
const result = DashboardEntriesSchema.parse(
await get(`/dashboard/${block}${block.includes("?") ? "&" : "?"}companyId=${scope}`)
);
assert.ok(result.items.every((item) => item.companyId === scope));
assert.ok(result.items.length <= 10);
}
}
const work = DashboardEntriesSchema.parse(await get("/dashboard/work"));
assert.equal(work.total, summary.tickets + summary.interventions);
assert.ok(
work.items.every((item) => !["resolved", "cancelled", "finished"].includes(item.state ?? ""))
);
const priorityRank = (value: string | null) =>
["urgent", "high", "normal", "low"].indexOf(value ?? "normal");
for (let index = 1; index < work.items.length; index++) {
const previous = work.items[index - 1];
const current = work.items[index];
assert.ok(previous && current);
assert.ok(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"));
assert.equal(new Set(activity.items.map((item) => item.eventId)).size, activity.items.length);
assert.ok(activity.items.every((item) => item.eventId));
assert.equal(activity.total, activity.items.length);
const attention = DashboardAttentionSchema.parse(await get("/dashboard/attention"));
assert.equal(attention.items.length, 5);
for (const alert of attention.items) {
const details = DashboardEntriesSchema.parse(
await get(`/dashboard/details?issue=${alert.kind}`)
);
assert.equal(alert.count, details.total);
}
const beyond = DashboardEntriesSchema.parse(
await get("/dashboard/details?kind=asset&page=100000")
);
assert.equal(beyond.items.length, 0);
assert.equal(beyond.total, summary.assets);
for (const path of [
@ -112,24 +128,31 @@ export async function verifyDashboard(request: Request, companyId: string, other
export async function verifyDashboardBoundaries(request: Request) {
const created = await request("/companies", { name: "Dashboard vacío", active: true });
assert.equal(created.status, 201, await created.clone().text());
const companyId = CompanyResponseSchema.parse(await created.json()).company.id;
const empty = await request(`/dashboard/summary?companyId=${companyId}`);
const zero = DashboardSummarySchema.parse(await empty.json());
assert.deepEqual(zero, { companies: 1, tickets: 0, interventions: 0, assets: 0, resources: 0 });
const catalogResponse = await request("/resource-catalog/global");
const catalog = CatalogResponseSchema.parse(await catalogResponse.json());
const expiry = catalog.items.find((item) => item.builtinKey === "field:expires");
assert.ok(expiry);
const date = (offset: number) =>
new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
for (const [issue, key] of [
["certificate", "certificate"],
["license", "software_license"],
]) {
const template = catalog.items.find((item) => item.builtinKey === `template:${key}`);
assert.ok(template);
const expected: string[] = [];
for (const [offset, active] of [
[0, true],
[30, true],
@ -147,15 +170,19 @@ export async function verifyDashboardBoundaries(request: Request) {
characteristics: offset === null ? {} : { [expiry.id]: date(offset) },
},
});
assert.equal(response.status, 201, await response.clone().text());
const resource = ResourceResponseSchema.parse(await response.json()).resource;
if (active && (offset === 0 || offset === 30)) {
expected.push(resource.id);
}
}
const response = await request(`/dashboard/details?companyId=${companyId}&issue=${issue}`);
assert.equal(response.status, 200, await response.clone().text());
const result = DashboardEntriesSchema.parse(await response.json());
assert.equal(result.total, 2);
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(
await (await request(`/dashboard/details?companyId=${companyId}&kind=resource&page=2`)).json()
);
assert.equal(first.total, 12);
assert.equal(first.items.length, 10);
assert.equal(second.items.length, 2);
@ -182,9 +210,11 @@ export async function verifyDashboardInactiveAccess(request: Request, companyId:
isContact: false,
};
const created = await request(`/companies/${companyId}/people`, personValues);
assert.equal(created.status, 201, await created.clone().text());
const company = CompanyResponseSchema.parse(await created.json()).company;
const person = company.people.find((item) => item.name === personValues.name);
assert.ok(person);
const values = {
personId: person.id,
@ -199,25 +229,32 @@ export async function verifyDashboardInactiveAccess(request: Request, companyId:
notes: null,
};
const response = await request(`/remote-connections/${companyId}`, { values });
assert.equal(response.status, 201, await response.clone().text());
const connection = RemoteConnectionResponseSchema.parse(await response.json()).connection;
const deactivated = await request(`/companies/${companyId}/people/${person.id}/update`, {
...personValues,
active: false,
});
assert.equal(deactivated.status, 200, await deactivated.clone().text());
async function count() {
const response = await request(
`/dashboard/details?companyId=${companyId}&issue=inactive_person`
);
assert.equal(response.status, 200, await response.clone().text());
return DashboardEntriesSchema.parse(await response.json());
}
assert.ok((await count()).items.some((item) => item.id === person.id));
for (const state of ["removal_pending", "removed"]) {
const updated = await request(`/remote-connections/${companyId}/${connection.id}/update`, {
values: { ...values, state },
});
assert.equal(updated.status, 200, await updated.clone().text());
assert.equal(
(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 () => {
const environment = readDatabaseEnvironment(process.env);
// Este proceso solo puede limpiar la base de pruebas dedicada.
assert.equal(environment.DB_NAME, "metis_desk_test");
const database = createDatabase(environment);
@ -63,6 +64,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
legacyResourceFixtures: true,
});
const server = createApp(() => undefined, identity).listen(0, "127.0.0.1");
try {
await once(server, "listening");
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.
const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411";
const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898";
await database.query("INSERT INTO companies (id,name,active) VALUES (:id,'Legacy',1)", {
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",
{ replacements: { id: legacyPersonId }, type: QueryTypes.SELECT }
);
assert.equal(legacy[0]?.id, legacyPersonId);
assert.equal(legacy[0]?.email, "legacy@example.test");
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();
assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}/api`;
function browser() {
let cookie = "";
let csrf = "";
return {
async request(
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) }),
});
const newCookie = response.headers.get("set-cookie");
if (newCookie) {
cookie = newCookie.split(";")[0] ?? "";
}
return response;
},
async session() {
const response = await this.request("/auth/session");
assert.equal(response.status, 200);
const data = SessionResponseSchema.parse(await response.json());
csrf = data.csrfToken;
return data;
},
cookie: () => cookie,
};
}
async function expectError(response: Response, status: number, code: string) {
assert.equal(response.status, status);
assert.equal(IdentityErrorResponseSchema.parse(await response.json()).code, code);
}
assert.equal((await fetch(`${baseUrl}/asset-brands`)).status, 401);
const admin = browser();
const competitor = browser();
assert.equal((await admin.session()).setupRequired, true);
await competitor.session();
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",
password: "Admin123",
};
await expectError(
await admin.request("/auth/bootstrap", first, "https://other.example"),
403,
@ -256,12 +272,15 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
admin.request("/auth/bootstrap", first),
competitor.request("/auth/bootstrap", { ...first, email: "other@example.test" }),
]);
assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]);
const winnerIndex = attempts.findIndex((response) => response.status === 201);
const administrator = winnerIndex === 0 ? admin : competitor;
const winnerResponse = attempts[winnerIndex];
assert.ok(winnerResponse);
const initial = UserResponseSchema.parse(await winnerResponse.json()).user;
assert.equal(initial.isInitialAdmin, true);
assert.equal(initial.id.charAt(14), "7");
if (winnerIndex === 0) {
@ -301,8 +320,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
password: "User1234",
};
const created = await administrator.request("/users", person);
assert.equal(created.status, 201);
const user = UserResponseSchema.parse(await created.json()).user;
assert.equal(user.isInitialAdmin, false);
await expectError(
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"
);
const list = UsersResponseSchema.parse(await (await administrator.request("/users")).json());
assert.equal(list.users.length, 2);
assert.equal(JSON.stringify(list).includes("passwordHash"), false);
const operator = browser();
await operator.session();
await expectError(
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"
);
const oldSession = browser();
await oldSession.session();
assert.equal(
(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();
const changedPassword = "Nuevo123";
assert.equal(
(
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 oldSession.session()).user, null);
const resetPassword = "Reset123";
assert.equal(
(await administrator.request(`/users/${user.id}/reset-password`, { password: resetPassword }))
.status,
@ -405,6 +431,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
);
await operator.session();
const anonymousCompanyBrowser = browser();
await anonymousCompanyBrowser.session();
await expectError(await anonymousCompanyBrowser.request("/companies"), 401, "UNAUTHENTICATED");
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",
active: true,
});
assert.equal(companyCreated.status, 201);
const customer = CompanyResponseSchema.parse(await companyCreated.json()).company;
assert.equal(customer.id.charAt(14), "7");
const secondCreated = await operator.request("/companies", {
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",
active: true,
};
await expectError(
await operator.request(
`/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`,
personContact
);
assert.equal(addedContact.status, 201);
const afterContact = CompanyResponseSchema.parse(await addedContact.json()).company;
const contact = afterContact.contacts[0];
assert.ok(contact);
assert.equal(contact.companyId, customer.id);
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`,
personContact
);
assert.equal(crossContact.status, 404);
assert.equal(
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`,
{ ...personContact, name: "Ana Actualizada", active: false }
);
assert.equal(changedContact.status, 200);
assert.equal(
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`, {
name: "Almacén",
});
assert.equal(noAddressSite.status, 201);
const emptySite = CompanyResponseSchema.parse(await noAddressSite.json()).company.sites[0];
assert.ok(emptySite);
assert.equal(emptySite.address, null);
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`,
{ name: "Sede central", address: siteAddress }
);
assert.equal(changedSite.status, 200);
assert.equal(
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`,
{ name: "Ajena", address: null }
);
assert.equal(crossSite.status, 404);
const largeAddress = "Á".repeat(10000);
assert.equal(
(
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",
active: false,
});
assert.equal(editedCompany.status, 200);
const details = CompanyResponseSchema.parse(
await (await operator.request(`/companies/${customer.id}`)).json()
).company;
assert.equal(details.id, customer.id);
assert.equal(details.active, false);
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(
await (await operator.request("/companies")).json()
);
assert.equal(companiesList.companies.length, 2);
await assert.rejects(
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`,
{ name: "Sede central", address: null }
);
assert.equal(clearedSite.status, 200);
// Personas y departamentos: mismas fronteras de sesión, UUID y compañía.
const peoplePath = `/companies/${customer.id}`;
async function companyData(response: Response) {
assert.ok(response.status === 200 || response.status === 201);
return CompanyResponseSchema.parse(await response.json()).company;
}
async function companyError(response: Response, status: number, code: string) {
assert.equal(response.status, status);
assert.equal(CompanyErrorResponseSchema.parse(await response.json()).code, code);
}
const personBody = {
name: "Persona sin correo",
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)
);
const companyPerson = personResponse.people.find((item) => item.name === personBody.name);
assert.ok(companyPerson);
assert.equal(companyPerson.email, null);
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 })
);
const department = departmentResponse.departments.find((item) => item.name === "Compras");
assert.ok(department);
await companyError(
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];
assert.ok(foreignDepartment);
const assignmentBody = {
departmentId: department.id,
@ -612,6 +662,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
endDate: null,
isPrimary: true,
};
await companyError(
await operator.request(`${peoplePath}/people/${companyPerson.id}/assignments`, {
...assignmentBody,
@ -626,6 +677,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
const membership = assignmentResponse.assignments.find(
(item) => item.personId === companyPerson.id
);
assert.ok(membership);
assert.equal(membership.id.charAt(14), "7");
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(
(item) => item.name === "Administración"
);
assert.ok(secondDepartment);
await companyError(
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;
assert.equal(history?.length, 2);
assert.equal(history?.[0]?.position, "Responsable");
const repeatedMembership = await companyData(
@ -680,6 +734,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
editedAssignment
)
);
assert.equal(
repeatedMembership.assignments.find((item) => item.id === membership.id)?.history.length,
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 }
)
);
assert.equal(closed.assignments.find((item) => item.id === membership.id)?.history.length, 3);
await companyData(
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,
}),
]);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
// La FK compuesta impide pertenencias cruzadas aunque se intente escribir SQL directamente.
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",
{ type: QueryTypes.SELECT }
);
assert.equal(Number(userCount[0]?.total), 2);
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(
await (await operator.request(`/companies/${secondCompany.id}`)).json()
).company;
await verifyAccess((...args) => operator.request(...args), accessCompany, accessOther);
await verifyAssets(
(...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í.
const migratedRuntime = await createIdentityRuntime(database, environment);
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
try {
await once(migratedServer, "listening");
const address = z.object({ port: z.number() }).parse(migratedServer.address());
const session = await operator.session();
await verifyResourceMigration(
(path, body) =>
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",
{ type: QueryTypes.SELECT }
);
assert.equal(Number(countAfterAccess[0]?.total), 2);
assert.equal((await operator.request("/auth/logout", {})).status, 200);
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",
{ replacements: { id: user.id }, type: QueryTypes.SELECT }
);
assert.ok(stored[0]?.passwordHash.startsWith("scrypt-v1$"));
assert.equal(JSON.stringify(stored).includes(resetPassword), false);
// Al acumular fallos se rechazan los siguientes intentos.
let limited = false;
for (let index = 0; index < 12; index += 1) {
const response = await operator.request("/auth/login", {
email: "missing@example.test",
password: "wrong",
});
if (response.status === 429) {
limited = true;
break;

View File

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

View File

@ -5,6 +5,7 @@ export type HttpLogEntry = Readonly<{
statusCode: number;
durationMs: number;
}>;
export type HttpLogger = (entry: HttpLogEntry) => void;
// 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,
resources.services.inventory
);
await resources.migrateLegacy();
const dashboard = companies.initializeDashboard(
assets.services.dashboard,
@ -52,6 +53,7 @@ export async function createIdentityRuntime(
maxAge: 8 * 60 * 60 * 1000,
},
});
return {
register: (app: Parameters<typeof identity.register>[0]) => {
identity.register(app);

View File

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

View File

@ -12,17 +12,21 @@ import {
import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyInterventions(request: Request, companyId: string, otherId: string) {
const path = `/interventions/${companyId}`;
const lookupResponse = await request(`${path}/lookups`);
assert.equal(lookupResponse.status, 200);
const lookups = InterventionLookupsSchema.parse(await lookupResponse.json());
const contextResponse = await request("/interventions");
assert.equal(contextResponse.status, 200, await contextResponse.clone().text());
const context = InterventionListSchema.parse(await contextResponse.json());
const technician = context.technicians.find((item) => item.active);
const asset = lookups.assets.find((item) => item.active);
const ticket = lookups.tickets[0];
assert.ok(technician && asset && ticket);
const values: InterventionInput = {
subject: "Actuación sencilla",
@ -41,17 +45,24 @@ export async function verifyInterventions(request: Request, companyId: string, o
steps: [],
};
const body = { values, template: null, requestKey: randomUUID(), expectedRevisionId: null };
async function save(url: string, payload: unknown, status = 201) {
const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text());
return InterventionResponseSchema.parse(await response.json()).intervention;
}
async function error(url: string, payload: unknown, status: number, code: string) {
const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}
const simple = await save(path, body);
assert.equal(simple.id[14], "7");
assert.match(simple.reference, /^I-[0-9]{6,}$/);
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);
await error(path, { ...body, values: { ...values, subject: "Otra" } }, 409, "REQUEST_KEY_REUSED");
await error(`/interventions/${otherId}`, body, 409, "REQUEST_KEY_REUSED");
const update = (item: InterventionDTO, next: InterventionInput) => ({
values: next,
template: null,
requestKey: randomUUID(),
expectedRevisionId: item.history.at(-1)?.id,
});
const same = await save(`${path}/${simple.id}/update`, update(simple, values), 200);
assert.equal(same.history.length, 1);
await error(
path,
@ -86,6 +100,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
}),
200
);
assert.equal(finished.history.length, 2);
assert.equal(finished.values.state, "finished");
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 }))
)
);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]);
const linked = await save(path, {
...body,
@ -111,6 +127,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
affected: [{ kind: "asset", targetId: asset.id }],
},
});
assert.equal(linked.ticketReference, ticket.reference);
await error(
`/interventions/${otherId}`,
@ -137,6 +154,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
const globalTemplate = lookups.templates.find(
(item) => item.values.active && item.companyId === null
);
assert.ok(globalTemplate);
const step = {
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] },
};
const templated = await save(path, templatedBody);
assert.equal(templated.procedure?.values.name, globalTemplate.values.name);
await error(
path,
@ -172,6 +191,7 @@ export async function verifyInterventions(request: Request, companyId: string, o
expectedRevisionId: globalTemplate.history.at(-1)?.id,
}
);
assert.equal(beforeTemplateUpdate.status, 200);
InterventionTemplateResponseSchema.parse(await beforeTemplateUpdate.json());
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(
await (await request(path)).json()
).interventions.find((item) => item.id === templated.id);
assert.deepEqual(persisted?.procedure, templated.procedure);
await error(
`${path}/${templated.id}/update`,
@ -214,11 +235,13 @@ export async function verifyInterventions(request: Request, companyId: string, o
}),
200
);
assert.equal(done.values.steps[0]?.notes, "No utiliza correo");
assert.equal(done.procedure?.revisionId, templated.procedure?.revisionId);
const ticketAfter = TicketListSchema.parse(
await (await request(`/tickets/${companyId}`)).json()
).tickets.find((item) => item.id === ticket.id);
assert.equal(ticketAfter?.values.state, ticket.values.state);
await error(
`${path}/${done.id}/update`,
@ -227,10 +250,13 @@ export async function verifyInterventions(request: Request, companyId: string, o
"INVALID_REQUEST"
);
const other = await save(`/interventions/${otherId}`, { ...body, requestKey: randomUUID() });
assert.notEqual(other.reference, simple.reference);
const all = InterventionListSchema.parse(await (await request("/interventions")).json());
assert.ok(all.interventions.some((item) => item.id === other.id));
const scoped = InterventionListSchema.parse(await (await request(path)).json());
assert.ok(scoped.interventions.every((item) => item.companyId === companyId));
assert.equal((await request(path, { ...body, unexpected: true })).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 { UniqueID } from "../../../packages/rdx-ddd/src/index";
export async function verifyInventory(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
companyId: string,
otherId: string
) {
const path = `/assets/${companyId}`;
async function save(url: string, values: unknown) {
const response = await request(url, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text());
return AssetResponseSchema.parse(await response.json()).asset;
}
async function error(url: string, values: unknown, code: string, status = 409) {
const response = await request(url, { values });
assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}
async function read(url = path) {
const response = await request(url);
assert.equal(response.status, 200);
return InventoryResponseSchema.parse(await response.json());
}
const catalog = CatalogResponseSchema.parse(
await (await request(`/asset-catalog/${companyId}`)).json()
);
const monitor = catalog.items.find((item) => item.values.name === "Monitor");
assert.ok(monitor);
const laptop = catalog.items.find((item) => item.values.name === "Portátil");
assert.ok(laptop);
const inch = catalog.items.find((item) => item.values.name === "Pulgadas");
assert.ok(inch);
async function site(company: string) {
const response = await request(`/companies/${company}/sites`, {
name: "Sede inventario",
address: null,
});
assert.equal(response.status, 201);
const saved = CompanyResponseSchema.parse(await response.json()).company.sites.at(-1);
assert.ok(saved);
return saved;
}
const ownSite = await site(companyId);
const foreignSite = await site(otherId);
const input = {
@ -60,8 +78,10 @@ export async function verifyInventory(
notes: "",
characteristics: {},
};
assert.equal((await read()).assets.length, 0);
const created = await save(path, input);
assert.equal(created.history.length, 1);
assert.equal(created.id.charAt(14), "7");
assert.equal(created.history[0]?.id.charAt(14), "7");
@ -85,9 +105,11 @@ export async function verifyInventory(
status: "in_use",
characteristics: { [inch.id]: 24 },
});
assert.equal(inUse.id, created.id);
assert.equal(inUse.history.length, 2);
const unchanged = await save(`${path}/${created.id}/update`, inUse.values);
assert.deepEqual(unchanged.history, inUse.history);
await error(
`${path}/${created.id}/update`,
@ -98,6 +120,7 @@ export async function verifyInventory(
...inUse.values,
status: "retired",
});
assert.equal(retired.history.length, 3);
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 }],
},
});
assert.equal(templateResponse.status, 201);
const templateId = SaveDefinitionResponseSchema.parse(await templateResponse.json()).savedId;
await error(path, { ...input, templateId }, "REQUIRED_CHARACTERISTICS");
await error(`/assets/${otherId}`, { ...input, templateId }, "TEMPLATE_UNAVAILABLE");
const custom = await save(path, {
@ -121,6 +146,7 @@ export async function verifyInventory(
characteristics: { [inch.id]: 0 },
});
const revisionId = custom.history[0]?.id;
assert.equal(
(
await request(`/asset-catalog/${companyId}/${templateId}/update`, {
@ -135,6 +161,7 @@ export async function verifyInventory(
200
);
const current = (await read()).assets.find((asset) => asset.id === custom.id);
assert.ok(current);
assert.equal(current.specification.templateName, "Monitor obligatorio inventario");
assert.equal(current.specification.fields[0]?.required, true);
@ -154,8 +181,10 @@ export async function verifyInventory(
...custom.values,
notes: "Plantilla conservada",
});
assert.equal(updated.history[0]?.id, revisionId);
const stored = (await read()).assets.find((asset) => asset.id === updated.id);
assert.deepEqual(stored, updated);
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";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyMail(
request: Request,
companyId: string,
@ -41,12 +42,14 @@ export async function verifyMail(
const second = assets[1];
const otherPerson = company.people.find((item) => item.active);
const foreignPerson = other.people[0];
assert.ok(first && second && otherPerson && foreignPerson);
for (const asset of [first, second]) {
if (asset.values.status === "retired") {
const response = await request(`/assets/${companyId}/${asset.id}/update`, {
values: { ...asset.values, status: "available" },
});
assert.equal(response.status, 200, await response.clone().text());
}
}
@ -59,10 +62,13 @@ export async function verifyMail(
isContact: false,
};
const peopleResponse = await request(`/companies/${companyId}/people`, personFields);
assert.equal(peopleResponse.status, 201);
const person = CompanyResponseSchema.parse(await peopleResponse.json()).company.people.at(-1);
assert.ok(person);
const today = new Date().toISOString().slice(0, 10);
const profile = async (personId: string, assetId: string) => {
const response = await request(`/work-profiles/${companyId}`, {
values: {
@ -74,28 +80,40 @@ export async function verifyMail(
notes: null,
},
});
assert.equal(response.status, 201, await response.clone().text());
return WorkProfileResponseSchema.parse(await response.json()).profile;
};
const primary = await profile(person.id, first.id);
const mobileProfile = await profile(person.id, second.id);
const sharedProfile = await profile(otherPerson.id, first.id);
const saveBox = async (values: MailboxInput, id?: string) => {
const response = await request(boxPath + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
const data = MailboxesResponseSchema.parse(await response.json());
const box = data.mailboxes.find((item) => item.id === data.savedId);
assert.ok(box);
return box;
};
const saveUse = async (values: MailboxUseInput, id?: string) => {
const response = await request(`${boxPath}/uses${id ? `/${id}/update` : ""}`, { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
const data = MailboxesResponseSchema.parse(await response.json());
const use = data.uses.find((item) => item.id === data.savedId);
assert.ok(use);
return use;
};
const boxFields: MailboxInput = {
address: "ventas@example.test",
kind: "corporate",
@ -113,6 +131,7 @@ export async function verifyMail(
});
const useFields = { mailboxId: box.id, personId: person.id, startDate: today, endDate: null };
const authorization = await saveUse(useFields);
await saveUse({ ...useFields, mailboxId: external.id });
await saveUse({ ...useFields, personId: otherPerson.id });
assert.equal(box.id[14], "7");
@ -121,6 +140,7 @@ export async function verifyMail(
const duplicateBox = await request(boxPath, {
values: { ...boxFields, address: "VENTAS@EXAMPLE.TEST" },
});
assert.equal(duplicateBox.status, 409);
assert.equal((await request(`${boxPath}/uses`, { values: useFields })).status, 409);
assert.equal(
@ -144,16 +164,22 @@ export async function verifyMail(
state: "configured",
notes: null,
};
const save = async (values: MailConfigurationInput, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
return MailConfigurationResponseSchema.parse(await response.json()).configuration;
};
const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status, await response.clone().text());
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, mailboxId: foreignPerson.id }, "MAIL_SCOPE_MISMATCH");
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 }),
]);
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
const created = MailConfigurationResponseSchema.parse(
await (a.status === 201 ? a : b).json()
).configuration;
assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7");
assert.ok(created.history[0]?.actorName);
@ -182,6 +210,7 @@ export async function verifyMail(
state: "pending",
method: "pop",
});
assert.equal(new Set([created.id, personal.id, mobile.id, shared.id]).size, 4);
await error(
{ ...input, profileId: mobileProfile.id, state: "removed" },
@ -198,11 +227,13 @@ export async function verifyMail(
const reviewed = MailConfigurationsResponseSchema.parse(
await (await request(path)).json()
).configurations;
assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true);
assert.equal(reviewed.find((item) => item.id === personal.id)?.needsReview, false);
await error({ ...input, method: "pop" }, "MAIL_NOT_AUTHORIZED", created.id);
const pending = await save({ ...input, state: "removal_pending" }, created.id);
const removed = await save({ ...input, state: "removed" }, created.id);
assert.equal(pending.history.length, 2);
assert.equal(removed.history.length, 3);
assert.equal(removed.history[0]?.values.state, "configured");
@ -212,19 +243,23 @@ export async function verifyMail(
...personFields,
active: false,
});
assert.equal(deactivate.status, 200);
const inactive = MailConfigurationsResponseSchema.parse(
await (await request(path)).json()
).configurations;
assert.equal(inactive.find((item) => item.id === mobile.id)?.needsReview, true);
const never = await save(
{ ...input, mailboxId: external.id, state: "never_configured" },
personal.id
);
assert.equal(never.history.length, 2);
// 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);
assert.equal(active.status, 200);
await saveUse(
{ ...useFields, mailboxId: external.id, startDate: today, endDate: today },
@ -233,8 +268,10 @@ export async function verifyMail(
const systemResponse = await request(`/access/${companyId}`, {
values: { kind: "system", name: "Correo legado", active: true },
});
assert.equal(systemResponse.status, 201);
const system = AccessResponseSchema.parse(await systemResponse.json()).items.at(-1);
assert.ok(system);
const accountResponse = await request(`/access/${companyId}`, {
values: {
@ -248,12 +285,15 @@ export async function verifyMail(
notes: null,
},
});
assert.equal(accountResponse.status, 201);
const account = AccessResponseSchema.parse(await accountResponse.json()).items.at(-1);
assert.ok(account);
const legacyId = randomUUID();
const revisionId = randomUUID();
const actorId = created.history[0]?.actorId;
assert.ok(actorId);
const legacyValues = {
personId: person.id,
@ -265,6 +305,7 @@ export async function verifyMail(
notes: "Conservar",
};
const raw = JSON.stringify(legacyValues);
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)",
{
@ -292,6 +333,7 @@ export async function verifyMail(
const legacy = MailConfigurationsResponseSchema.parse(
await (await request(path)).json()
).configurations.find((item) => item.id === legacyId);
assert.ok(legacy?.legacy);
assert.equal(legacy.needsReview, true);
await error({ ...input, mailboxId: external.id }, "MAIL_LEGACY_TARGET_MISMATCH", legacyId);
@ -300,8 +342,10 @@ export async function verifyMail(
address: legacyValues.address,
kind: "external",
});
await saveUse({ ...useFields, mailboxId: legacyBox.id });
const adopted = await save({ ...input, mailboxId: legacyBox.id, notes: "Conservar" }, legacyId);
assert.equal(adopted.id, legacyId);
assert.equal(adopted.legacy, false);
assert.equal(adopted.history[0]?.id, revisionId);
@ -312,6 +356,7 @@ export async function verifyMail(
{ replacements: { id: revisionId }, type: QueryTypes.SELECT }
);
const original = persisted[0]?.values;
assert.ok(original);
assert.deepEqual(typeof original === "string" ? JSON.parse(original) : original, legacyValues);
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";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyPrinters(
request: Request,
companyId: string,
@ -27,13 +28,16 @@ export async function verifyPrinters(
active: true,
isContact: false,
});
assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person);
const inventory = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
const host = inventory.assets.find((item) => item.values.status !== "retired");
assert.ok(host);
const catalog = CatalogResponseSchema.parse(
await (await request(`/asset-catalog/${companyId}`)).json()
@ -42,7 +46,9 @@ export async function verifyPrinters(
(item) =>
item.values.kind === "template" && item.values.name === "Impresora" && item.effectiveActive
);
assert.ok(template);
const addPrinter = async (name: string) => {
const response = await request(`/assets/${companyId}`, {
values: {
@ -54,13 +60,17 @@ export async function verifyPrinters(
characteristics: {},
},
});
assert.equal(response.status, 201);
const current = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
).assets.find((item) => item.values.name === name);
assert.ok(current);
return current;
};
const first = await addPrinter("Impresora prueba USB");
const second = await addPrinter("Impresora prueba red");
const profileValues = {
@ -72,6 +82,7 @@ export async function verifyPrinters(
notes: null,
};
const profileResponse = await request(`/work-profiles/${companyId}`, { values: profileValues });
assert.equal(profileResponse.status, 201);
const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile;
const path = `/printer-configurations/${companyId}`;
@ -83,16 +94,22 @@ export async function verifyPrinters(
state: "configured",
notes: null,
};
const save = async (values: PrinterConfigurationInput, id?: string) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, id ? 200 : 201);
return PrinterConfigurationResponseSchema.parse(await response.json()).configuration;
};
const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(path + (id ? `/${id}/update` : ""), { values });
assert.equal(response.status, status);
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, printerId: randomUUID() }, "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 }),
]);
assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]);
const successful = attempts.find((response) => response.status === 201);
assert.ok(successful);
const created = PrinterConfigurationResponseSchema.parse(await successful.json()).configuration;
assert.equal(created.id[14], "7");
assert.equal(created.history[0]?.id[14], "7");
assert.ok(created.history[0]?.actorName);
@ -118,6 +138,7 @@ export async function verifyPrinters(
isDefault: false,
state: "pending",
});
await error({ ...input, printerId: second.id }, "PRINTER_TARGET_IMMUTABLE", created.id);
await error(
{ ...input, state: "removed", isDefault: false },
@ -137,10 +158,12 @@ export async function verifyPrinters(
const archive = await request(`/work-profiles/${companyId}/${profile.id}/update`, {
values: { ...profileValues, active: false },
});
assert.equal(archive.status, 200);
const reviewed = PrinterConfigurationsResponseSchema.parse(
await (await request(path)).json()
).configurations;
assert.equal(reviewed.find((item) => item.id === created.id)?.needsReview, true);
await error(
{ ...network.values, state: "configured" },
@ -148,14 +171,17 @@ export async function verifyPrinters(
network.id
);
const noted = await save({ ...created.values, notes: "Retirar tras salida" }, created.id);
assert.equal(noted.history.length, 2);
assert.equal(noted.history[0]?.values.notes, null);
const removal = await save(
{ ...noted.values, state: "removal_pending", isDefault: false },
created.id
);
assert.equal(removal.needsReview, true);
const removed = await save({ ...removal.values, state: "removed" }, created.id);
assert.equal(removed.needsReview, false);
await error({ ...removed.values, notes: "Otro" }, "INVALID_PRINTER_TRANSITION", created.id);
assert.equal(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyWorkstations(
request: Request,
companyId: string,
@ -26,6 +27,7 @@ export async function verifyWorkstations(
.assets[0];
const first = inventory.assets[0];
const second = inventory.assets[1];
assert.ok(first && second && foreign);
const personFields = {
name: "Persona puesto",
@ -36,14 +38,18 @@ export async function verifyWorkstations(
isContact: false,
};
const personResponse = await request(`/companies/${companyId}/people`, personFields);
assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person);
const otherPersonResponse = await request(`/companies/${otherId}/people`, personFields);
assert.equal(otherPersonResponse.status, 201);
const otherPerson = CompanyResponseSchema.parse(
await otherPersonResponse.json()
).company.people.at(-1);
assert.ok(otherPerson);
const input = {
name: "Puesto Ana",
@ -53,16 +59,22 @@ export async function verifyWorkstations(
assetIds: [first.id, second.id],
sharedAssetIds: [],
};
const save = async (values: unknown, id?: string) => {
const response = await request(id ? `${path}/${id}/update` : path, { values });
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
return WorkstationResponseSchema.parse(await response.json()).workstation;
};
const error = async (values: unknown, code: string, id?: string, status = 409) => {
const response = await request(id ? `${path}/${id}/update` : path, { values });
assert.equal(response.status, status);
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, siteId: other.sites[0]?.id }, "SITE_NOT_FOUND", undefined, 404);
await error({ ...input, personId: otherPerson.id }, "PERSON_UNAVAILABLE");
@ -74,9 +86,11 @@ export async function verifyWorkstations(
request(path, { values: input }),
request(path, { values: { ...input, name: "Puesto conflictivo" } }),
]);
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
const success = a.status === 201 ? a : b;
const created = WorkstationResponseSchema.parse(await success.json()).workstation;
assert.equal(created.id.charAt(14), "7");
assert.equal(created.history[0]?.id.charAt(14), "7");
assert.ok(created.history[0]?.actorName);
@ -84,8 +98,10 @@ export async function verifyWorkstations(
{ ...created.values, assetIds: [...created.values.assetIds].reverse() },
created.id
);
assert.deepEqual(unchanged, created);
const empty = await save({ ...input, name: "Puesto vacío", siteId: null, assetIds: [] });
assert.equal(
(
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: [created.id] }, "ASSET_NOT_FOUND", empty.id, 404);
const inactive = await save({ ...created.values, active: false }, created.id);
assert.equal(inactive.history.length, 2);
const receiver = await save({ ...empty.values, assetIds: [first.id] }, empty.id);
await error({ ...created.values, active: true }, "ASSET_IN_WORKSTATION", created.id);
const removed = await save({ ...receiver.values, assetIds: [] }, receiver.id);
assert.equal(removed.history.length, 3);
const restored = await save(created.values, created.id);
assert.equal(restored.history.length, 3);
assert.deepEqual(restored.history[1]?.values.assetIds, created.values.assetIds);
const foreignUpdate = await request(`/assets/${otherId}/workstations/${created.id}/update`, {
values: created.values,
});
assert.equal(foreignUpdate.status, 404);
await assert.rejects(
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] };
await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
replacements: { values: JSON.stringify(corruption), id: restored.id },
});
@ -131,6 +153,7 @@ export async function verifyWorkstations(
const current = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
assert.deepEqual(
current.workstations.find((item) => item.id === restored.id),
restored
@ -143,6 +166,7 @@ export async function verifyWorkstations(
// Las instantáneas anteriores se leen sin reescribir identidades ni historia.
const legacyRoot = { ...restored.values };
const { sharedAssetIds: _legacyShared, ...legacyValues } = legacyRoot;
await database.query(
"UPDATE workstations SET " +
String.fromCharCode(96) +
@ -196,11 +220,13 @@ export async function verifyWorkstations(
const sharedInput = { ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] };
const sharedOne = await save({ ...sharedInput, name: "Compartido uno" });
const sharedTwo = await save({ ...sharedInput, name: "Compartido dos" });
await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION");
await save({ ...sharedOne.values, sharedAssetIds: [] }, sharedOne.id);
await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION");
await save({ ...sharedTwo.values, active: false }, sharedTwo.id);
const exclusive = await save({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] });
await error({ ...sharedTwo.values, active: true }, "ASSET_IN_WORKSTATION", sharedTwo.id);
await save({ ...exclusive.values, active: false }, exclusive.id);
const race = await Promise.all([
@ -216,5 +242,6 @@ export async function verifyWorkstations(
values: { ...sharedInput, name: "Uso compartido concurrente", sharedAssetIds: [second.id] },
}),
]);
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
</DashboardNavigationButton>
);
assert.ok(markup.includes('aria-label="Open tickets: 2"'));
assert.ok(markup.includes('aria-pressed="true"'));
assert.ok(markup.includes('disabled=""'));
@ -48,6 +49,7 @@ test("los estados del dashboard mantienen texto observable además del color", (
const markup = renderToStaticMarkup(
<DashboardStateBadge state={state}>{label}</DashboardStateBadge>
);
assert.ok(markup.includes(`>${label}</span>`));
if (state === null) {
assert.equal(markup.includes("data-state="), false);
@ -72,6 +74,7 @@ test("las tablas del dashboard conservan caption, cabeceras semánticas y conten
</tbody>
</DashboardTable>
);
assert.match(markup, /<caption[^>]*>Pending work<\/caption>/);
assert.ok(markup.includes('<th scope="col">Reference</th>'));
assert.ok(markup.includes("<td>T-2026-001</td>"));
@ -83,12 +86,14 @@ async function renderPage(
seed?: (client: QueryClient) => void
) {
const { instance, initialization } = createAppI18n(language);
await initialization;
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, retryOnMount: false, staleTime: Number.POSITIVE_INFINITY },
},
});
if (session) {
client.setQueryData(identityKeys.session, session);
}
@ -105,12 +110,14 @@ async function renderPage(
client.clear();
}
}
test("el acceso protege el contenido y traduce la carga de sesión", async () => {
for (const [language, pending] of [
["es", "Comprobando acceso"],
["en", "Checking access"],
] as const) {
const markup = await renderPage(language);
assert.equal(markup.includes("Tu espacio de soporte"), false);
assert.equal(markup.includes("Your support workspace"), false);
assert.match(markup, /role="combobox"/);
@ -126,11 +133,13 @@ test("la configuración única y el login presentan sus formularios traducidos s
] as const) {
const snapshot = { user: null, csrfToken: "a".repeat(64), setupRequired: true };
const setup = await renderPage(language, snapshot);
assert.ok(setup.includes(setupTitle));
assert.match(setup, /autoComplete="new-password"/);
assert.match(setup, /minLength="8"/);
assert.equal(setup.includes("Tu espacio de soporte"), false);
const login = await renderPage(language, { ...snapshot, setupRequired: false });
assert.ok(login.includes(loginTitle));
assert.match(login, /autoComplete="current-password"/);
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 () => {
const snapshot = { user, csrfToken: "a".repeat(64), setupRequired: false };
const normal = await renderPage("es", snapshot);
assert.ok(normal.includes("Tu espacio de soporte"));
assert.ok(normal.includes("Mi contraseña"));
assert.ok(normal.includes("Salir"));
assert.equal(normal.includes('href="/settings/users"'), false);
const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } });
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);
}
);
assert.ok(html.includes(`aria-label="${spaces}"`));
assert.ok(html.includes(language === "es" ? "Trabajo pendiente" : "Pending work"));
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 () => {
const session = { user, csrfToken: "a".repeat(64), setupRequired: false };
for (const language of ["es", "en"]) {
const loading = await renderPage(language, session);
assert.ok(loading.includes("—"));
assert.ok(loading.includes('role="status"'));
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: [] });
});
assert.ok(
empty.includes(
language === "es"
@ -222,8 +237,10 @@ test("el dashboard distingue carga, vacío y fallo parcial sin reemplazar métri
const query = client
.getQueryCache()
.build(client, { queryKey: dashboardKeys.block("activity") });
query.setState({ status: "error", error: new Error("failed"), fetchStatus: "idle" });
});
assert.ok(partial.includes('role="alert"'));
assert.ok(partial.includes(">23</strong>"));
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", () => {
const item = { id: "entry", companyId: "company" };
assert.deepEqual(entryDestination({ ...item, kind: "ticket" }), {
area: "tickets",
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 () => {
const session = { user, csrfToken: "a".repeat(64), setupRequired: false };
for (const language of ["es", "en"]) {
const html = await renderPage(language, session, (client) => {
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",
actorName: "Operador test",
};
client.setQueryData(dashboardKeys.block("work"), { items: [entry], total: 1 });
client.setQueryData(dashboardKeys.block("activity"), { items: [entry], total: 1 });
client.setQueryData(dashboardKeys.block("attention"), {
@ -301,6 +321,7 @@ test("los bloques del dashboard presentan datos, avisos y actividad reales en am
total: 1,
});
});
assert.ok(html.includes("Revisar acceso"));
assert.ok(html.includes("T-2026-001"));
assert.ok(html.includes("Operador test"));

View File

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

View File

@ -5,13 +5,17 @@ import { createAppI18n } from "./i18n";
function translationLeaves(value: unknown, prefix = ""): Record<string, string> {
assert.ok(value !== null && typeof value === "object" && !Array.isArray(value));
return Object.fromEntries(
Object.entries(value).flatMap(([key, content]) => {
const path = prefix ? `${prefix}.${key}` : key;
if (typeof content === "string") {
assert.ok(content.trim(), `Traducción vacía: ${path}`);
return [[path, content]];
}
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 () => {
const { instance, initialization } = createAppI18n();
await initialization;
for (const namespace of ["core", "identity", "companies", "access", "assets", "resources"]) {
const spanish = translationLeaves(instance.getResourceBundle("es", namespace));
const english = translationLeaves(instance.getResourceBundle("en", namespace));
assert.deepEqual(Object.keys(english).sort(), Object.keys(spanish).sort(), namespace);
for (const [key, value] of Object.entries(spanish)) {
const translated = english[key];
assert.equal(typeof translated, "string");
assert.ok(translated);
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 () => {
const { instance, initialization } = createAppI18n("en");
await initialization;
assert.equal(instance.t("language"), "Language");
assert.equal(instance.t("brands.title", { ns: "assets" }), "Brands");

View File

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

View File

@ -9,6 +9,7 @@ import { createAppI18n } from "./i18n";
import "./global.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
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", () => {
const destination = "/companies/019b8838-44af-776d-a406-af74452b6f26/people";
const login = `/login?${new URLSearchParams({ returnTo: destination })}`;
assert.equal(appRedirect(destination, ANONYMOUS), login);
assert.equal(appRedirect(login, ANONYMOUS), undefined);
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) {
const returnTo = safeReturnTo(requested);
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 pathname = url.pathname.replace(/\/+$/u, "") || "/";
const accessPage = pathname === "/login" || pathname === "/setup";
if (!session.user) {
const target = session.setupRequired ? "/setup" : "/login";
const requested = accessPage ? url.searchParams.get("returnTo") : location;
const destination = accessHref(target, requested);
return destination === location ? undefined : destination;
}
if (accessPage) {
const requested = url.searchParams.get("returnTo");
return safeReturnTo(requested) ?? "/dashboard";
}
if (pathname === "/") {
return `/dashboard${url.search}`;
}
const canonical = `${pathname}${url.search}`;
return canonical === location ? undefined : canonical;
}

View File

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

View File

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

View File

@ -10,6 +10,7 @@
"ignoreUnknown": true,
"includes": [
"**",
"!!packages/shadcn-ui",
"!!**/node_modules",
"!!**/.next",
"!!**/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 { AssetCatalog } from "../domain/asset-catalog.aggregate";
export function buildCatalogResponse(
catalog: AssetCatalog,
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 { AssetCatalogError } from "../domain/asset-definition";
export interface IAssetCatalogRepository {
get(scope: Maybe<UniqueID>): Promise<Result<AssetCatalog, AssetCatalogError>>;
update(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ export type InventoryContext = Readonly<{
people: readonly { id: UniqueID; active: boolean }[];
actorNames: ReadonlyMap<string, string>;
}>;
export interface IAssetRepository {
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
saveWorkstation(
@ -42,10 +43,13 @@ export interface IAssetRepository {
action: (context: InventoryContext) => Result<Asset, AssetCatalogError>
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
}
export function validateAssetReferences(context: InventoryContext, values: AssetValues) {
const next = values.toPrimitive();
if (next.siteId && !context.sites.some((site) => site.toPrimitive() === next.siteId)) {
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
}
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 { validateAssetReferences } from "./asset.repository";
import { mapAssetInput } from "./asset-input.mapper";
export class AssetUseCases {
constructor(private readonly repository: IAssetRepository) {}
get(companyId: string) {
const company = UniqueID.create(companyId);
if (!company.isSuccess) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.get(company.data);
}
save(companyId: string, id: string | undefined, input: AssetInput, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined;
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.save(company.data, (context) => {
const previous = identity?.isSuccess
? context.assets.find((asset) => asset.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
}
const mapped = mapAssetInput(input, company.data, context, previous);
if (!mapped.isSuccess) {
return Result.fail(mapped.error);
}
const references = validateAssetReferences(context, mapped.data.values);
if (!references.isSuccess) {
return Result.fail(references.error);
}
const now = new Date().toISOString();
return previous
? previous.update(mapped.data.values, actor.data, now)
: Asset.create(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import type { SupportConnectionDTO, SupportToolDTO } from "../../common/support";
import type { SupportConnection } from "../domain/support-connection.aggregate";
import type { SupportTool } from "../domain/support-tool.aggregate";
export function buildSupportTool(tool: SupportTool): SupportToolDTO {
return {
id: tool.id.toPrimitive(),
@ -9,6 +10,7 @@ export function buildSupportTool(tool: SupportTool): SupportToolDTO {
builtin: tool.builtin,
};
}
export function buildSupport(
connection: SupportConnection,
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 { SupportTool } from "../domain/support-tool.aggregate";
import type { SupportError } from "../domain/support-values";
export type SupportContext = Readonly<{
connections: readonly SupportConnection[];
tools: readonly SupportTool[];
assetRetired: boolean;
actorNames: ReadonlyMap<string, string>;
}>;
export interface ISupportRepository {
remove(company: UniqueID, asset: UniqueID, id: UniqueID): Promise<Result<void, 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 { mapSupportInput } from "./support-input.mapper";
export class SupportUseCases {
constructor(private readonly repository: ISupportRepository) {}
get(companyId: string, assetId: string) {
const company = UniqueID.create(companyId);
const asset = UniqueID.create(assetId);
if (!(company.isSuccess && asset.isSuccess)) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
}
return this.repository.get(company.data, asset.data);
}
remove(companyId: string, assetId: string, id: string) {
const company = UniqueID.create(companyId);
const asset = UniqueID.create(assetId);
const identity = UniqueID.create(id);
if (!(company.isSuccess && asset.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, asset.data, identity.data);
}
tools() {
return this.repository.tools();
}
save(
companyId: string,
assetId: string,
@ -41,38 +50,47 @@ export class SupportUseCases {
const asset = UniqueID.create(assetId);
const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined;
if (
!(company.isSuccess && asset.isSuccess && actor.isSuccess) ||
(identity && !identity.isSuccess)
) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
}
return this.repository.save(company.data, asset.data, (context) => {
const previous = identity?.isSuccess
? context.connections.find((item) => item.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new SupportError("CONNECTION_NOT_FOUND"));
}
const values = mapSupportInput(input, context, previous);
if (!values.isSuccess) {
return Result.fail(values.error);
}
const now = new Date().toISOString();
return previous
? previous.update(values.data, actor.data, now)
: SupportConnection.create(company.data, asset.data, values.data, actor.data, now);
});
}
saveTool(id: string | undefined, input: SupportToolInput) {
const identity = id ? UniqueID.create(id) : undefined;
if (identity && !identity.isSuccess) {
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
}
return this.repository.saveTool((tools) => {
const previous = identity?.isSuccess
? tools.find((item) => item.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new SupportError("TOOL_NOT_FOUND"));
}
@ -87,6 +105,7 @@ export class SupportUseCases {
) {
return Result.fail(new SupportError("TOOL_EXISTS"));
}
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 { InventoryContext } from "./asset.repository";
export function mapWorkstationInput(
input: WorkstationInput,
context: InventoryContext,
previous?: Workstation
) {
const mapped = WorkstationValues.create(input);
if (!mapped.isSuccess) {
return mapped;
}
const values = mapped.data.toPrimitive();
if (values.siteId && !context.sites.some((id) => id.toPrimitive() === values.siteId)) {
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
}
const person = context.people.find((item) => item.id.toPrimitive() === values.personId);
if (
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]) {
const asset = context.assets.find((item) => item.id.toPrimitive() === id);
if (!asset) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
}
@ -45,5 +50,6 @@ export function mapWorkstationInput(
return Result.fail(new AssetCatalogError("ASSET_IN_WORKSTATION"));
}
}
return mapped;
}

View File

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

View File

@ -7,27 +7,34 @@ import type { WorkstationInput } from "../domain/workstation-values";
import type { IAssetRepository } from "./asset.repository";
import { mapWorkstationInput } from "./workstation-input.mapper";
export class WorkstationUseCases {
constructor(private readonly repository: IAssetRepository) {}
save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const identity = id ? UniqueID.create(id) : undefined;
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.saveWorkstation(company.data, (context) => {
const previous = identity?.isSuccess
? context.workstations.find((item) => item.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
}
const values = mapWorkstationInput(input, context, previous);
if (!values.isSuccess) {
return Result.fail(values.error);
}
const now = new Date().toISOString();
return previous
? previous.update(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) {
let current = catalog;
const add = (key: string, input: AssetDefinitionSpec) => {
const values = AssetDefinitionValues.create(input);
if (!values.isSuccess) {
throw new Error("Definición inicial de activos inválida.");
}
const result = current.seed(key, values.data);
if (!result.isSuccess) {
throw new Error("Catálogo inicial de activos inconsistente.");
}
current = result.data;
};
for (const [key, name, type, unit] of FIELDS) {
add(`field:${key}`, { kind: "field", name, description: "", type, unit, choices: [] });
}
@ -116,12 +120,15 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
const id = current.definitions
.find((item) => item.stockKey.getOrUndefined() === `field:${field}`)
?.id.toPrimitive();
if (!id) {
throw new Error("Campo inicial de activos ausente.");
}
const sectionId = sections.find((section) => section.keys.includes(field))?.id;
return { fieldId: id, required: false, ...(sectionId ? { sectionId } : {}) };
});
add(`template:${key}`, {
kind: "template",
name,
@ -141,5 +148,6 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
] as const) {
add(`relation:${key}`, { kind: "relation", name, inverseName, symmetric, description: "" });
}
return current;
}

View File

@ -10,6 +10,7 @@ type CatalogProps = {
definitions: readonly AssetDefinition[];
availability: readonly CatalogAvailability[];
};
export class AssetCatalog extends AggregateRoot<CatalogProps> {
private constructor(props: CatalogProps, id?: UniqueID) {
super(
@ -20,18 +21,23 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
id
);
}
static create() {
return new AssetCatalog({ definitions: [], availability: [] });
}
static rehydrate(props: CatalogProps, id: UniqueID) {
return new AssetCatalog(props, id);
}
get definitions() {
return this.props.definitions;
}
get availability() {
return this.props.availability;
}
visible(companyId: Maybe<UniqueID>) {
return this.definitions.filter(
(item) =>
@ -39,13 +45,16 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
item.companyId.getOrUndefined()?.equals(companyId.getOrUndefined())
);
}
active(item: AssetDefinition, companyId: Maybe<UniqueID>) {
const override = this.availability.find(
(entry) =>
entry.companyId.equals(companyId.getOrUndefined()) && entry.definitionId.equals(item.id)
);
return override?.active.getOrUndefined() ?? item.active;
}
save(
companyId: Maybe<UniqueID>,
id: Maybe<UniqueID>,
@ -54,23 +63,28 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
const previous = id.getOrUndefined()
? this.definitions.find((item) => item.id.equals(id.getOrUndefined()))
: undefined;
if (id.getOrUndefined() && !(previous && sameScope(previous.companyId, companyId))) {
return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND"));
}
const candidate = previous
? previous.update(values)
: Result.ok(AssetDefinition.create(companyId, values, true));
if (!candidate.isSuccess) {
return candidate;
}
return this.replace(candidate.data, companyId, previous);
}
private replace(
candidate: AssetDefinition,
scope: Maybe<UniqueID>,
previous?: AssetDefinition
): Result<AssetCatalog, AssetCatalogError> {
const conflicts = scope.getOrUndefined() ? this.visible(scope) : this.definitions;
if (
conflicts.some(
(item) =>
@ -83,12 +97,14 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
return Result.fail(new AssetCatalogError("NAME_EXISTS"));
}
const invalidField = this.invalidField(candidate, scope, previous);
if (invalidField) {
return Result.fail(new AssetCatalogError("FIELD_UNAVAILABLE"));
}
if (candidate === previous) {
return Result.ok(this);
}
return Result.ok(
new AssetCatalog(
{
@ -101,6 +117,7 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
)
);
}
private invalidField(
candidate: AssetDefinition,
scope: Maybe<UniqueID>,
@ -108,22 +125,28 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
) {
return candidate.values.fieldIds.some((id) => {
const field = this.visible(scope).find((item) => item.id.equals(id));
if (!field || field.values.data.kind !== "field") {
return true;
}
return !(
this.active(field, scope) ||
previous?.values.fieldIds.some((existing) => existing.equals(id))
);
});
}
seed(stockKey: string, values: AssetDefinitionValues): Result<AssetCatalog, AssetCatalogError> {
const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey);
if (previous) {
const upgraded = previous.initializeSections(values);
if (upgraded === previous) {
return Result.ok(this);
}
return Result.ok(
new AssetCatalog(
{
@ -136,21 +159,25 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
)
);
}
return this.replace(
AssetDefinition.create(Maybe.none(), values, true, Maybe.some(stockKey)),
Maybe.none()
);
}
setActive(
scope: Maybe<UniqueID>,
id: UniqueID,
active: boolean | null
): Result<AssetCatalog, AssetCatalogError> {
const item = this.visible(scope).find((entry) => entry.id.equals(id));
if (!item) {
return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND"));
}
const company = scope.getOrUndefined();
if (company && !item.companyId.getOrUndefined()) {
const previous = this.availability.find(
(entry) => entry.companyId.equals(company) && entry.definitionId.equals(id)
@ -159,9 +186,11 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
const changed = previous
? previous.update(value)
: CatalogAvailability.create(company, id, value);
if (previous === changed || (!previous && active === null)) {
return Result.ok(this);
}
return Result.ok(
new AssetCatalog(
{
@ -178,9 +207,11 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const changed = item.setActive(active);
if (changed === item) {
return Result.ok(this);
}
return Result.ok(
new AssetCatalog(
{
@ -192,6 +223,7 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
);
}
}
function sameScope(a: Maybe<UniqueID>, b: Maybe<UniqueID>) {
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";
const global = Maybe.none<UniqueID>();
function values(spec: AssetDefinitionSpec) {
const result = AssetDefinitionValues.create(spec);
assert.ok(result.isSuccess);
return result.data;
}
function field(name: string) {
return values({ kind: "field", name, type: "text", unit: "", choices: [], description: "" });
}
function changed(result: ReturnType<AssetCatalog["save"]>) {
assert.ok(result.isSuccess);
return result.data;
}
test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => {
const catalog = seedAssetCatalog(AssetCatalog.create());
assert.equal(catalog.definitions.length, 47);
assert.equal(seedAssetCatalog(catalog), catalog);
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.flatMap((item) => item.bindings.map((binding) => binding.id)),
].map((id) => id.toPrimitive());
assert.equal(new Set(ids).size, ids.length);
assert.ok(ids.every((id) => id.charAt(14) === "7"));
const templates = catalog.definitions.filter((item) => item.values.data.kind === "template");
assert.equal(templates.length, 12);
assert.ok(templates.every((item) => item.bindings.every((field) => !field.required)));
const rehydrated = AssetCatalog.rehydrate(
{ definitions: catalog.definitions, availability: catalog.availability },
catalog.id
);
assert.ok(rehydrated.id.equals(catalog.id));
assert.ok(rehydrated.definitions[0]?.id.equals(catalog.definitions[0]?.id));
const stock = catalog.definitions[0];
assert.ok(stock);
const protectedResult = catalog.save(global, Maybe.some(stock.id), field("Cambio"));
assert.ok(!protectedResult.isSuccess);
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 second = Maybe.some(UniqueID.generateNewID());
const stock = catalog.definitions[0];
assert.ok(stock);
catalog = changed(catalog.setActive(first, stock.id, 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(catalog.definitions.length, 47);
const overrideId = catalog.availability[0]?.id;
assert.ok(overrideId);
assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog);
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());
const company = Maybe.some(UniqueID.generateNewID());
const other = Maybe.some(UniqueID.generateNewID());
catalog = changed(catalog.save(company, Maybe.none(), field("Campo local")));
const local = catalog.definitions.at(-1);
assert.ok(local);
const template = values({
kind: "template",
@ -82,15 +99,19 @@ test("las plantillas validan ámbito, campos activos e identidad estable al edit
description: "",
fields: [{ fieldId: local.id.toPrimitive(), required: false }],
});
assert.ok(!catalog.save(global, Maybe.none(), template).isSuccess);
assert.ok(!catalog.save(other, Maybe.none(), template).isSuccess);
catalog = changed(catalog.save(company, Maybe.none(), template));
const saved = catalog.definitions.at(-1);
assert.ok(saved);
assert.equal(changed(catalog.save(company, Maybe.some(saved.id), template)), catalog);
const updated = values({ ...template.data, name: "Otra denominación" });
catalog = changed(catalog.save(company, Maybe.some(saved.id), updated));
const edited = catalog.definitions.at(-1);
assert.ok(edited);
assert.ok(edited.id.equals(saved.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: "",
choices: ["Buena", "Sustituir"],
};
assert.ok(!AssetDefinitionValues.create({ ...list, choices: [] }).isSuccess);
assert.ok(!AssetDefinitionValues.create({ ...list, choices: ["Buena", " buena "] }).isSuccess);
assert.ok(!AssetDefinitionValues.create({ ...list, type: "text" }).isSuccess);
const frozen = values(list);
assert.ok(Object.isFrozen(frozen.data));
if (frozen.data.kind === "field") {
assert.ok(Object.isFrozen(frozen.data.choices));
}
let catalog = AssetCatalog.create();
catalog = changed(catalog.save(global, Maybe.none(), frozen));
const saved = catalog.definitions[0];
assert.ok(saved);
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);

View File

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

View File

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

View File

@ -14,10 +14,12 @@ type RelationshipProps = Readonly<{
endedBy: Maybe<UniqueID>;
endedAt: Maybe<string>;
}>;
export class AssetRelationship extends AggregateRoot<RelationshipProps> {
private constructor(props: RelationshipProps, id?: UniqueID) {
super(Object.freeze({ ...props }), id);
}
static create(
companyId: UniqueID,
values: AssetRelationshipValues,
@ -27,6 +29,7 @@ export class AssetRelationship extends AggregateRoot<RelationshipProps> {
if (!z.iso.datetime().safeParse(now).success) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
return Result.ok(
new AssetRelationship({
companyId,
@ -38,30 +41,39 @@ export class AssetRelationship extends AggregateRoot<RelationshipProps> {
})
);
}
static rehydrate(props: RelationshipProps, id: UniqueID) {
return new AssetRelationship(props, id);
}
get companyId() {
return this.props.companyId;
}
get values() {
return this.props.values;
}
get createdBy() {
return this.props.createdBy;
}
get createdAt() {
return this.props.createdAt;
}
get endedBy() {
return this.props.endedBy;
}
get endedAt() {
return this.props.endedAt;
}
get active() {
return !this.endedAt.getOrUndefined();
}
end(actor: UniqueID, now: string): Result<AssetRelationship, AssetCatalogError> {
if (!this.active) {
return Result.ok(this);
@ -69,6 +81,7 @@ export class AssetRelationship extends AggregateRoot<RelationshipProps> {
if (!z.iso.datetime().safeParse(now).success || now < this.createdAt) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
return Result.ok(
new AssetRelationship(
{ ...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 targetId = UniqueID.generateNewID().toPrimitive();
const typeId = UniqueID.generateNewID().toPrimitive();
function input(symmetric = false): RelationshipValuesData {
return {
sourceId,
@ -25,13 +26,18 @@ function input(symmetric = false): RelationshipValuesData {
notes: " rack 1 ",
};
}
function values(data = input()) {
const result = AssetRelationshipValues.create(data);
assert.ok(result.isSuccess);
return result.data;
}
test("Las relaciones validan sus extremos, normalizan nombres y distinguen dirección", () => {
const first = values();
assert.equal(first.toPrimitive().name, "Alojado en");
assert.equal(first.notes, "rack 1");
assert.notEqual(
@ -39,12 +45,14 @@ test("Las relaciones validan sus extremos, normalizan nombres y distinguen direc
values({ ...input(), sourceId: targetId, targetId: sourceId }).key()
);
const symmetric = values(input(true));
assert.equal(
symmetric.key(),
values({ ...input(true), sourceId: targetId, targetId: sourceId }).key()
);
assert.equal(symmetric.toPrimitive().inverseName, "Alojado en");
const self = AssetRelationshipValues.create({ ...input(), targetId: sourceId });
assert.ok(!self.isSuccess);
assert.equal(self.error.code, "SELF_RELATIONSHIP");
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 otherActor = UniqueID.generateNewID();
const created = AssetRelationship.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
assert.ok(created.isSuccess);
const link = created.data;
assert.equal(link.id.toPrimitive().charAt(14), "7");
assert.ok(link.active);
assert.ok(!link.end(otherActor, "2026-10-03T10:00:00.000Z").isSuccess);
const ended = link.end(otherActor, "2026-10-04T11:00:00.000Z");
assert.ok(ended.isSuccess);
assert.equal(ended.data.active, false);
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.endedBy.getOrUndefined()?.equals(otherActor));
const retry = ended.data.end(actor, "2026-10-04T12:00:00.000Z");
assert.ok(retry.isSuccess);
assert.equal(retry.data, ended.data);
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());
let catalog = seedAssetCatalog(AssetCatalog.create());
const stock = catalog.definitions.find((item) => item.values.data.kind === "relation");
assert.ok(stock);
const spec = {
kind: "relation",
@ -93,30 +106,38 @@ test("Tipos de relación de serie protegidos, ámbito de compañía y dirección
inverseName: "Replicado desde",
symmetric: false,
});
assert.ok(custom.isSuccess);
const protectedChange = catalog.save(Maybe.none(), Maybe.some(stock.id), custom.data);
assert.ok(!protectedChange.isSuccess);
assert.equal(protectedChange.error.code, "BUILTIN_PROTECTED");
const status = catalog.setActive(scope, stock.id, false);
assert.ok(status.isSuccess);
catalog = status.data;
assert.equal(catalog.active(stock, scope), false);
assert.equal(catalog.active(stock, other), true);
const added = catalog.save(scope, Maybe.none(), custom.data);
assert.ok(added.isSuccess);
catalog = added.data;
const item = catalog.definitions.at(-1);
assert.ok(item);
assert.equal(
catalog.visible(other).some((entry) => entry.id.equals(item.id)),
false
);
const converted = AssetDefinitionValues.create({ ...spec, name: "Replica a" });
assert.ok(converted.isSuccess);
const rejected = catalog.save(scope, Maybe.some(item.id), converted.data);
assert.ok(!rejected.isSuccess);
assert.equal(rejected.error.code, "RELATION_DIRECTION_FIXED");
const unchanged = catalog.save(scope, Maybe.some(item.id), custom.data);
assert.ok(unchanged.isSuccess);
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 type { AssetValues } from "./value-objects/asset-values.vo";
export type AssetRevisionProps = Readonly<{
values: AssetValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class AssetRevision extends DomainEntity<AssetRevisionProps> {
static create(props: AssetRevisionProps) {
return new AssetRevision(props);
}
static rehydrate(props: AssetRevisionProps, id: UniqueID) {
return new AssetRevision(props, id);
}
get values() {
return this.props.values;
}
get actorId() {
return this.props.actorId;
}
get recordedAt() {
return this.props.recordedAt;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -17,16 +17,21 @@ const logoSvg =
function brand(name = "LG", builtin = true) {
const props = mapBrandInput({ name, active: true }, builtin);
assert.ok(props.isSuccess);
const created = Brand.create(props.data);
assert.ok(created.isSuccess);
return created.data;
}
test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempotencia", () => {
for (const name of ["", " ", "x".repeat(101)]) {
assert.equal(mapBrandInput({ name, active: true }).isSuccess, false);
}
const original = brand(" LG ");
assert.equal(original.id.toPrimitive()[14], "7");
assert.equal(original.name.toPrimitive(), "LG");
const same = original.update({
@ -35,6 +40,7 @@ test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempo
builtin: true,
logo: original.logo,
});
assert.ok(same.isSuccess);
assert.equal(same.data, original);
const inactive = original.update({
@ -43,24 +49,29 @@ test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempo
builtin: true,
logo: original.logo,
});
assert.ok(inactive.isSuccess);
assert.equal(inactive.data.id.toPrimitive(), original.id.toPrimitive());
assert.equal(inactive.data.active, false);
const renamed = mapBrandInput({ name: "Otra", active: true }, true);
assert.ok(renamed.isSuccess);
const blocked = original.update(renamed.data);
assert.equal(blocked.isSuccess, false);
if (!blocked.isSuccess) {
assert.equal(blocked.error.code, "BUILTIN_PROTECTED");
}
assert.equal(original.update({ ...renamed.data, builtin: false }).isSuccess, false);
const custom = brand("Propia", false);
assert.ok(custom.update({ ...renamed.data, builtin: false }).isSuccess);
});
test("Marcas: SVG estáticos con transparencia, límites y contenido activo rechazado", () => {
assert.ok(BrandLogoSchema.safeParse(logoSvg).success);
const prefix = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">';
for (const svg of [
"",
"<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);
}
const padding = BRAND_LOGO_MAX_BYTES - new TextEncoder().encode(logoSvg).length - 7;
assert.ok(
BrandLogoSchema.safeParse(logoSvg.replace("</svg>", `<!--${"x".repeat(padding)}--></svg>`))
.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);
for (const name of BUILTIN_BRANDS.filter((name) => name !== "Clónico")) {
const logo = builtinLogos.find((item) => item.name === name);
assert.ok(logo, name);
assert.ok(BrandLogoSchema.safeParse(logo.svg).success, name);
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", () => {
const original = brand();
const withLogo = mapBrandInput({ name: "LG", active: true, logoSvg }, true);
assert.ok(withLogo.isSuccess);
const updated = original.update(withLogo.data);
assert.ok(updated.isSuccess);
assert.ok(updated.data.id.equals(original.id));
assert.equal(updated.data.logo.getOrUndefined()?.toPrimitive(), logoSvg);
const same = updated.data.update(withLogo.data);
assert.ok(same.isSuccess);
assert.equal(same.data, updated.data);
const mapper = new BrandPersistenceMapper();
assert.equal(
mapper.toDomain(mapper.toPersistence(updated.data)).logo.getOrUndefined()?.toPrimitive(),
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") },
true
);
assert.ok(replacement.isSuccess);
const replaced = updated.data.update(replacement.data);
assert.ok(replaced.isSuccess);
assert.equal(
replaced.data.logo.getOrUndefined()?.toPrimitive(),
logoSvg.replace("#005CAB", "#000000")
);
const removed = replaced.data.update({ ...withLogo.data, logo: original.logo });
assert.ok(removed.isSuccess);
assert.equal(removed.data.logo.getOrUndefined(), undefined);
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 row = mapper.toPersistence(original);
const restored = mapper.toDomain({ ...row, active: 1, builtin: 1 });
assert.ok(restored.id.equals(original.id));
assert.ok(restored.name.equals(original.name));
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,
remove: async (action) => {
const result = action(brands);
if (result.isSuccess) {
brands = brands.filter((item) => !item.id.equals(result.data.id));
}
return result;
},
save: async (action) => {
const result = action(brands);
if (result.isSuccess) {
brands = [...brands.filter((item) => !item.id.equals(result.data.id)), result.data];
}
return result;
},
};
const useCases = new BrandUseCases(repository);
const existing = brands[1];
assert.ok(existing);
const disabled = await useCases.save(existing.id.toPrimitive(), {
name: "Propia",
active: false,
});
assert.ok(disabled.isSuccess);
const duplicate = await useCases.save(undefined, { name: " PROPIA ", active: true });
assert.equal(duplicate.isSuccess, false);
if (!duplicate.isSuccess) {
assert.equal(duplicate.error.code, "NAME_EXISTS");
}
const renamed = await useCases.save(existing.id.toPrimitive(), { name: "Nueva", active: true });
assert.ok(renamed.isSuccess);
assert.equal(renamed.data.id.toPrimitive(), existing.id.toPrimitive());
const invalid = await useCases.save("invalid", { name: "Nueva", active: true });
assert.equal(invalid.isSuccess, false);
const builtin = brands.find((item) => item.builtin);
assert.ok(builtin);
for (const [id, code] of [
["invalid", "INVALID_REQUEST"],
@ -200,6 +231,7 @@ test("Marcas: unicidad, edición y eliminación de personalizadas, con protecci
]) {
assert.ok(id);
const result = await useCases.remove(id);
assert.equal(result.isSuccess, false);
if (!result.isSuccess) {
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);
}
const removed = await useCases.remove(existing.id.toPrimitive().toUpperCase());
assert.ok(removed.isSuccess);
assert.ok(removed.data.id.equals(existing.id));
assert.deepEqual(brands, [builtin]);
const repeated = await useCases.remove(existing.id.toPrimitive());
assert.equal(repeated.isSuccess, false);
if (!repeated.isSuccess) {
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 type { SupportValues } from "./value-objects/support-values.vo";
export type SupportConnectionRevisionProps = Readonly<{
values: SupportValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class SupportConnectionRevision extends DomainEntity<SupportConnectionRevisionProps> {
static create(props: SupportConnectionRevisionProps) {
return new SupportConnectionRevision(Object.freeze({ ...props }));
}
static rehydrate(props: SupportConnectionRevisionProps, id: UniqueID) {
return new SupportConnectionRevision(Object.freeze({ ...props }), id);
}
get values() {
return this.props.values;
}
get actorId() {
return this.props.actorId;
}
get recordedAt() {
return this.props.recordedAt;
}

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ const company = UniqueID.generateNewID();
const asset = UniqueID.generateNewID();
const actor = UniqueID.generateNewID();
const toolResult = SupportTool.create({ name: "AnyDesk", active: true }, true);
assert.ok(toolResult.isSuccess);
const tool = toolResult.data;
const input = {
@ -25,8 +26,10 @@ const input = {
state: "available" as const,
notes: "",
};
function connection() {
const values = SupportValues.create(input);
assert.ok(values.isSuccess);
const result = SupportConnection.create(
company,
@ -35,20 +38,27 @@ function connection() {
actor,
"2026-10-07T10:00:00.000Z"
);
assert.ok(result.isSuccess);
return result.data;
}
test("asistencia: UUID v7, historial, cambios semánticos y fechas", () => {
const item = connection();
assert.equal(item.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");
assert.ok(same.isSuccess);
assert.equal(same.data, item);
const values = SupportValues.create({ ...input, requiresVpn: true });
assert.ok(values.isSuccess);
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");
assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(item.id));
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(),
};
const duplicate = mapSupportInput(input, context);
assert.equal(duplicate.isSuccess, false);
const distinct = mapSupportInput({ ...input, destination: "otro" }, context);
assert.ok(distinct.isSuccess);
const preferred = SupportValues.create({ ...input, preferred: true });
assert.ok(preferred.isSuccess);
const updated = first.update(preferred.data, actor, "2026-10-07T11:00:00.000Z");
assert.ok(updated.isSuccess);
assert.equal(
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", () => {
assert.equal(tool.update({ name: "Otra", active: true }).isSuccess, false);
const disabled = tool.update({ name: "AnyDesk", active: false });
assert.ok(disabled.isSuccess);
const context = {
connections: [],
@ -105,6 +120,7 @@ test("herramientas: protección de serie y desactivación sin perder referencias
assetRetired: false,
actorNames: new Map(),
};
assert.equal(mapSupportInput(input, context).isSuccess, false);
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 rows = mapper.toPersistence(item);
const read = mapper.toDomain(rows.root, rows.revisions);
assert.ok(read.id.equals(item.id));
assert.ok(read.values.equals(item.values));
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) {
const id = UniqueID.create(input.id);
const name = Name.create(input.name, 100);
if (!(id.isSuccess && name.isSuccess)) {
throw new Error("Sección de plantilla persistida inconsistente.");
}
return new TemplateSection({ name: name.data, description: input.description }, id.data);
}
get name() {
return this.props.name;
}
get description() {
return this.props.description;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { Workstation } from "./workstation.aggregate";
import type { WorkstationInput } from "./workstation-values";
const assetIds = [UniqueID.generateNewID().toPrimitive(), UniqueID.generateNewID().toPrimitive()];
function input(): WorkstationInput {
return {
name: " Puesto Ana ",
@ -18,11 +19,15 @@ function input(): WorkstationInput {
assetIds,
};
}
function values(data = input()) {
const result = WorkstationValues.create(data);
assert.ok(result.isSuccess);
return result.data;
}
test("Puestos: composición opcional, referencias válidas y límites sin duplicados", () => {
assert.ok(WorkstationValues.create({ ...input(), assetIds: [] }).isSuccess);
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(),
assetIds: [assetIds[0] ?? "", assetIds[0] ?? ""],
});
assert.ok(!duplicate.isSuccess);
assert.equal(duplicate.error.code, "INVALID_MEMBERS");
assert.ok(
@ -47,8 +53,10 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
const company = UniqueID.generateNewID();
const actor = UniqueID.generateNewID();
const created = Workstation.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
assert.ok(created.isSuccess);
const item = created.data;
assert.equal(item.id.toPrimitive().charAt(14), "7");
assert.equal(item.history[0]?.id.toPrimitive().charAt(14), "7");
const same = item.update(
@ -56,6 +64,7 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
actor,
"2026-10-04T11:00:00.000Z"
);
assert.ok(same.isSuccess);
assert.equal(same.data, item);
const changed = item.update(
@ -63,6 +72,7 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
actor,
"2026-10-04T11:00:00.000Z"
);
assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(item.id));
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", () => {
const sharedId = UniqueID.generateNewID().toPrimitive();
const original = values({ ...input(), sharedAssetIds: [sharedId] });
assert.ok(Object.isFrozen(original.sharedAssetIds));
assert.ok(
!WorkstationValues.create({ ...input(), sharedAssetIds: [assetIds[0] ?? ""] }).isSuccess
@ -94,12 +105,14 @@ test("Puestos: recursos compartidos separados, sin duplicados y cambios con hist
UniqueID.generateNewID(),
"2026-10-04T10:00:00.000Z"
);
assert.ok(created.isSuccess);
const changed = created.data.update(
values(),
UniqueID.generateNewID(),
"2026-10-04T11:00:00.000Z"
);
assert.ok(changed.isSuccess);
assert.deepEqual(changed.data.history[0]?.values.toPrimitive().sharedAssetIds, [sharedId]);
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) {
const status = code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409;
response.status(status).json({ code, requestId: response.getHeader("x-request-id") });
}
function sendResult(
response: Response,
result: Result<AssetCatalog, AssetCatalogError>,
@ -28,16 +30,21 @@ function sendResult(
) {
if (!result.isSuccess) {
sendError(response, result.error.code);
return;
}
const parsed = catalogScope(scope);
if (!parsed.isSuccess) {
sendError(response, "INVALID_REQUEST");
return;
}
const body = buildCatalogResponse(result.data, parsed.data);
if (!savedName) {
response.status(status).json(body);
return;
}
const savedId =
@ -48,21 +55,26 @@ function sendResult(
item.values.kind === savedName.kind &&
item.values.name === savedName.name.trim()
)?.id;
if (!savedId) {
throw new Error("Resultado del catálogo inconsistente.");
}
response.status(status).json({ ...body, savedId });
}
export function registerAssetCatalogRoutes(
app: Application,
useCases: AssetCatalogUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:scope", (request, response, next) => {
const params = CatalogParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
sendError(response, "INVALID_REQUEST");
return;
}
useCases
@ -75,12 +87,15 @@ export function registerAssetCatalogRoutes(
const schema = path.endsWith("update") ? CatalogItemParamsSchema : CatalogParamsSchema;
const params = schema.safeParse(request.params);
const body = SaveDefinitionRequestSchema.safeParse(request.body);
if (!(params.success && body.success)) {
sendError(response, "INVALID_REQUEST");
return;
}
const identity =
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
useCases
.save(params.data.scope, identity, body.data.values)
.then((result) =>
@ -96,8 +111,10 @@ export function registerAssetCatalogRoutes(
router.post("/:scope/:id/status", (request, response, next) => {
const params = CatalogItemParamsSchema.safeParse(request.params);
const body = AvailabilityRequestSchema.safeParse(request.body);
if (!(params.success && body.success)) {
sendError(response, "INVALID_REQUEST");
return;
}
useCases

View File

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

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