Licencias de software

This commit is contained in:
David Arranz 2026-10-07 13:26:59 +02:00
parent 1989bbff89
commit 5144073481
8 changed files with 291 additions and 18 deletions

View File

@ -43,14 +43,104 @@ export async function verifyResources(
return ResourceResponseSchema.parse(await response.json()).resource; return ResourceResponseSchema.parse(await response.json()).resource;
} }
const initial = await read(global); const initial = await read(global);
assert.equal(initial.items.length, 18); assert.equal(initial.items.length, 26);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 5); assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 6);
assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7")); assert.ok(initial.items.every((item) => item.builtin && item.id[14] === "7"));
assert.ok( assert.ok(
initial.items.every((item) => initial.items.every((item) =>
item.bindings.every((binding) => !binding.required && binding.id[14] === "7") 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");
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 assetsResponse = await request("/asset-catalog/global");
const assets = AssetCatalogResponseSchema.parse(await assetsResponse.json()); const assets = AssetCatalogResponseSchema.parse(await assetsResponse.json());
assert.ok(initial.items.every((item) => !assets.items.some((asset) => asset.id === item.id))); assert.ok(initial.items.every((item) => !assets.items.some((asset) => asset.id === item.id)));

View File

@ -49,7 +49,7 @@ Buzones independientes corporativos o externos, cuenta informática opcional, pe
## Recursos con campos y plantillas: base implementada ## Recursos con campos y plantillas: base implementada
Catálogo independiente de activos, 13 campos y cinco plantillas de serie, adiciones globales y por compañía, disponibilidad local y recursos con especificación capturada e historial. Los buzones y recursos básicos anteriores están migrados, conservando UUID, historial y relaciones. La gestión anterior se ha retirado; sus tablas se mantienen como proyecciones para permisos y configuraciones existentes. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md). Catálogo independiente de activos, 20 campos y seis plantillas de serie, adiciones globales y por compañía, disponibilidad local y recursos con especificación capturada e historial. Los buzones y recursos básicos anteriores están migrados, conservando UUID, historial y relaciones. La gestión anterior se ha retirado; sus tablas se mantienen como proyecciones para permisos y configuraciones existentes. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
## Impresoras por perfil: implementado ## Impresoras por perfil: implementado
@ -87,7 +87,7 @@ Varios métodos por activo, catálogo global ampliable con herramientas de serie
1. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria. 1. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria.
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente. Este orden se concreta antes de cada entrega. El registro de licencias como recursos está implementado; sus asignaciones y relaciones con cuentas, personas, equipos y aplicaciones quedan pendientes.
## Inventario: implementado ## Inventario: implementado
@ -103,9 +103,13 @@ Tickets por compañía y consulta global con búsqueda/filtros, referencia corta
Consulta global y por compañía, técnico, tiempos, diagnóstico, trabajo realizado, resultado y elementos afectados. Varias intervenciones por ticket o actuaciones internas sin ticket. Pasos opcionales, procedimientos versionados, comprobaciones obligatorias al finalizar, historial, idempotencia y control de concurrencia. Consultar [Intervenciones y plantillas](intervenciones-y-plantillas.md). Consulta global y por compañía, técnico, tiempos, diagnóstico, trabajo realizado, resultado y elementos afectados. Varias intervenciones por ticket o actuaciones internas sin ticket. Pasos opcionales, procedimientos versionados, comprobaciones obligatorias al finalizar, historial, idempotencia y control de concurrencia. Consultar [Intervenciones y plantillas](intervenciones-y-plantillas.md).
## Licencias de software: registro implementado
Plantilla global protegida, siete campos nuevos y cuatro reutilizados del catálogo de recursos. Registro por compañía, características opcionales, modalidades, criterio de licenciamiento, cantidad contratada, caducidad, renovación e historial. Modales con ayuda específica. Pendientes las asignaciones, capacidad disponible, vínculos con aplicaciones y avisos. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md).
## Servicios y documentación: pendiente ## Servicios y documentación: pendiente
Software, licencias, Microsoft 365, ERP, antivirus, cloud, dominios, DNS, certificados, contratos y documentación. Gestión de asignaciones de licencias, Microsoft 365, ERP, antivirus, cloud, dominios, DNS, certificados, contratos y documentación.
Las credenciales se resolverán con una solución específica; no almacenarlas como texto libre. Las credenciales se resolverán con una solución específica; no almacenarlas como texto libre.
## Decisiones pendientes ## Decisiones pendientes

View File

@ -20,7 +20,7 @@ Cada compañía puede activar o desactivar una definición global para su ámbit
## Datos de serie ## Datos de serie
Se incluyen 13 campos y cinco plantillas: Se incluyen 20 campos y seis plantillas:
| Plantilla | Campos | | Plantilla | Campos |
| --- | --- | | --- | --- |
@ -28,6 +28,7 @@ Se incluyen 13 campos y cinco plantillas:
| Carpeta compartida | Ruta UNC, servidor como referencia documental y descripción | | Carpeta compartida | Ruta UNC, servidor como referencia documental y descripción |
| Aplicación | Proveedor, URL y descripción | | Aplicación | Proveedor, URL y descripción |
| Certificado digital | Titular, emisor, caducidad, huella digital y finalidad | | Certificado digital | Titular, emisor, caducidad, huella digital y finalidad |
| Licencia de software | Producto, edición o plan, proveedor, modalidad, criterio de licenciamiento, cantidad contratada, referencia, caducidad, fecha de renovación, renovación automática y descripción |
| Recurso de acceso | Referencia; contexto estructurado de tipo de acceso, sistema y entorno | | Recurso de acceso | Referencia; contexto estructurado de tipo de acceso, sistema y entorno |
Las características de serie son opcionales salvo la dirección válida exigida al registrar un buzón. Los datos se alimentan con claves de serie estables: reiniciar no duplica definiciones, cambia UUID ni reactiva elementos desactivados. No se crean recursos, personas o compañías de ejemplo. Las características de serie son opcionales salvo la dirección válida exigida al registrar un buzón. Los datos se alimentan con claves de serie estables: reiniciar no duplica definiciones, cambia UUID ni reactiva elementos desactivados. No se crean recursos, personas o compañías de ejemplo.
@ -77,3 +78,13 @@ POP/IMAP pertenece a la configuración de correo por perfil; letras de unidad pe
Las pruebas con MariaDB de test comprueban UUID y revisiones originales, repetición idempotente, edición canónica y actualización de proyecciones, aislamiento por compañía y rechazo de escrituras antiguas. Se mantienen las pruebas de múltiples correos, permisos, necesidades, cobertura y perfiles. Las pruebas de dominio cubren correo inválido o ausente, normalización, cuenta opcional, cambio auditado y tipo fijo. Las pruebas con MariaDB de test comprueban UUID y revisiones originales, repetición idempotente, edición canónica y actualización de proyecciones, aislamiento por compañía y rechazo de escrituras antiguas. Se mantienen las pruebas de múltiples correos, permisos, necesidades, cobertura y perfiles. Las pruebas de dominio cubren correo inválido o ausente, normalización, cuenta opcional, cambio auditado y tipo fijo.
La comprobación en navegador usa los datos existentes de desarrollo, sin crear registros ficticios: ficha, historial, personas vinculadas, edición modal y selectores. La comprobación en navegador usa los datos existentes de desarrollo, sin crear registros ficticios: ficha, historial, personas vinculadas, edición modal y selectores.
## Licencias de software: registro implementado
Una licencia es un Resource de la compañía creado con la plantilla de serie Licencia de software. Se registra en Recursos → Nuevo recurso, seleccionando esa plantilla; no constituye un activo ni una instalación de aplicación. Ejemplos: Microsoft 365 Business Standard, ESET Endpoint contratado para la empresa o licencia perpetua de ERP.
Nombre, plantilla y estado siguen el contrato común. Todas las características de esta plantilla son opcionales. Modalidad ofrece Suscripción, Perpetua, OEM y Otra; criterio ofrece Por usuario, Por dispositivo, Por servidor, Concurrente, Corporativa y Otro. Cantidad contratada es una característica numérica documental; no representa plazas libres ni impone aún reglas de asignación. Caducidad y fecha de renovación se registran independientemente. Renovación automática distingue sí, no y sin datos. No almacenar claves de activación.
Se reutilizan proveedor, referencia, caducidad y descripción del catálogo de recursos. Los siete campos nuevos y la plantilla se incorporan de forma idempotente al arrancar, sin cambiar identidades, fichas existentes ni disponibilidad configurada. La plantilla de serie está protegida y se puede copiar para personalizar; se puede desactivar globalmente o para una compañía. La ayuda específica aparece al final del modal, antes de Guardar y Cancelar.
La ficha captura su especificación y conserva revisiones como cualquier recurso. Registrar una licencia no crea asignaciones, vínculos con aplicaciones/cuentas/equipos, avisos de vencimiento ni cálculos de capacidad. Estas relaciones y avisos quedan pendientes de una entrega posterior.

View File

@ -2,7 +2,7 @@ import type { ResourceCatalog } from "./resource-catalog.aggregate";
import type { ResourceDefinitionSpec, ResourceFieldType } from "./resource-definition"; import type { ResourceDefinitionSpec, ResourceFieldType } from "./resource-definition";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values"; import { ResourceDefinitionValues } from "./value-objects/resource-definition-values";
const FIELDS: readonly [string, string, ResourceFieldType][] = [ const FIELDS: readonly [string, string, ResourceFieldType, (readonly string[])?][] = [
["email", "Dirección de correo", "text"], ["email", "Dirección de correo", "text"],
["provider", "Proveedor", "text"], ["provider", "Proveedor", "text"],
["context", "Contexto", "text"], ["context", "Contexto", "text"],
@ -16,6 +16,18 @@ const FIELDS: readonly [string, string, ResourceFieldType][] = [
["fingerprint", "Huella digital", "text"], ["fingerprint", "Huella digital", "text"],
["purpose", "Finalidad", "text"], ["purpose", "Finalidad", "text"],
["reference", "Referencia", "text"], ["reference", "Referencia", "text"],
["software_product", "Producto de software", "text"],
["software_edition", "Edición o plan", "text"],
["license_model", "Modalidad de licencia", "choice", ["Suscripción", "Perpetua", "OEM", "Otra"]],
[
"license_metric",
"Criterio de licenciamiento",
"choice",
["Por usuario", "Por dispositivo", "Por servidor", "Concurrente", "Corporativa", "Otro"],
],
["license_quantity", "Cantidad contratada", "number"],
["renews", "Fecha de renovación", "date"],
["auto_renew", "Renovación automática", "boolean"],
]; ];
const TEMPLATES: readonly [string, string, readonly string[]][] = [ const TEMPLATES: readonly [string, string, readonly string[]][] = [
["mailbox", "Buzón de correo", ["email", "provider", "context"]], ["mailbox", "Buzón de correo", ["email", "provider", "context"]],
@ -23,6 +35,23 @@ const TEMPLATES: readonly [string, string, readonly string[]][] = [
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]], ["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
["application", "Aplicación", ["provider", "url", "description"]], ["application", "Aplicación", ["provider", "url", "description"]],
["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]], ["certificate", "Certificado digital", ["holder", "issuer", "expires", "fingerprint", "purpose"]],
[
"software_license",
"Licencia de software",
[
"software_product",
"software_edition",
"provider",
"license_model",
"license_metric",
"license_quantity",
"reference",
"expires",
"renews",
"auto_renew",
"description",
],
],
]; ];
/** Solo alimenta definiciones; no crea recursos ni migra registros anteriores. */ /** Solo alimenta definiciones; no crea recursos ni migra registros anteriores. */
export function seedResourceCatalog(catalog: ResourceCatalog) { export function seedResourceCatalog(catalog: ResourceCatalog) {
@ -38,8 +67,15 @@ export function seedResourceCatalog(catalog: ResourceCatalog) {
} }
current = result.data; current = result.data;
}; };
for (const [key, name, type] of FIELDS) { for (const [key, name, type, choices] of FIELDS) {
add(`field:${key}`, { kind: "field", name, description: "", type, unit: "", choices: [] }); add(`field:${key}`, {
kind: "field",
name,
description: "",
type,
unit: "",
choices: [...(choices ?? [])],
});
} }
for (const [key, name, fields] of TEMPLATES) { for (const [key, name, fields] of TEMPLATES) {
const references = fields.map((field) => { const references = fields.map((field) => {

View File

@ -24,7 +24,7 @@ const field = {
} as const; } as const;
test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => { test("catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección", () => {
const catalog = seedResourceCatalog(ResourceCatalog.create()); const catalog = seedResourceCatalog(ResourceCatalog.create());
assert.equal(catalog.definitions.length, 18); assert.equal(catalog.definitions.length, 26);
assert.equal(seedResourceCatalog(catalog), catalog); assert.equal(seedResourceCatalog(catalog), catalog);
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7")); assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
const template = catalog.definitions.find((item) => item.values.data.kind === "template"); const template = catalog.definitions.find((item) => item.values.data.kind === "template");
@ -110,3 +110,62 @@ test("ámbitos, tipos, listas, campos externos y disponibilidad por compañía",
assert.ok(reset.isSuccess); assert.ok(reset.isSuccess);
assert.equal(reset.data.active(stock, Maybe.some(company)), true); assert.equal(reset.data.active(stock, Maybe.some(company)), true);
}); });
test("licencias: ampliar un catálogo previo conserva identidades y disponibilidad", () => {
const seeded = seedResourceCatalog(ResourceCatalog.create());
const originalKeys = new Set([
"field:email",
"field:provider",
"field:context",
"field:unc",
"field:server",
"field:description",
"field:url",
"field:holder",
"field:issuer",
"field:expires",
"field:fingerprint",
"field:purpose",
"field:reference",
"template:mailbox",
"template:access",
"template:shared_folder",
"template:application",
"template:certificate",
]);
const previous = ResourceCatalog.rehydrate(
{
definitions: seeded.definitions.filter((item) =>
originalKeys.has(item.stockKey.getOrUndefined() ?? "")
),
availability: [],
},
seeded.id
);
const original = previous.definitions[0];
assert.ok(original);
const disabled = previous.setActive(Maybe.none(), original.id, false);
assert.ok(disabled.isSuccess);
const upgraded = seedResourceCatalog(disabled.data);
assert.equal(upgraded.definitions.length, 26);
for (const existing of disabled.data.definitions) {
assert.equal(
upgraded.definitions.find((item) => item.id.equals(existing.id)),
existing
);
}
const license = upgraded.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:software_license"
);
assert.ok(license && license.values.data.kind === "template");
assert.ok(license.values.data.fields.every((binding) => !binding.required));
const protectedTemplate = license.update(values({ ...license.values.data, name: "Otra" }));
assert.ok(!protectedTemplate.isSuccess);
assert.equal(protectedTemplate.error.code, "BUILTIN_PROTECTED");
const company = UniqueID.generateNewID();
const locallyDisabled = upgraded.setActive(Maybe.some(company), license.id, false);
assert.ok(locallyDisabled.isSuccess);
assert.equal(locallyDisabled.data.active(license, Maybe.some(company)), false);
assert.equal(locallyDisabled.data.active(license, Maybe.none()), true);
assert.equal(seedResourceCatalog(locallyDisabled.data), locallyDisabled.data);
});

View File

@ -23,13 +23,21 @@ export const inventorySpanish = {
snapshotNotice: snapshotNotice:
"La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.", "La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
resourceDescription: resourceDescription:
"Registra un buzón, carpeta compartida, aplicación o certificado disponible en el contexto de esta compañía.", "Registra un buzón, carpeta compartida, aplicación, certificado o licencia de software disponible en el contexto de esta compañía.",
resourceExamples: resourceExamples:
"Ejemplos: administracion@empresa.es, carpeta Administración, Zoom corporativo o certificado de firma.", "Ejemplos: administracion@empresa.es, carpeta Administración, Zoom corporativo, certificado de firma o suscripción Microsoft 365.",
resourceGuidance: resourceGuidance:
"Las características son opcionales salvo que la plantilla indique lo contrario. No registres contraseñas ni claves privadas.", "Las características son opcionales salvo que la plantilla indique lo contrario. No registres contraseñas ni claves privadas.",
resourceScope: resourceScope:
"Este registro identifica el recurso de la compañía. Su disponibilidad y su configuración en los equipos se gestionan por separado.", "Este registro identifica el recurso de la compañía. Su disponibilidad y su configuración en los equipos se gestionan por separado.",
licenseDescription:
"Registra una licencia o suscripción de software contratada por esta compañía.",
licenseExamples:
"Ejemplos: Microsoft 365 Business Standard por usuario, ESET Endpoint por dispositivo o una licencia perpetua de ERP.",
licenseGuidance:
"Todos los campos son opcionales. Cantidad contratada indica las plazas o unidades del contrato. Caducidad y renovación pueden ser distintas; una licencia perpetua puede no tener caducidad. No registres claves de activación.",
licenseScope:
"La aplicación utilizada y la licencia contratada son recursos distintos. Este registro no asigna plazas ni calcula cuántas quedan disponibles.",
relatedNotice: "La plantilla se guardará aunque después canceles el recurso.", relatedNotice: "La plantilla se guardará aunque después canceles el recurso.",
inventoryErrors: { inventoryErrors: {
INVALID_REQUEST: "Revisa los datos del formulario.", INVALID_REQUEST: "Revisa los datos del formulario.",
@ -73,13 +81,20 @@ export const inventoryEnglish = {
recordedBy: "Recorded by {{name}}", recordedBy: "Recorded by {{name}}",
snapshotNotice: "This record retains the template fields captured when the resource was created.", snapshotNotice: "This record retains the template fields captured when the resource was created.",
resourceDescription: resourceDescription:
"Register a mailbox, shared folder, application or certificate used in this company's context.", "Register a mailbox, shared folder, application, certificate or software license used in this company's context.",
resourceExamples: resourceExamples:
"Examples: admin@company.com, Administration folder, corporate Zoom or signing certificate.", "Examples: admin@company.com, Administration folder, corporate Zoom, signing certificate or Microsoft 365 subscription.",
resourceGuidance: resourceGuidance:
"Characteristics are optional unless the template requires them. Never store passwords or private keys.", "Characteristics are optional unless the template requires them. Never store passwords or private keys.",
resourceScope: resourceScope:
"This record identifies the company resource. Availability and device configuration are managed separately.", "This record identifies the company resource. Availability and device configuration are managed separately.",
licenseDescription: "Register a software license or subscription contracted by this company.",
licenseExamples:
"Examples: Microsoft 365 Business Standard per user, ESET Endpoint per device or a perpetual ERP license.",
licenseGuidance:
"All fields are optional. Contracted quantity records seats or units covered by the contract. Expiration and renewal dates may differ; perpetual licenses may have no expiration. Do not enter activation keys.",
licenseScope:
"The application used and its contracted license are separate resources. This record does not allocate seats or calculate remaining capacity.",
relatedNotice: "The template remains saved if you later cancel the resource.", relatedNotice: "The template remains saved if you later cancel the resource.",
inventoryErrors: { inventoryErrors: {
INVALID_REQUEST: "Check the form data.", INVALID_REQUEST: "Check the form data.",

View File

@ -84,6 +84,10 @@ export function ResourceForm({
); );
const template = templates.find((item) => item.id === values.templateId); const template = templates.find((item) => item.id === values.templateId);
const fields = resourceFields(resource, template, items); const fields = resourceFields(resource, template, items);
const isSoftwareLicense = items.some(
(item) => item.id === values.templateId && item.builtinKey === "template:software_license"
);
const helpPrefix = isSoftwareLicense ? "license" : "resource";
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (input: ResourceInput) => mutationFn: (input: ResourceInput) =>
inventoryApi.save(companyId, input, csrfToken, resource?.id), inventoryApi.save(companyId, input, csrfToken, resource?.id),
@ -234,11 +238,11 @@ export function ResourceForm({
)} )}
<InventoryFeedback error={mutation.error ?? referenceQuery.error} /> <InventoryFeedback error={mutation.error ?? referenceQuery.error} />
<ContextHelp <ContextHelp
description={t("resourceDescription")} description={t(`${helpPrefix}Description`)}
examples={t("resourceExamples")} examples={t(`${helpPrefix}Examples`)}
examplesLabel={t("examplesLabel")} examplesLabel={t("examplesLabel")}
guidance={t("resourceGuidance")} guidance={t(`${helpPrefix}Guidance`)}
scopeNote={t("resourceScope")} scopeNote={t(`${helpPrefix}Scope`)}
title={t("helpTitle")} title={t("helpTitle")}
/> />
<FormActions> <FormActions>

View File

@ -75,3 +75,57 @@ test("recurso: ayuda inferior, plantilla con alta rápida y sin asignaciones de
); );
assert.equal(child.includes('aria-label="Nueva plantilla"'), false); assert.equal(child.includes('aria-label="Nueva plantilla"'), false);
}); });
test("licencia: formulario reutilizable con campos opcionales y ayuda antes de guardar", async () => {
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
const template = catalog.items.find((item) => item.builtinKey === "template:software_license");
assert.ok(template && template.values.kind === "template");
const fields = template.values.fields.map((binding) => {
const field = catalog.items.find((item) => item.id === binding.fieldId);
assert.ok(field && field.values.kind === "field");
return {
fieldId: field.id,
name: field.values.name,
type: field.values.type,
unit: field.values.unit,
choices: field.values.choices,
required: binding.required,
};
});
const resource = {
id: UniqueID.generateNewID().toPrimitive(),
companyId,
specification: { templateId: template.id, templateName: template.values.name, fields },
values: {
name: "Microsoft 365",
templateId: template.id,
active: true,
notes: "",
characteristics: {},
},
history: [],
};
for (const [language, help] of [
["es", "Registra una licencia o suscripción"],
["en", "Register a software license or subscription"],
]) {
assert.ok(language && help);
const html = await render(
language,
<ResourceForm
companyId={companyId}
csrfToken="test"
items={catalog.items}
onSaved={() => undefined}
resource={resource}
/>
);
assert.ok(html.includes("Cantidad contratada"));
assert.ok(html.includes("Fecha de renovación"));
assert.ok(html.includes("Renovación automática"));
assert.ok(html.includes(help));
assert.ok(html.indexOf(help) < html.indexOf('type="submit"'));
assert.equal(html.includes("Cantidad contratada *"), false);
assert.equal(html.includes("licenseDescription"), false);
}
});