- 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.
485 lines
17 KiB
TypeScript
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);
|
|
}
|