metis-desk/apps/server/src/resources.integration.ts
David 72c2a5101d feat: implement resource lifecycle management with delete functionality
- Added `verifyResourceLifecycle` integration to handle resource lifecycle operations.
- Introduced `remove` method in `IResourceRepository` and implemented it in `SequelizeResourceRepository`.
- Created `DeleteResourceDialog` component for confirming resource deletion.
- Updated `resource.use-cases` to include `remove` and `setActive` methods for managing resource states.
- Enhanced API routes to support resource status updates and deletions.
- Added tests for resource lifecycle operations, ensuring correct behavior for activation, deactivation, and deletion.
- Updated UI components to integrate new deletion functionality and provide feedback on resource status changes.
- Improved localization for resource lifecycle actions in both English and Spanish.
2026-10-17 13:58:29 +02:00

485 lines
17 KiB
TypeScript

import assert from "node:assert/strict";
import { CatalogResponseSchema as AssetCatalogResponseSchema } from "@metis/assets/common";
import {
CatalogResponseSchema,
InventoryResponseSchema,
ResourceResponseSchema,
SaveDefinitionResponseSchema,
} from "@metis/resources/common";
import { createResourceModule } from "@metis/resources/module";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
import { verifyResourceLifecycle } from "./resource-lifecycle.integration";
export async function verifyResources(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
companyId: string,
otherId: string,
database: Sequelize
) {
const global = "/resource-catalog/global";
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, 28);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 8);
assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7"));
assert.ok(
initial.items.every((item) =>
item.bindings.every((binding) => !binding.required && binding.id[14] === "7")
)
);
const licenseTemplate = initial.items.find(
(item) => item.builtinKey === "template:software_license"
);
assert.ok(licenseTemplate && licenseTemplate.values.kind === "template");
assert.equal(licenseTemplate.values.icon, "file-key");
const changedIcon = await save(`${global}/${licenseTemplate.id}/update`, {
...licenseTemplate.values,
icon: "box",
});
const withIcon = changedIcon.items.find((item) => item.id === licenseTemplate.id);
assert.ok(withIcon?.values.kind === "template");
assert.equal(withIcon.values.icon, "box");
assert.deepEqual(withIcon.bindings, licenseTemplate.bindings);
const iconRows = await database.query<{ icon: string | null }>(
"SELECT icon FROM resource_definitions WHERE id=:id",
{ replacements: { id: licenseTemplate.id }, type: QueryTypes.SELECT }
);
assert.equal(iconRows[0]?.icon, "box");
await save(`${global}/${licenseTemplate.id}/update`, licenseTemplate.values);
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,
active: true,
notes: "",
characteristics: {
[licenseFieldId("software_product")]: "Microsoft 365",
[licenseFieldId("software_edition")]: "Business Standard",
[licenseFieldId("license_model")]: "Suscripción",
[licenseFieldId("license_metric")]: "Por usuario",
[licenseFieldId("license_quantity")]: 25,
[licenseFieldId("expires")]: "2027-10-07",
[licenseFieldId("renews")]: "2027-09-30",
[licenseFieldId("auto_renew")]: false,
},
};
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);
assert.equal(license.id[14], "7");
const licenseUpdated = await resource(`${inventory}/${license.id}/update`, {
...licenseInput,
characteristics: { ...licenseInput.characteristics, [licenseFieldId("license_quantity")]: 30 },
});
assert.equal(licenseUpdated.history.length, 2);
assert.equal(
licenseUpdated.history[0]?.values.characteristics[licenseFieldId("license_quantity")],
25
);
await error(
inventory,
{
values: {
...licenseInput,
name: "Fecha inválida",
characteristics: { [licenseFieldId("renews")]: "2027-02-30" },
},
},
"INVALID_CHARACTERISTICS"
);
await error(
inventory,
{
values: {
...licenseInput,
name: "Modalidad inválida",
characteristics: { [licenseFieldId("license_model")]: "Desconocida" },
},
},
"INVALID_CHARACTERISTICS"
);
const minimal = await resource(inventory, {
...licenseInput,
name: "ESET empresa",
characteristics: {},
});
assert.deepEqual(minimal.values.characteristics, {});
await error(
`/resources/${otherId}/${license.id}/update`,
{ values: licenseInput },
"RESOURCE_NOT_FOUND",
404
);
assert.equal(
(await request(`${company}/${licenseTemplate.id}/status`, { active: false })).status,
200
);
await error(
inventory,
{ values: { ...licenseInput, name: "No disponible" } },
"TEMPLATE_UNAVAILABLE"
);
await resource(`${inventory}/${license.id}/update`, {
...licenseUpdated.values,
notes: "Conservada",
});
assert.equal(
(await request(`${company}/${licenseTemplate.id}/status`, { active: null })).status,
200
);
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");
const { icon: previousIcon, ...templateWithoutIcon } = stockTemplate.values;
assert.equal(previousIcon, "mail");
const editedValues = { ...templateWithoutIcon, description: "Plantilla global editada" };
const edited = await save(`${global}/${stockTemplate.id}/update`, editedValues);
assert.equal(edited.savedId, stockTemplate.id);
await createResourceModule(database, (_request, _response, next) => next());
const editedCatalog = await read(global);
const editedTemplate = editedCatalog.items.find((item) => item.id === stockTemplate.id);
assert.ok(editedTemplate?.values.kind === "template");
assert.deepEqual(editedTemplate.values, editedValues);
assert.deepEqual(editedTemplate.bindings, stockTemplate.bindings);
const fieldValues = { ...stock.values, description: "Campo global editado" };
await save(`${global}/${stock.id}/update`, fieldValues);
assert.equal(
(await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example"))
.status,
403
);
assert.equal(
(await request(`${global}/${stock.id}/status`, { active: false }, undefined, "a".repeat(64)))
.status,
403
);
assert.equal((await request(`${company}/${stock.id}/status`, { active: false })).status, 200);
assert.equal(
(await read(other)).items.find((item) => item.id === stock.id)?.effectiveActive,
true
);
await createResourceModule(database, (_request, _response, next) => next());
const restarted = await read(global);
assert.deepEqual(restarted.items.find((item) => item.id === stock.id)?.values, fieldValues);
assert.deepEqual(
restarted.items.map((item) => item.id).sort(),
initial.items.map((item) => item.id).sort()
);
assert.equal(
(await read(company)).items.find((item) => item.id === stock.id)?.effectiveActive,
false
);
assert.equal((await request(`${company}/${stock.id}/status`, { active: null })).status, 200);
const definition = {
kind: "field",
name: "Marca",
type: "text",
unit: "",
description: "",
choices: [],
};
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(),
name: "Datos técnicos",
description: "",
};
const template = {
kind: "template",
name: "Recurso propio",
sections: [section],
description: "",
fields: [{ fieldId, required: true, sectionId: section.id }],
};
await error(
company,
{ values: { ...template, fields: [{ fieldId: assetField.id, required: false }] } },
"FIELD_UNAVAILABLE"
);
await error(
`/asset-catalog/${companyId}`,
{ values: { ...template, fields: [{ fieldId: stock.id, required: false }] } },
"FIELD_UNAVAILABLE"
);
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 },
"DEFINITION_NOT_FOUND",
404
);
const first = {
name: "Zoom corporativo",
templateId: createdTemplate.savedId,
active: true,
notes: "",
characteristics: { [fieldId]: "Zoom" },
};
await error(
inventory,
{ values: { ...first, templateId: assetTemplate.id } },
"TEMPLATE_UNAVAILABLE"
);
await error(`/resources/${otherId}`, { values: first }, "TEMPLATE_UNAVAILABLE");
await error(inventory, { values: { ...first, characteristics: {} } }, "REQUIRED_CHARACTERISTICS");
await error(
inventory,
{ values: { ...first, characteristics: { [assetField.id]: "Ajeno" } } },
"INVALID_CHARACTERISTICS"
);
await error(inventory, { values: { ...first, personId: companyId } }, "INVALID_REQUEST", 400);
assert.equal((await request(inventory, { values: first }, "https://other.example")).status, 403);
assert.equal(
(await request(inventory, { values: first }, undefined, "a".repeat(64))).status,
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(
`/resources/${otherId}/${created.id}/update`,
{ values: first },
"RESOURCE_NOT_FOUND",
404
);
const altered = await resource(`${inventory}/${created.id}/update`, {
...first,
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);
assert.equal(altered.values.active, false);
await error(inventory, { values: { ...first, name: " zoom CORPORATIVO " } }, "NAME_EXISTS");
await error(
`${inventory}/${created.id}/update`,
{ values: { ...first, templateId: stockTemplate.id } },
"TEMPLATE_FIXED"
);
// Cambiar una plantilla no altera las características ni la ficha ya registrada.
await save(`${company}/${createdTemplate.savedId}/update`, {
...template,
name: "Plantilla revisada",
sections: [],
fields: [{ fieldId: stock.id, required: false }],
});
const listResponse = await request(inventory);
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);
assert.equal(stored.specification.templateName, "Recurso propio");
assert.equal(stored.specification.fields[0]?.fieldId, fieldId);
await resource(`${inventory}/${created.id}/update`, {
...first,
active: false,
notes: "Notas posteriores",
});
// Una revisión fuera de secuencia se rechaza sin exponer detalles de persistencia.
const audit = await database.query<{ id: string; position: number }>(
"SELECT id, position FROM resource_revisions WHERE resourceId=:id ORDER BY position DESC LIMIT 1",
{ 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", {
replacements: { id: latest.id, position: latest.position },
});
}
assert.equal((await request(inventory)).status, 200);
// Una plantilla desactivada impide altas, pero permite conservar y editar registros existentes.
assert.equal(
(await request(`${company}/${createdTemplate.savedId}/status`, { active: false })).status,
200
);
await error(inventory, { values: { ...first, name: "Otro" } }, "TEMPLATE_UNAVAILABLE");
await resource(`${inventory}/${created.id}/update`, {
...first,
active: false,
notes: "Plantilla desactivada",
});
assert.equal(
(await request(`${company}/${createdTemplate.savedId}/status`, { active: true })).status,
200
);
const concurrentInput = {
name: "Concurrente",
templateId: stockTemplate.id,
active: true,
notes: "",
characteristics: {
[initial.items.find((item) => item.builtinKey === "field:email")?.id ?? ""]:
"concurrent@example.test",
},
};
const concurrent = await Promise.all([
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);
assert.equal((await request(`/access/${companyId}`)).status, 200);
assert.equal((await request(`/resources/${otherId}`)).status, 200);
assert.equal((await request(`${inventory}?unexpected=1`)).status, 400);
assert.equal((await request("/resources/not-a-uuid")).status, 400);
assert.equal((await request("/resources/82be74bb-894f-4cca-ac7b-dedc96c15689")).status, 404);
const globalSection = {
id: UniqueID.generateNewID().toPrimitive(),
name: "General",
description: "",
};
const groupedGlobal = await save(global, {
kind: "template",
name: "Plantilla global agrupada resources",
description: "",
sections: [globalSection],
fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }],
});
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(
global,
{
values: {
kind: "template",
name: "Sección ajena resources",
description: "",
sections: [],
fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }],
},
},
"INVALID_REQUEST",
400
);
await verifyResourceLifecycle(request, companyId, otherId, database, first);
const mailbox = await resource(inventory, {
...concurrentInput,
name: "Buzón para eliminar",
characteristics: {
...concurrentInput.characteristics,
[stock.id]: "delete-mailbox@example.test",
},
});
assert.equal((await request(`${inventory}/${mailbox.id}/delete`, {})).status, 200);
const projected = await database.query("SELECT id FROM mailboxes WHERE id=:id", {
replacements: { id: mailbox.id },
type: QueryTypes.SELECT,
});
assert.equal(projected.length, 0);
}