Compare commits
2 Commits
dbac5dfa84
...
dd64a01622
| Author | SHA1 | Date | |
|---|---|---|---|
| dd64a01622 | |||
| 2c827b8a7d |
124
.lintrc
Normal file
124
.lintrc
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -37,5 +37,6 @@ export function createAppI18n(language = "es") {
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
return { instance, initialization };
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import { createAppI18n } from "./i18n";
|
||||
import "./global.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Missing application root");
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!!packages/shadcn-ui",
|
||||
"!!**/node_modules",
|
||||
"!!**/.next",
|
||||
"!!**/dist",
|
||||
|
||||
66
docs/formato-y-espaciado-del-codigo.md
Normal file
66
docs/formato-y-espaciado-del-codigo.md
Normal 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
32
eslint.config.mjs
Normal 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,
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>>;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) },
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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, []));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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" &&
|
||||
|
||||
@ -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(":");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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."));
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user