From b7df37707bee10254251f632b04814c2fca1fa4b Mon Sep 17 00:00:00 2001 From: David Arranz Date: Thu, 22 Oct 2026 12:31:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20mejorar=20la=20inicializaci=C3=B3n=20de?= =?UTF-8?q?=20la=20estructura=20de=20la=20base=20de=20datos=20con=20valida?= =?UTF-8?q?ciones=20de=20tablas=20e=20=C3=ADndices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 +- ...pitulos-cotizaciones-integracion-legacy.md | 2 +- doc/capitulos-cotizaciones.md | 8 +- doc/capitulos-esquema.md | 44 ++++- doc/ficha-proyecto.md | 16 +- server/package.json | 1 + .../sequelize/SequelizeAdapter.test.ts | 24 +++ .../sequelize/SequelizeAdapter.ts | 14 +- .../common/infrastructure/sequelize/index.ts | 3 +- .../users/application/userServices.test.ts | 90 ++++++++++ .../users/application/userServices.ts | 6 +- server/src/infrastructure/http/server.test.ts | 16 +- server/src/infrastructure/http/server.ts | 24 +-- .../infrastructure/sequelize/initData.test.ts | 28 ++- .../src/infrastructure/sequelize/initData.ts | 28 +-- .../sequelize/initStructure.test.ts | 163 ++++++++++++++++-- .../infrastructure/sequelize/initStructure.ts | 135 +++++++++------ .../sequelize/initializeData.cli.test.ts | 49 ++++++ .../sequelize/initializeData.cli.ts | 41 +++++ .../sequelize/initializeData.test.ts | 71 ++++++++ .../sequelize/initializeData.ts | 56 ++++++ 21 files changed, 682 insertions(+), 147 deletions(-) create mode 100644 server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.test.ts create mode 100644 server/src/contexts/users/application/userServices.test.ts create mode 100644 server/src/infrastructure/sequelize/initializeData.cli.test.ts create mode 100644 server/src/infrastructure/sequelize/initializeData.cli.ts create mode 100644 server/src/infrastructure/sequelize/initializeData.test.ts create mode 100644 server/src/infrastructure/sequelize/initializeData.ts diff --git a/AGENTS.md b/AGENTS.md index 3ed8637..8058134 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,15 +76,11 @@ Usa controladores, casos de uso, repositorios, mapeadores y DTOs ya existentes c - La configuración Sequelize usa convenciones `underscored`, timestamps, `utf8mb4` y `utf8mb4_unicode_ci`. - Los repositorios y la infraestructura Sequelize son el lugar habitual para el acceso a persistencia; evita introducir consultas de base de datos en rutas o componentes. -No hay migraciones ni seeds formales localizados. El arranque de `server/src/infrastructure/http/server.ts` ejecuta: +No hay migraciones formales ni historial de versiones todavía. El arranque de `server/src/infrastructure/http/server.ts` autentica la conexión y ejecuta `checkDatabaseSchema`: comprueba tablas, vistas y columnas requeridas, pero no certifica tipos, defaults, índices ni FK. No ejecuta `sync`, `initStructure`, creación de cuentas ni correcciones de datos. -```ts -sequelizeConn.sync({ force: false, alter: true }) -initStructure(...) -insertUsers() -``` +La preparación de datos es explícita, desde `server/`, mediante `yarn db:init-data` con una o más opciones: `--admin`, `--sample`, `--tax-labels`. `--help` no conecta con la BD. El comando requiere un esquema ya preparado, no aplica migraciones y modifica datos solo al solicitar una operación. `initStructure` sigue siendo una rutina de preparación explícita del catálogo, fuera del arranque y sin historial de versiones. -Por ello, no inicies el backend, Docker Compose ni scripts de infraestructura contra una base de datos sin confirmar que la operación está dentro del alcance. No sustituyas este comportamiento por una estrategia de migraciones sin una tarea explícita. +No inicies el backend, Docker Compose ni comandos de inicialización contra una base de datos sin confirmar que la operación está dentro del alcance. No ejecutes inicialización ni introduzcas una estrategia de migraciones sin una tarea explícita. Las pruebas con dobles y la comprobación TypeScript no autorizan una ejecución real de esos comandos. ## Autenticación y autorización diff --git a/doc/capitulos-cotizaciones-integracion-legacy.md b/doc/capitulos-cotizaciones-integracion-legacy.md index 6e0cc99..88f2235 100644 --- a/doc/capitulos-cotizaciones-integracion-legacy.md +++ b/doc/capitulos-cotizaciones-integracion-legacy.md @@ -444,7 +444,7 @@ SHOW CREATE TABLE quote_chapters; SHOW CREATE TABLE quote_items; ``` -Revisar columnas, tipos físicos, claves, índices, motor transaccional y restricciones. No inferir que una FK propuesta o una tabla nueva está instalada solo porque aparezca en la documentación. El arranque del backend comprueba la presencia de tablas, vistas y columnas requeridas, sin instalar ni reparar el esquema. Esa comprobación no sustituye la revisión de tipos, índices y restricciones. El arranque conserva inicializaciones de datos y no debe utilizarse como mecanismo de inspección de solo lectura. +Revisar columnas, tipos físicos, claves, índices, motor transaccional y restricciones. No inferir que una FK propuesta o una tabla nueva está instalada solo porque aparezca en la documentación. El arranque del backend comprueba la presencia de tablas, vistas y columnas requeridas, sin instalar ni reparar el esquema. Esa comprobación no sustituye la revisión de tipos, índices y restricciones. El arranque no crea cuentas ni completa etiquetas de impuestos. La preparación de datos es una operación separada y explícita mediante `db:init-data`; véase [Inicialización de datos](capitulos-esquema.md#inicialización-explícita-de-datos). Completar etiquetas puede actualizar `quotes.updated_at`, por lo que el extractor debe tratar esas filas según su política habitual de modificaciones. ### Matriz mínima de aceptación del extractor diff --git a/doc/capitulos-cotizaciones.md b/doc/capitulos-cotizaciones.md index 090bf36..28b7500 100644 --- a/doc/capitulos-cotizaciones.md +++ b/doc/capitulos-cotizaciones.md @@ -2,6 +2,12 @@ Fecha: 19 de octubre de 2026. Estado: fases 1 a 5 implementadas y probadas sin conexión a base de datos, incluida generación real de PDF con Chromium. La aplicación y validación del esquema en MariaDB siguen pendientes de un entorno autorizado; todavía no se considera completada la funcionalidad de extremo a extremo. +## Estado de la preparación de base de datos + +Actualización de 22 de octubre de 2026: la API ya no ejecuta sincronización de esquema, preparación del catálogo, creación de cuentas ni correcciones de etiquetas de impuestos al arrancar. Autentica la conexión y comprueba las columnas requeridas antes de abrir HTTP. La inicialización de datos se solicita por separado con `db:init-data`; el usuario de ejemplo existente se recupera para poder completar su distribuidor tras un fallo. Los comandos, límites y recuperación están en [Inicialización explícita de datos](capitulos-esquema.md#inicialización-explícita-de-datos). + +Las referencias a `sync` en la auditoría inicial y en las pruebas históricas siguientes describen la implementación anterior. El ejecutor de migraciones, la adopción del esquema de producción y la validación de su actualización siguen pendientes. + ## Requisitos confirmados - Las cotizaciones nuevas comienzan sin capítulos. Las antiguas siguen pudiéndose consultar, editar, duplicar y exportar sin convertirlas. @@ -128,7 +134,7 @@ show_chapter_line_amounts: boolean; - Crear y actualizar cabecera, capítulos y detalles en la misma transacción. Con el reemplazo actual, eliminar primero detalles, después capítulos, insertar capítulos y finalmente detalles; no ejecutar en paralelo operaciones dependientes de las FK. - Los registros históricos conservan `chapter_id = null` y ningún capítulo. No crear capítulos automáticamente al leerlos. -La preparación y aplicación del cambio de esquema será una tarea de implementación/despliegue expresamente autorizada. Esta auditoría no ejecuta migraciones ni arranca el backend. El arranque actual usa `sync({ alter: true })`: antes de desplegar hay que revisar su efecto sobre la nueva tabla, columnas, asociaciones y FK en una base aislada. No dar por validado el esquema solo porque TypeScript compile. +La preparación y aplicación del cambio de esquema será una tarea de implementación/despliegue expresamente autorizada. Esta auditoría no ejecuta migraciones ni arranca el backend. El arranque auditado inicialmente usaba `sync({ alter: true })`; ese mecanismo ya se ha retirado. Antes de desplegar hay que preparar y verificar explícitamente la nueva tabla, columnas, asociaciones y FK en una base aislada. No dar por validado el esquema solo porque TypeScript compile. ## Reglas de dominio y compatibilidad diff --git a/doc/capitulos-esquema.md b/doc/capitulos-esquema.md index ff1125f..f5e2bd7 100644 --- a/doc/capitulos-esquema.md +++ b/doc/capitulos-esquema.md @@ -31,7 +31,7 @@ ALTER TABLE quote_items Es una definición para revisión, no un script de migración ejecutado. Antes de aplicarla se deben contrastar los tipos físicos, collation, motor y restricciones existentes de las tablas reales. Con `chapter_id = NULL`, las líneas antiguas no requieren un capítulo. El repositorio elimina primero detalles y luego capítulos, por lo que el reemplazo completo respeta esta restricción. -**Límite actual:** el modelo Sequelize no representa ni instala esta FK compuesta; la garantía de pertenencia implementada ahora es de aplicación. No afirmar que la base de datos la impone hasta aplicarla y verificarla. El arranque de la API ya no ejecuta `sync` ni `initStructure`: autentica la conexión y comprueba que las tablas, vistas y columnas requeridas por los modelos existen antes de actualizar datos o abrir HTTP. La comprobación es de solo lectura y no certifica tipos, defaults, índices ni FK. La instalación del esquema y el historial de migraciones siguen pendientes; una BD vacía o anterior a capítulos no arrancará con esta versión hasta preparar su esquema explícitamente. No añadir DDL automático en hooks ni reintroducir `sync` para resolver ese error. +**Límite actual:** el modelo Sequelize no representa ni instala esta FK compuesta; la garantía de pertenencia implementada ahora es de aplicación. No afirmar que la base de datos la impone hasta aplicarla y verificarla. El arranque de la API ya no ejecuta `sync` ni `initStructure`: autentica la conexión y comprueba que las tablas, vistas y columnas requeridas por los modelos existen antes de abrir HTTP. El arranque tampoco crea cuentas ni completa etiquetas de impuestos. La comprobación es de solo lectura y no certifica tipos, defaults, índices ni FK. La instalación del esquema y el historial de migraciones siguen pendientes; una BD vacía o anterior a capítulos no arrancará con esta versión hasta preparar su esquema explícitamente. No añadir DDL automático en hooks ni reintroducir `sync` para resolver ese error. ## Comprobaciones para una aplicación autorizada @@ -46,6 +46,48 @@ Es una definición para revisión, no un script de migración ejecutado. Antes d Las pruebas de fase 3 usan metadatos Sequelize y dobles transaccionales, sin abrir conexiones. Verifican contrato, mapeo, orden de operaciones, propagación de transacción y errores; no certifican el DDL, el bloqueo concurrente ni el rollback real de MariaDB. Estas comprobaciones quedan para el entorno autorizado de integración. +## Inicialización explícita de datos + +La API ya no modifica etiquetas de impuestos ni crea administrador, usuario o distribuidor de ejemplo al arrancar. El esquema debe estar preparado antes del arranque y antes de cualquier inicialización de datos. Estas operaciones no crean tablas, no instalan capítulos y no registran versiones de migración. + +Desde fuentes, con las variables del entorno elegido ya configuradas, ejecutar desde `server/`: + +```bash +yarn db:init-data --help +yarn db:init-data --admin +yarn db:init-data --tax-labels +``` + +La ayuda no carga la configuración ni conecta con la BD. Sin opciones, o con opciones desconocidas, el comando termina con código 1 sin iniciar operaciones. Se pueden combinar las opciones siguientes: + +| Opción | Operación | Comportamiento al repetir | +| --- | --- | --- | +| `--admin` | Crea el administrador configurado si no existe ningún administrador activo según el repositorio. | Conserva las cuentas existentes; no restablece contraseñas ni promueve un usuario existente. | +| `--sample` | Completa el usuario y el distribuidor de ejemplo configurados. | Recupera el usuario por email y comprueba su distribuidor, aunque el usuario ya existiese. No cambia sus datos ni contraseña. | +| `--tax-labels` | Completa `dealers.default_tax_label` y `quotes.tax_label` únicamente donde sean NULL. | Conserva las etiquetas no NULL. Las dos actualizaciones usan una misma transacción. | + +Los datos de ejemplo requieren expresamente `yarn db:init-data --sample`; no se crean al solicitar solo `--admin`. No hay una operación predeterminada. La inicialización utiliza los valores del entorno existente, no recibe contraseñas por argumentos y desactiva el registro SQL de su conexión. El CLI informa de la fase fallida sin imprimir SQL, configuración ni errores originales. + +El comando autentica la conexión y comprueba las columnas requeridas antes de escribir. Cierra el pool al terminar, también si hay un fallo; devuelve código 0 si termina correctamente y 1 ante un error. No inicia HTTP ni ejecuta `sync` o `initStructure`. + +### Ejecución compilada en producción + +La configuración de producción carga archivos `.js`. En la imagen actual, desde su directorio `/api`, el punto de entrada compilado utiliza los mismos preloads que la API: + +```bash +NODE_ENV=production node -r ts-node/register/transpile-only -r tsconfig-paths/register ./server/src/infrastructure/sequelize/initializeData.cli.js --admin +``` + +Este ejemplo requiere la imagen construida con el nuevo código, el esquema preparado y las variables de conexión y configuración del destino. No usar el comando de fuentes con `NODE_ENV=production`: el cargador espera la configuración compilada. No incluir la inicialización en el arranque de cada réplica. Las operaciones de escritura se ejecutan solo en el entorno y momento autorizados. + +### Recuperación y límites + +Si la creación del distribuidor falla después de haberse confirmado su usuario, repetir `--sample` reutiliza ese usuario y vuelve a intentar completar el distribuidor. Las operaciones de cuentas no forman una única transacción global: los pasos ya confirmados se conservan. Ejecutar un único inicializador cada vez; todavía no existe un bloqueo distribuido ni un historial de seeds. + +La corrección de etiquetas puede actualizar `updated_at` mediante Sequelize, aunque no modifica importes ni posiciones. El extractor legacy debe aplicar su política habitual para cotizaciones modificadas. + +El inventario de producción, el historial de migraciones, la instalación del esquema de capítulos y la validación real contra MariaDB siguen pendientes. La rutina explícita `initStructure` cubre el catálogo y no sustituye esas migraciones. + ## Suite de integración preparada Comando explícito desde `server/`: `yarn test:mariadb`. Usa `jest.mariadb.config.js` y `src/contexts/sales/infrastructure/__integration__/quoteChapters.mariadb.test.ts`. La suite habitual de Jest excluye los tests `*.mariadb.test.ts` y conserva las comprobaciones unitarias de seguridad del entorno de pruebas. diff --git a/doc/ficha-proyecto.md b/doc/ficha-proyecto.md index 822866e..a622fb2 100644 --- a/doc/ficha-proyecto.md +++ b/doc/ficha-proyecto.md @@ -60,15 +60,15 @@ El `package.json` raíz declara únicamente `shared` como workspace. `client` y - Servicio contenedorizado: imagen `mariadb:latest`; por tanto la versión de MariaDB no está fijada y `11.5` no puede confirmarse. - Conexión mediante variables `MYSQL_*`; en producción el host previsto es el servicio `mariadb`. - Migraciones: no se localizaron migraciones formales. -- Seeds: no se localizaron seeds formales. El servidor ejecuta inicialización propia mediante `initStructure()` e `insertUsers()` al arrancar. -- La sincronización Sequelize se ejecuta con `{ force: false, alter: true }`. +- Inicialización de datos explícita: `cd server && yarn db:init-data` requiere seleccionar `--admin`, `--sample` o `--tax-labels`. No existe historial de seeds. +- La API autentica la conexión y comprueba tablas, vistas y columnas requeridas. El arranque no ejecuta `sync`, `initStructure`, creación de cuentas ni correcciones de datos. Un esquema vacío o incompleto requiere preparación explícita antes de arrancar. **Testing:** - Infraestructura Jest/ts-jest declarada en los tres paquetes. -- Pruebas encontradas: 8 archivos de prueba: 2 en `client` y 6 en `shared`; no se localizaron pruebas bajo `server/src`. -- Testing Library está declarada en cliente; Supertest está declarado en servidor, sin pruebas de servidor localizadas. -- No hay evidencia de pruebas E2E con Cypress o Playwright. +- Existen pruebas en cliente, shared y servidor; las de infraestructura cubren arranque, esquema e inicialización explícita de datos con dobles. +- Testing Library está declarada en cliente; Supertest está declarado en servidor. +- Hay pruebas E2E con Playwright bajo `e2e/`. La creación del esquema de una BD E2E vacía sigue pendiente de integrarse con las futuras migraciones. **Infraestructura / despliegue:** @@ -103,7 +103,7 @@ Backend organizado por contextos funcionales y capas `domain` / `application` / - El acceso a base de datos se concentra predominantemente en adaptadores, repositorios y modelos de infraestructura Sequelize. - Las llamadas HTTP del frontend se centralizan en el proveedor Axios y el `DataSourceProvider`. - React Query maneja el estado remoto; los providers Context manejan autenticación, fuente de datos, tema y avisos de cambios sin guardar. -- El arranque del backend modifica potencialmente la estructura y datos de la base de datos mediante sincronización Sequelize e inicialización propia. +- El arranque del backend comprueba conexión y columnas sin preparar estructura ni datos. Las operaciones explícitas de inicialización están separadas de HTTP y requieren un esquema compatible. ## 5. Convenciones relevantes @@ -183,7 +183,7 @@ NO DETERMINADO. Hay configuración Prettier en raíz, pero no se declara un scri **Migraciones / seeds:** -NO DETERMINADO como comandos. No se localizaron scripts ni herramientas de migración/seed declaradas. +No hay ejecutor ni historial de migraciones. Para preparar datos existe `cd server && yarn db:init-data --help`; las opciones de escritura son `--admin`, `--sample` y `--tax-labels`, combinables y sin selección predeterminada. Requieren un esquema compatible y la configuración del entorno destino. La ejecución desde fuentes usa ts-node; producción debe usar el ejecutable compilado. Véanse los comandos y condiciones en [Inicialización de datos](capitulos-esquema.md#inicialización-explícita-de-datos). **Docker:** @@ -213,7 +213,7 @@ yarn docker:ps - La configuración de producción limita CORS a `https://presupuestos.uecko.com`. - La aplicación cliente depende de `VITE_API_URL`. - La persistencia usa dialecto MySQL a través de Sequelize; el despliegue Docker suministra MariaDB. -- El servidor puede alterar el esquema al iniciar debido a `sequelize.sync({ alter: true })`; no debe tratarse como un arranque sin efectos sobre la base de datos. +- El arranque ya no altera el esquema ni inicializa datos. `db:init-data` y `initStructure` son operaciones explícitas de escritura y no deben ejecutarse como inspección. Las migraciones de producción siguen pendientes. - Los idiomas configurados en cliente son español, inglés y catalán. ## 9. Observaciones diff --git a/server/package.json b/server/package.json index 1ecacb0..444d4f5 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ "lint": "eslint --ignore-path .gitignore . --ext .ts", "lint:fix": "npm run lint -- --fix", "test": "jest --config=./jest.config.js --verbose", + "db:init-data": "ts-node -r tsconfig-paths/register ./src/infrastructure/sequelize/initializeData.cli.ts", "test:mariadb": "cross-env UECKO_MARIADB_INTEGRATION=1 jest --config=./jest.mariadb.config.js --runInBand --verbose", "clean": "rm -rf node_modules" }, diff --git a/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.test.ts b/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.test.ts new file mode 100644 index 0000000..54b76c4 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.test.ts @@ -0,0 +1,24 @@ +import { Sequelize } from "sequelize"; +import type { ISequelizeQueryBuilder } from "./queryBuilder/SequelizeQueryBuilder"; + +jest.mock("@/config", () => ({ config: { database: { + database: "synthetic", username: "synthetic", password: "synthetic", host: "example.invalid", dialect: "mysql", port: 3306, +} } })); +jest.mock("@/infrastructure/logger", () => ({ initLogger: () => ({ debug: jest.fn() }) })); +jest.mock("glob", () => ({ sync: () => [] })); +jest.mock("sequelize", () => ({ Sequelize: jest.fn(() => ({ models: {} })), DataTypes: {} })); + +beforeEach(() => { jest.clearAllMocks(); }); + +it.each([ + { logging: false as const, expectedLogging: false, parameters: false }, + { logging: undefined, expectedLogging: expect.any(Function), parameters: true }, +])("configures SQL logging for an isolated initializer with logging=$logging", async ({ logging, expectedLogging, parameters }) => { + await jest.isolateModulesAsync(async () => { + const { SequelizeAdapter } = await import("./SequelizeAdapter"); + SequelizeAdapter.getInstance({ queryBuilder: {} as ISequelizeQueryBuilder, logging }); + }); + expect(Sequelize).toHaveBeenCalledWith("synthetic", "synthetic", "synthetic", expect.objectContaining({ + logging: expectedLogging, logQueryParameters: parameters, + })); +}); diff --git a/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts b/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts index b42caac..de4a9c6 100644 --- a/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts +++ b/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts @@ -17,6 +17,8 @@ import { ISequelizeQueryBuilder } from "./queryBuilder/SequelizeQueryBuilder"; //import * as dotenv from "dotenv"; //dotenv.config(); +type SequelizeAdapterParams = { queryBuilder: ISequelizeQueryBuilder; logging?: false }; + export interface ISequelizeAdapter extends IAdapter { queryBuilder: ISequelizeQueryBuilder; startTransaction: () => SequelizeBusinessTransactionType; @@ -28,7 +30,7 @@ export class SequelizeAdapter implements ISequelizeAdapter { // eslint-disable-next-line no-use-before-define private static instance: SequelizeAdapter; - public static getInstance(params: { queryBuilder: ISequelizeQueryBuilder }): SequelizeAdapter { + public static getInstance(params: SequelizeAdapterParams): SequelizeAdapter { if (!SequelizeAdapter.instance) { SequelizeAdapter.instance = SequelizeAdapter.create(params); } @@ -36,10 +38,10 @@ export class SequelizeAdapter implements ISequelizeAdapter { return SequelizeAdapter.instance; } - private static create(params: { queryBuilder: ISequelizeQueryBuilder }) { + private static create(params: SequelizeAdapterParams) { const { queryBuilder } = params; - const connection = initConnection(); + const connection = initConnection(params.logging); const models = registerModels(connection); return new SequelizeAdapter(connection, models, queryBuilder); @@ -86,7 +88,7 @@ export class SequelizeAdapter implements ISequelizeAdapter { } } -function initConnection(): Sequelize { +function initConnection(logging?: false): Sequelize { const { username, password, database, host, dialect, port } = config.database; const logger = initLogger(rTracer); @@ -108,8 +110,8 @@ function initConnection(): Sequelize { acquire: 60000, idle: 10000, }, - logQueryParameters: true, - logging: (sql, timing) => logger.debug(sql, timing), + logQueryParameters: logging !== false, + logging: logging === false ? false : (sql, timing) => logger.debug(sql, timing), define: { charset: "utf8mb4", diff --git a/server/src/contexts/common/infrastructure/sequelize/index.ts b/server/src/contexts/common/infrastructure/sequelize/index.ts index d3c3708..2efe92c 100644 --- a/server/src/contexts/common/infrastructure/sequelize/index.ts +++ b/server/src/contexts/common/infrastructure/sequelize/index.ts @@ -1,9 +1,10 @@ import { ISequelizeAdapter, SequelizeAdapter } from "./SequelizeAdapter"; import { createSequelizeQueryBuilder } from "./queryBuilder"; -const createSequelizeAdapter = () => { +const createSequelizeAdapter = (options: { logging?: false } = {}) => { return SequelizeAdapter.getInstance({ queryBuilder: createSequelizeQueryBuilder(), + ...options, }); }; diff --git a/server/src/contexts/users/application/userServices.test.ts b/server/src/contexts/users/application/userServices.test.ts new file mode 100644 index 0000000..5d1e0ea --- /dev/null +++ b/server/src/contexts/users/application/userServices.test.ts @@ -0,0 +1,90 @@ +import { initializeAdmin, initializeSampleDealer, initializeSampleUser } from "./userServices"; +import { IAdapter, RepositoryBuilder } from "@/contexts/common/domain"; +import { IUserRepository, User } from "../domain"; +import { Dealer, IDealerRepository } from "@/contexts/sales/domain"; +import { UniqueID } from "@shared/contexts"; + +jest.mock("@/config", () => ({ config: { + admin: { name: "Test Admin", email: "admin@example.invalid", password: "synthetic-password", language: "es" }, + sample_dealer: { name: "Test Dealer", email: "dealer@example.invalid", password: "synthetic-password", language: "es" }, +} })); + +const transaction = {}; +const adapter = { startTransaction: () => ({ complete: async (work: (t: object) => Promise) => work(transaction) }) } as unknown as IAdapter; +let user: User | null; +let dealer: Dealer | null; +const users = { findUserByEmail: jest.fn(), create: jest.fn(), existsAdminUser: jest.fn() }; +const dealers = { getByUserId: jest.fn(), create: jest.fn() }; +const userRepository = jest.fn(() => users) as unknown as RepositoryBuilder; +const dealerRepository = jest.fn(() => dealers) as unknown as RepositoryBuilder; +const completeSample = async () => { + const result = await initializeSampleUser(adapter, userRepository); + await initializeSampleDealer(result, adapter, dealerRepository); + return result; +}; + +beforeEach(() => { + jest.resetAllMocks(); + user = null; + dealer = null; + users.findUserByEmail.mockImplementation(async () => user); + users.existsAdminUser.mockResolvedValue(true); + users.create.mockImplementation(async (created: User) => { user = created; }); + dealers.getByUserId.mockImplementation(async () => dealer); + dealers.create.mockImplementation(async (created: Dealer) => { dealer = created; }); + jest.mocked(userRepository).mockReturnValue(users as unknown as IUserRepository); + jest.mocked(dealerRepository).mockReturnValue(dealers as unknown as IDealerRepository); +}); + +it("returns the existing sample user without overwriting account data", async () => { + user = { id: UniqueID.generateNewID().object } as User; + await expect(initializeSampleUser(adapter, userRepository)).resolves.toBe(user); + expect(users.create).not.toHaveBeenCalled(); + expect(userRepository).toHaveBeenCalledWith({ transaction }); +}); + +it("completes a missing dealer for an existing user", async () => { + user = { id: UniqueID.generateNewID().object } as User; + await completeSample(); + expect(users.create).not.toHaveBeenCalled(); + expect(dealers.create).toHaveBeenCalledTimes(1); + expect(dealer?.user_id.toString()).toBe(user.id.toString()); +}); + +it("recovers after dealer creation fails, without duplicating the committed user", async () => { + const failure = new Error("Dealer insert failed"); + dealers.create.mockRejectedValueOnce(failure); + await expect(completeSample()).rejects.toBe(failure); + const committedUser = user; + expect(committedUser).not.toBeNull(); + expect(dealer).toBeNull(); + + await expect(completeSample()).resolves.toBe(committedUser); + expect(users.create).toHaveBeenCalledTimes(1); + expect(dealers.create).toHaveBeenCalledTimes(2); + expect(dealer).not.toBeNull(); + + await completeSample(); + expect(users.create).toHaveBeenCalledTimes(1); + expect(dealers.create).toHaveBeenCalledTimes(2); +}); + +it("does not modify existing sample accounts on repeated runs", async () => { + const created = await completeSample(); + await expect(completeSample()).resolves.toBe(created); + expect(users.create).toHaveBeenCalledTimes(1); + expect(dealers.create).toHaveBeenCalledTimes(1); +}); + +it("does not create or reset an administrator when one already exists", async () => { + await initializeAdmin(adapter, userRepository); + expect(users.create).not.toHaveBeenCalled(); +}); + +it("propagates sample user lookup failures", async () => { + const failure = new Error("Lookup failed"); + users.findUserByEmail.mockRejectedValue(failure); + await expect(completeSample()).rejects.toBe(failure); + expect(users.create).not.toHaveBeenCalled(); + expect(dealers.create).not.toHaveBeenCalled(); +}); diff --git a/server/src/contexts/users/application/userServices.ts b/server/src/contexts/users/application/userServices.ts index d9f8a29..f99be5f 100644 --- a/server/src/contexts/users/application/userServices.ts +++ b/server/src/contexts/users/application/userServices.ts @@ -84,9 +84,9 @@ export const initializeSampleUser = async ( ) => { return await adapter.startTransaction().complete(async (t) => { const email = Email.create(config.sample_dealer.email).object; - const userExists = await repository({ transaction: t }).existsUserWithEmail(email); + const existingUser = await repository({ transaction: t }).findUserByEmail(email); - if (!userExists) { + if (!existingUser) { const user = User.create( { name: Name.create(config.sample_dealer.name).object, @@ -101,7 +101,7 @@ export const initializeSampleUser = async ( //console.log("Usuario creado"); return user; } else { - return false; + return existingUser; } }); }; diff --git a/server/src/infrastructure/http/server.test.ts b/server/src/infrastructure/http/server.test.ts index f9c7675..eb3c7db 100644 --- a/server/src/infrastructure/http/server.test.ts +++ b/server/src/infrastructure/http/server.test.ts @@ -27,6 +27,7 @@ jest.mock("@/contexts/sales/infrastructure/sequelize/dealer.model", () => ({ jest.mock("@/contexts/sales/infrastructure/sequelize/quote.model", () => ({ DEFAULT_QUOTE_TAX_LABEL: "% IVA", Quote_Model: { update: (...args: unknown[]) => updateQuote(...args) }, })); +jest.mock("../sequelize/checkDatabaseSchema", () => ({ checkDatabaseSchema: (...args: unknown[]) => checkSchema(...args) })); jest.mock("../sequelize", () => ({ initStructure: (...args: unknown[]) => prepareStructure(...args), checkDatabaseSchema: (...args: unknown[]) => checkSchema(...args), @@ -83,24 +84,25 @@ it("waits for each initialization step before opening HTTP", async () => { const order: string[] = []; for (const [name, step] of [ ["authenticate", authenticate], ["schema", checkSchema], - ["dealer", updateDealer], ["quote", updateQuote], ["users", prepareUsers], ] as const) step.mockImplementation(async () => { order.push(name); }); server.listen.mockImplementation(() => { order.push("listen"); server.emit("listening"); }); loadServer(); await flush(); - expect(order).toEqual(["authenticate", "schema", "dealer", "quote", "users", "listen"]); + expect(order).toEqual(["authenticate", "schema", "listen"]); expect(sync).not.toHaveBeenCalled(); expect(prepareStructure).not.toHaveBeenCalled(); + expect(prepareUsers).not.toHaveBeenCalled(); + expect(updateDealer).not.toHaveBeenCalled(); + expect(updateQuote).not.toHaveBeenCalled(); expect(checkSchema).toHaveBeenCalledWith(expect.objectContaining({ authenticate })); expect(exit).not.toHaveBeenCalled(); }); -it.each(["schema", "users"])("does not listen while %s initialization is pending", async (stage) => { +it("does not listen while schema inspection is pending", async () => { let finish!: () => void; - const step = stage === "schema" ? checkSchema : prepareUsers; - step.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + checkSchema.mockReturnValue(new Promise((resolve) => { finish = resolve; })); loadServer(); await flush(); expect(server.listen).not.toHaveBeenCalled(); @@ -109,8 +111,8 @@ it.each(["schema", "users"])("does not listen while %s initialization is pending expect(server.listen).toHaveBeenCalledTimes(1); }); -it.each(["authenticate", "schema", "dealer", "quote", "users"])("closes the database and exits on %s failure without opening HTTP", async (stage) => { - const steps = { authenticate, schema: checkSchema, dealer: updateDealer, quote: updateQuote, users: prepareUsers }; +it.each(["authenticate", "schema"])("closes the database and exits on %s failure without opening HTTP", async (stage) => { + const steps = { authenticate, schema: checkSchema }; const failure = new Error("Initialization failed"); steps[stage as keyof typeof steps].mockRejectedValue(failure); loadServer(); diff --git a/server/src/infrastructure/http/server.ts b/server/src/infrastructure/http/server.ts index c781446..a036dd6 100644 --- a/server/src/infrastructure/http/server.ts +++ b/server/src/infrastructure/http/server.ts @@ -4,18 +4,10 @@ import { assign } from "lodash"; import { DateTime, Settings } from "luxon"; import { createSequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; -import { - DEFAULT_TAX_LABEL, - Dealer_Model, -} from "@/contexts/sales/infrastructure/sequelize/dealer.model"; -import { - DEFAULT_QUOTE_TAX_LABEL, - Quote_Model, -} from "@/contexts/sales/infrastructure/sequelize/quote.model"; import { config } from "../../config"; import { app } from "../express/app"; import { initLogger } from "../logger"; -import { checkDatabaseSchema, insertUsers } from "../sequelize"; +import { checkDatabaseSchema } from "../sequelize/checkDatabaseSchema"; process.env.TZ = "UTC"; Settings.defaultLocale = "es-ES"; @@ -106,20 +98,6 @@ const startServer = async () => { await checkDatabaseSchema(sequelizeConn.connection); if (stopping) return; - await Dealer_Model.update( - { default_tax_label: DEFAULT_TAX_LABEL }, - { where: { default_tax_label: null } } - ); - if (stopping) return; - await Quote_Model.update( - { tax_label: DEFAULT_QUOTE_TAX_LABEL }, - { where: { tax_label: null } } - ); - if (stopping) return; - - await insertUsers(); - if (stopping) return; - await new Promise((resolve, reject) => { const onError = (error: Error) => { server.removeListener("listening", onListening); diff --git a/server/src/infrastructure/sequelize/initData.test.ts b/server/src/infrastructure/sequelize/initData.test.ts index a833cef..28a7f51 100644 --- a/server/src/infrastructure/sequelize/initData.test.ts +++ b/server/src/infrastructure/sequelize/initData.test.ts @@ -16,36 +16,36 @@ const dealer = jest.mocked(initializeSampleDealer); beforeEach(() => { jest.resetAllMocks(); admin.mockResolvedValue(undefined); - user.mockResolvedValue(false); + user.mockResolvedValue({} as Awaited>); dealer.mockResolvedValue(undefined); }); it("awaits admin initialization before initializing the sample user", async () => { let finish!: () => void; admin.mockReturnValue(new Promise((resolve) => { finish = resolve; })); - const pending = insertUsers(); + const pending = insertUsers({ admin: true, sample: true }); expect(user).not.toHaveBeenCalled(); finish(); await pending; expect(user).toHaveBeenCalledTimes(1); - expect(dealer).not.toHaveBeenCalled(); + expect(dealer).toHaveBeenCalledTimes(1); }); it.each(["admin", "user", "dealer"])("propagates %s initialization errors", async (stage) => { - user.mockResolvedValue({} as Exclude>, boolean>); + user.mockResolvedValue({} as Awaited>); const failure = new Error("Initialization failed"); ({ admin, user, dealer })[stage as "admin" | "user" | "dealer"].mockRejectedValue(failure); - await expect(insertUsers()).rejects.toBe(failure); + await expect(insertUsers({ admin: true, sample: true })).rejects.toBe(failure); expect(user).toHaveBeenCalledTimes(stage === "admin" ? 0 : 1); expect(dealer).toHaveBeenCalledTimes(stage === "dealer" ? 1 : 0); }); it("does not complete until the sample dealer is initialized", async () => { - user.mockResolvedValue({} as Exclude>, boolean>); + user.mockResolvedValue({} as Awaited>); let finish!: () => void; dealer.mockReturnValue(new Promise((resolve) => { finish = resolve; })); let completed = false; - const pending = insertUsers().then(() => { completed = true; }); + const pending = insertUsers({ admin: true, sample: true }).then(() => { completed = true; }); await Promise.resolve(); await Promise.resolve(); expect(dealer).toHaveBeenCalledTimes(1); @@ -54,3 +54,17 @@ it("does not complete until the sample dealer is initialized", async () => { await pending; expect(completed).toBe(true); }); + +it("initializes only the administrator when requested", async () => { + await insertUsers({ admin: true, sample: false }); + expect(admin).toHaveBeenCalledTimes(1); + expect(user).not.toHaveBeenCalled(); + expect(dealer).not.toHaveBeenCalled(); +}); + +it("initializes sample accounts without creating an administrator", async () => { + await insertUsers({ admin: false, sample: true }); + expect(admin).not.toHaveBeenCalled(); + expect(user).toHaveBeenCalledTimes(1); + expect(dealer).toHaveBeenCalledTimes(1); +}); diff --git a/server/src/infrastructure/sequelize/initData.ts b/server/src/infrastructure/sequelize/initData.ts index cf0ca0d..f3430b5 100644 --- a/server/src/infrastructure/sequelize/initData.ts +++ b/server/src/infrastructure/sequelize/initData.ts @@ -8,29 +8,35 @@ import { initializeSampleDealer, initializeSampleUser, } from "@/contexts/users/application/userServices"; +import { IUserContext } from "@/contexts/users/infrastructure/User.context"; +import { IUserRepository } from "@/contexts/users/domain"; +import { IDealerRepository } from "@/contexts/sales/domain"; import { registerUserRepository } from "@/contexts/users/infrastructure/User.repository"; -export const insertUsers = async () => { - const context = { +export type UserInitializationOptions = { admin: boolean; sample: boolean }; + +export const insertUsers = async ({ admin, sample }: UserInitializationOptions) => { + const context: IUserContext = { defaults: config.defaults, adapter: createSequelizeAdapter(), repositoryManager: RepositoryManager.getInstance(), - } as any; + }; registerUserRepository(context); registerDealerRepository(context); - await initializeAdmin(context.adapter, context.repositoryManager.getRepository("User")); - const user = await initializeSampleUser( - context.adapter, - context.repositoryManager.getRepository("User") - ); - - if (user) { + if (admin) { + await initializeAdmin(context.adapter, context.repositoryManager.getRepository("User")); + } + if (sample) { + const user = await initializeSampleUser( + context.adapter, + context.repositoryManager.getRepository("User") + ); await initializeSampleDealer( user, context.adapter, - context.repositoryManager.getRepository("Dealer") + context.repositoryManager.getRepository("Dealer") ); } }; diff --git a/server/src/infrastructure/sequelize/initStructure.test.ts b/server/src/infrastructure/sequelize/initStructure.test.ts index 9cf7190..b4c6f23 100644 --- a/server/src/infrastructure/sequelize/initStructure.test.ts +++ b/server/src/infrastructure/sequelize/initStructure.test.ts @@ -3,30 +3,159 @@ import { initStructure } from "./initStructure"; jest.mock("@/infrastructure/logger", () => ({ initLogger: () => ({ debug: jest.fn(), error: jest.fn() }) })); -it.each(["catalog", "catalog_translations", "v_catalog"])("propagates %s creation errors and stops initialization", async (object) => { - const failure = new Error("DDL failed"); - const query = jest.fn(async (sql: string) => { - if (sql.includes("SELECT COUNT(*)")) return [[{ count: 0 }]]; - if (sql.includes(`CREATE TABLE ${object} (`) || sql.includes(`CREATE VIEW ${object} AS`)) throw failure; +type Index = { name: string; unique: boolean; type: string; fields: { attribute: string; length?: number }[] }; +const index = (name: string, field: string): Index => ({ name, unique: false, type: "BTREE", fields: [{ attribute: field }] }); +const columns = { + catalog: ["id", "catalog_name", "id_article", "reference", "points", "retail_price", "created_at", "updated_at", "deleted_at"], + catalog_translations: ["id", "lang_code", "description", "catalog_id"], +}; +type Table = keyof typeof columns; +let tables: Set; +let descriptions: Record>; +let indexes: Record; +let viewDefinition: string; +let query: jest.Mock; +let describeTable: jest.Mock; +let showIndex: jest.Mock; +let addIndex: jest.Mock; +let connection: Sequelize; + +beforeEach(() => { + tables = new Set(["catalog", "catalog_translations"]); + descriptions = Object.fromEntries(Object.entries(columns).map(([table, names]) => [ + table, Object.fromEntries(names.map((name) => [name, { primaryKey: name === "id" }])), + ])) as typeof descriptions; + indexes = { catalog: [], catalog_translations: [] }; + viewDefinition = "obsolete definition"; + query = jest.fn(async (sql: string) => { + const table = sql.match(/table_name = '(\w+)'/)?.[1] as Table; + if (sql.includes("SELECT COUNT(*)")) return [[{ count: tables.has(table) ? 1 : 0 }]]; + const createdTable = sql.match(/CREATE TABLE (\w+)/)?.[1] as Table; + if (createdTable) tables.add(createdTable); + if (sql.includes("CREATE OR REPLACE VIEW")) viewDefinition = sql; return []; }); - await expect(initStructure({ query } as unknown as Sequelize)).rejects.toBe(failure); + describeTable = jest.fn(async (table: Table) => descriptions[table]); + showIndex = jest.fn(async (table: Table) => [...indexes[table]]); + addIndex = jest.fn(async (table: Table, fields: string[], options: { name: string }) => { + indexes[table].push(index(options.name, fields[0])); + }); + connection = { + query, + getQueryInterface: () => ({ describeTable, showIndex, addIndex }), + } as unknown as Sequelize; +}); + +it("inspects existing tables and completes all missing indexes", async () => { + await initStructure(connection); + expect(describeTable.mock.calls).toEqual([["catalog"], ["catalog_translations"]]); + expect(addIndex).toHaveBeenCalledTimes(5); + expect(query.mock.calls.some(([sql]) => sql.includes("CREATE TABLE"))).toBe(false); + expect(indexes.catalog.map(({ name }) => name)).toEqual(["catalog_name_idx", "id_article_idx", "updated_at_idx"]); + expect(indexes.catalog_translations.map(({ name }) => name)).toEqual(["catalog_id", "lang_code_idx"]); +}); + +it("creates missing tables before describing them and adding their indexes", async () => { + tables.clear(); + describeTable.mockImplementation(async (table: Table) => { + if (!tables.has(table)) throw new Error("Table missing"); + return descriptions[table]; + }); + await initStructure(connection); + const statements = query.mock.calls.map(([sql]) => sql as string); + expect(statements.filter((sql) => sql.includes("CREATE TABLE"))).toHaveLength(2); + expect(statements.some((sql) => sql.includes("CREATE INDEX"))).toBe(false); + expect(addIndex).toHaveBeenCalledTimes(5); + expect(statements[statements.length - 1]).toContain("CREATE OR REPLACE VIEW v_catalog"); +}); + +it.each(["catalog", "catalog_translations"] as const)("rejects missing columns in existing %s without altering them", async (table) => { + delete descriptions[table].id; + await expect(initStructure(connection)).rejects.toThrow(`Incompatible table "${table}": missing columns id`); + expect(query.mock.calls.some(([sql]) => /ALTER|CREATE TABLE|CREATE OR REPLACE VIEW/.test(sql))).toBe(false); +}); + +it.each(["catalog", "catalog_translations"] as const)("rejects an incompatible primary key in %s", async (table) => { + descriptions[table].id.primaryKey = false; + await expect(initStructure(connection)).rejects.toThrow(`Incompatible primary key in "${table}"`); + expect(viewDefinition).toBe("obsolete definition"); +}); + +it("does not duplicate indexes on repeated calls", async () => { + await initStructure(connection); + await initStructure(connection); + expect(addIndex).toHaveBeenCalledTimes(5); +}); + +it("accepts an equivalent index with a different name", async () => { + indexes.catalog.push(index("existing_catalog_name", "catalog_name")); + await initStructure(connection); + expect(addIndex).toHaveBeenCalledTimes(4); + expect(indexes.catalog.some(({ name }) => name === "catalog_name_idx")).toBe(false); +}); + +it.each(["different column", "unique", "prefix", "fulltext"])("rejects an existing named index with %s instead of replacing it", async (variant) => { + const incompatible = index("catalog_name_idx", "catalog_name"); + if (variant === "different column") incompatible.fields[0].attribute = "id_article"; + if (variant === "unique") incompatible.unique = true; + if (variant === "prefix") incompatible.fields[0].length = 10; + if (variant === "fulltext") incompatible.type = "FULLTEXT"; + indexes.catalog.push(incompatible); + await expect(initStructure(connection)).rejects.toThrow('Incompatible index "catalog.catalog_name_idx"'); + expect(addIndex).not.toHaveBeenCalled(); + expect(viewDefinition).toBe("obsolete definition"); +}); + +it("resumes missing indexes after a failure following table creation", async () => { + tables.clear(); + const add = addIndex.getMockImplementation()!; + const failure = new Error("Index failed"); + addIndex.mockImplementationOnce(add).mockRejectedValueOnce(failure); + await expect(initStructure(connection)).rejects.toBe(failure); + expect(tables.has("catalog")).toBe(true); + expect(indexes.catalog).toHaveLength(1); + expect(viewDefinition).toBe("obsolete definition"); + + await initStructure(connection); + expect(indexes.catalog.map(({ name }) => name)).toEqual(["catalog_name_idx", "id_article_idx", "updated_at_idx"]); + expect(query.mock.calls.filter(([sql]) => sql.includes("CREATE TABLE catalog ("))).toHaveLength(1); + expect(addIndex.mock.calls.filter(([, , options]) => options.name === "catalog_name_idx")).toHaveLength(1); +}); + +it("refreshes an existing view using the current projection, join and soft-delete filter", async () => { + await initStructure(connection); + expect(viewDefinition).toContain("CREATE OR REPLACE VIEW v_catalog AS"); + expect(viewDefinition).toContain("catalog_translations.description AS description"); + expect(viewDefinition).toContain("JOIN catalog_translations ON catalog.id = catalog_translations.catalog_id"); + expect(viewDefinition).toContain("WHERE catalog.deleted_at IS NULL"); + expect(query.mock.calls.some(([sql]) => sql.includes("information_schema.views") || sql.includes("DROP VIEW"))).toBe(false); +}); + +it.each(["catalog", "catalog_translations", "v_catalog"])("propagates %s creation errors and stops initialization", async (object) => { + tables.clear(); + const execute = query.getMockImplementation()!; + const failure = new Error("DDL failed"); + query.mockImplementation(async (sql: string) => { + if (sql.includes(`CREATE TABLE ${object} (`) || sql.includes(`CREATE OR REPLACE VIEW ${object} AS`)) throw failure; + return execute(sql); + }); + await expect(initStructure(connection)).rejects.toBe(failure); const lastSQL = query.mock.calls[query.mock.calls.length - 1][0]; - expect(lastSQL).toContain(object === "v_catalog" ? `CREATE VIEW ${object}` : `CREATE TABLE ${object}`); + expect(lastSQL).toContain(object === "v_catalog" ? `CREATE OR REPLACE VIEW ${object}` : `CREATE TABLE ${object}`); }); -it("propagates metadata query errors", async () => { +it("propagates table existence metadata errors", async () => { const failure = new Error("Metadata failed"); - const query = jest.fn().mockRejectedValue(failure); - await expect(initStructure({ query } as unknown as Sequelize)).rejects.toBe(failure); + query.mockRejectedValue(failure); + await expect(initStructure(connection)).rejects.toBe(failure); expect(query).toHaveBeenCalledTimes(1); + expect(describeTable).not.toHaveBeenCalled(); }); -it("awaits catalog tables and view creation in order", async () => { - const query = jest.fn(async (sql: string) => sql.includes("SELECT COUNT(*)") ? [[{ count: 0 }]] : []); - await initStructure({ query } as unknown as Sequelize); - expect(query).toHaveBeenCalledTimes(6); - expect(query.mock.calls[1][0]).toContain("CREATE TABLE catalog ("); - expect(query.mock.calls[3][0]).toContain("CREATE TABLE catalog_translations ("); - expect(query.mock.calls[5][0]).toContain("CREATE VIEW v_catalog AS"); +it.each(["describe", "indexes"])("propagates %s inspection errors without continuing", async (stage) => { + const failure = new Error("Inspection failed"); + (stage === "describe" ? describeTable : showIndex).mockRejectedValue(failure); + await expect(initStructure(connection)).rejects.toBe(failure); + expect(addIndex).not.toHaveBeenCalled(); + expect(viewDefinition).toBe("obsolete definition"); }); diff --git a/server/src/infrastructure/sequelize/initStructure.ts b/server/src/infrastructure/sequelize/initStructure.ts index 53ac9ac..6d4e168 100644 --- a/server/src/infrastructure/sequelize/initStructure.ts +++ b/server/src/infrastructure/sequelize/initStructure.ts @@ -4,6 +4,46 @@ import { Sequelize } from "sequelize"; const logger = initLogger(rTracer); +type CatalogIndex = { + name: string; + unique: boolean; + type: string; + fields: { attribute: string; length?: number }[]; +}; + +const ensureTableStructure = async ( + sequelize: Sequelize, + table: string, + columns: string[], + requiredIndexes: { name: string; field: string }[] +) => { + const queryInterface = sequelize.getQueryInterface(); + const description = await queryInterface.describeTable(table); + const missing = columns.filter((column) => !Object.hasOwn(description, column)); + if (missing.length) { + throw new Error(`Incompatible table "${table}": missing columns ${missing.join(", ")}. An explicit migration is required.`); + } + const primaryColumns = Object.entries(description) + .filter(([, column]) => column.primaryKey).map(([name]) => name); + if (primaryColumns.length !== 1 || primaryColumns[0] !== "id") { + throw new Error(`Incompatible primary key in "${table}". An explicit migration is required.`); + } + + const indexes = await queryInterface.showIndex(table) as CatalogIndex[]; + for (const required of requiredIndexes) { + const matches = (index: CatalogIndex) => !index.unique && index.type === "BTREE" + && index.fields.length === 1 && index.fields[0].attribute === required.field + && !index.fields[0].length; + const named = indexes.find((index) => index.name === required.name); + if (named && !matches(named)) { + throw new Error(`Incompatible index "${table}.${required.name}". An explicit migration is required.`); + } + if (!indexes.some(matches)) { + await queryInterface.addIndex(table, [required.field], { name: required.name, unique: false, using: "BTREE" }); + } + } +}; + const createCatalogTableIfNotExists = async (sequelize: Sequelize) => { try { // Consulta para verificar si la tabla existe @@ -33,22 +73,21 @@ const createCatalogTableIfNotExists = async (sequelize: Sequelize) => { PRIMARY KEY (id) ) ENGINE = InnoDB COLLATE utf8mb4_unicode_ci; - - CREATE INDEX catalog_name_idx - ON catalog (catalog_name); - - CREATE INDEX id_article_idx - ON catalog (id_article); - - CREATE INDEX updated_at_idx - ON catalog (updated_at); `; await sequelize.query(createTableQuery); logger.debug('Tabla "catalog" creada con éxito.'); } + await ensureTableStructure(sequelize, "catalog", [ + "id", "catalog_name", "id_article", "reference", "points", "retail_price", + "created_at", "updated_at", "deleted_at", + ], [ + { name: "catalog_name_idx", field: "catalog_name" }, + { name: "id_article_idx", field: "id_article" }, + { name: "updated_at_idx", field: "updated_at" }, + ]); } catch (error) { - logger.error('Error al crear la tabla "catalog":', error); + logger.error('Error al preparar la tabla "catalog":', error); throw error; } }; @@ -83,69 +122,57 @@ const createCatalogTranslationsTableIfNotExists = async (sequelize: Sequelize) = ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE = InnoDB; - - CREATE INDEX catalog_id - ON catalog_translations - (catalog_id); - - CREATE INDEX lang_code_idx - ON catalog_translations - (lang_code); `; await sequelize.query(createTableQuery); logger.debug('Tabla "catalog_translations" creada con éxito.'); } + await ensureTableStructure(sequelize, "catalog_translations", [ + "id", "lang_code", "description", "catalog_id", + ], [ + { name: "catalog_id", field: "catalog_id" }, + { name: "lang_code_idx", field: "lang_code" }, + ]); } catch (error) { - logger.error('Error al crear la tabla "catalog_translations":', error); + logger.error('Error al preparar la tabla "catalog_translations":', error); throw error; } }; -const createVCatalogViewIfNotExists = async (sequelize: Sequelize) => { +const updateVCatalogView = async (sequelize: Sequelize) => { try { - // Consulta para verificar si la tabla existe - const checkViewQuery = ` - SELECT COUNT(*) as count - FROM information_schema.views - WHERE table_schema = DATABASE() - AND table_name = 'v_catalog'; - `; + // This explicit initializer refreshes stale definitions without dropping the view. + const createViewQuery = ` + CREATE OR REPLACE VIEW v_catalog AS + SELECT + catalog.id AS id, + catalog.catalog_name AS catalog_name, + catalog.id_article AS id_article, + catalog.reference AS reference, + catalog.points AS points, + catalog.retail_price AS retail_price, + catalog.created_at AS created_at, + catalog.updated_at AS updated_at, + catalog_translations.lang_code AS lang_code, + catalog_translations.description AS description + FROM catalog + JOIN catalog_translations ON catalog.id = catalog_translations.catalog_id + WHERE catalog.deleted_at IS NULL; + `; - const [result]: any = await sequelize.query(checkViewQuery); - const viewExists = result.length > 0 && result[0].count > 0; - - if (!viewExists) { - // Consulta para crear la tabla si no existe - const createViewQuery = ` - CREATE VIEW v_catalog AS - SELECT - catalog.id AS id, - catalog.catalog_name AS catalog_name, - catalog.id_article AS id_article, - catalog.reference AS reference, - catalog.points AS points, - catalog.retail_price AS retail_price, - catalog.created_at AS created_at, - catalog.updated_at AS updated_at, - catalog_translations.lang_code AS lang_code, - catalog_translations.description AS description - FROM catalog - JOIN catalog_translations ON catalog.id = catalog_translations.catalog_id - WHERE catalog.deleted_at IS NULL; - `; - - await sequelize.query(createViewQuery); - logger.debug('Vista "v_catalog" creada con éxito.'); - } + await sequelize.query(createViewQuery); + logger.debug('Vista "v_catalog" actualizada con éxito.'); } catch (error) { logger.error('Error al crear la vista "v_catalog":', error); throw error; } }; +/** Explicit catalog provisioning only; never called by HTTP startup. + * Does not version the schema or repair column types, defaults or foreign keys. + */ export const initStructure = async (sequelize: Sequelize) => { await createCatalogTableIfNotExists(sequelize); await createCatalogTranslationsTableIfNotExists(sequelize); - await createVCatalogViewIfNotExists(sequelize); + await updateVCatalogView(sequelize); }; diff --git a/server/src/infrastructure/sequelize/initializeData.cli.test.ts b/server/src/infrastructure/sequelize/initializeData.cli.test.ts new file mode 100644 index 0000000..6189016 --- /dev/null +++ b/server/src/infrastructure/sequelize/initializeData.cli.test.ts @@ -0,0 +1,49 @@ +import { runInitializeDataCLI } from "./initializeData.cli"; +import { initializeData } from "./initializeData"; + +jest.mock("./initializeData", () => ({ initializeData: jest.fn() })); +const initialize = jest.mocked(initializeData); +let info: jest.SpyInstance; +let error: jest.SpyInstance; + +beforeEach(() => { + jest.resetAllMocks(); + initialize.mockResolvedValue(undefined); + info = jest.spyOn(console, "info").mockImplementation(() => undefined); + error = jest.spyOn(console, "error").mockImplementation(() => undefined); +}); +afterEach(() => { jest.restoreAllMocks(); }); + +it("shows help without initializing data", async () => { + await expect(runInitializeDataCLI(["--help"])).resolves.toBe(0); + expect(initialize).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("--tax-labels")); +}); + +it.each([{ args: [] }, { args: ["--unknown"] }, { args: ["--help", "--sample"] }])("rejects invalid or missing options $args without initializing data", async ({ args }) => { + await expect(runInitializeDataCLI(args)).resolves.toBe(1); + expect(initialize).not.toHaveBeenCalled(); +}); + +it.each([ + [["--admin"], { admin: true, sample: false, taxLabels: false }], + [["--sample"], { admin: false, sample: true, taxLabels: false }], + [["--tax-labels"], { admin: false, sample: false, taxLabels: true }], + [["--admin", "--sample", "--tax-labels"], { admin: true, sample: true, taxLabels: true }], +] as const)("runs explicit operations %j", async (args, options) => { + await expect(runInitializeDataCLI([...args])).resolves.toBe(0); + expect(initialize).toHaveBeenCalledWith(options); +}); + +it("does not print raw errors or SQL", async () => { + initialize.mockRejectedValue(new Error("synthetic private detail")); + await expect(runInitializeDataCLI(["--sample"])).resolves.toBe(1); + expect(error).toHaveBeenCalledTimes(1); + expect(error.mock.calls[0][0]).not.toContain("synthetic private detail"); +}); + +it("prints only the sanitized failure phase", async () => { + initialize.mockRejectedValue(new Error("Data initialization failed during accounts.", { cause: new Error("synthetic private detail") })); + await expect(runInitializeDataCLI(["--admin"])).resolves.toBe(1); + expect(error).toHaveBeenCalledWith("Data initialization failed during accounts."); +}); diff --git a/server/src/infrastructure/sequelize/initializeData.cli.ts b/server/src/infrastructure/sequelize/initializeData.cli.ts new file mode 100644 index 0000000..a9997ba --- /dev/null +++ b/server/src/infrastructure/sequelize/initializeData.cli.ts @@ -0,0 +1,41 @@ +const usage = `Uso: yarn db:init-data [--admin] [--sample] [--tax-labels] + --admin Crea el administrador configurado si no existe un administrador. + --sample Completa el usuario y el distribuidor de ejemplo configurados. + --tax-labels Completa únicamente las etiquetas de impuestos que sean NULL. + --help Muestra esta ayuda sin conectar con la BD. +Se requiere al menos una operación. No crea tablas ni aplica migraciones.`; + +export const runInitializeDataCLI = async (args: string[]): Promise => { + if (args.length === 1 && args[0] === "--help") { + console.info(usage); + return 0; + } + const allowed = new Set(["--admin", "--sample", "--tax-labels"]); + if (!args.length || args.some((arg) => !allowed.has(arg))) { + console.error(usage); + return 1; + } + + try { + // Do not load configuration, models or connections for help/invalid arguments. + const { initializeData } = await import("./initializeData"); + await initializeData({ + admin: args.includes("--admin"), + sample: args.includes("--sample"), + taxLabels: args.includes("--tax-labels"), + }); + console.info("Inicialización de datos completada."); + return 0; + } catch (error) { + // Allow only our static phase messages; never print SQL, config or raw errors. + const message = error instanceof Error + && /^Data initialization failed during (connection|schema check|tax labels|accounts|connection cleanup)\.$/.test(error.message) + ? error.message : "La inicialización de datos ha fallado. Revisa la conexión, el esquema y la configuración."; + console.error(message); + return 1; + } +}; + +if (require.main === module) { + void runInitializeDataCLI(process.argv.slice(2)).then((code) => { process.exitCode = code; }); +} diff --git a/server/src/infrastructure/sequelize/initializeData.test.ts b/server/src/infrastructure/sequelize/initializeData.test.ts new file mode 100644 index 0000000..a0d0265 --- /dev/null +++ b/server/src/infrastructure/sequelize/initializeData.test.ts @@ -0,0 +1,71 @@ +import { initializeData } from "./initializeData"; +import { createSequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; +import { checkDatabaseSchema } from "./checkDatabaseSchema"; +import { insertUsers } from "./initData"; +import { Dealer_Model } from "@/contexts/sales/infrastructure/sequelize/dealer.model"; +import { Quote_Model } from "@/contexts/sales/infrastructure/sequelize/quote.model"; + +jest.mock("@/contexts/common/infrastructure/sequelize", () => ({ createSequelizeAdapter: jest.fn() })); +jest.mock("./checkDatabaseSchema", () => ({ checkDatabaseSchema: jest.fn() })); +jest.mock("./initData", () => ({ insertUsers: jest.fn() })); +jest.mock("@/contexts/sales/infrastructure/sequelize/dealer.model", () => ({ DEFAULT_TAX_LABEL: "% IVA", Dealer_Model: { update: jest.fn() } })); +jest.mock("@/contexts/sales/infrastructure/sequelize/quote.model", () => ({ DEFAULT_QUOTE_TAX_LABEL: "% IVA", Quote_Model: { update: jest.fn() } })); + +const options = { admin: false, sample: false, taxLabels: false }; +const transaction = {}; +const connection = { authenticate: jest.fn(), close: jest.fn(), transaction: jest.fn() }; +const createAdapter = jest.mocked(createSequelizeAdapter); +const checkSchema = jest.mocked(checkDatabaseSchema); +const users = jest.mocked(insertUsers); +const dealer = jest.mocked(Dealer_Model.update); +const quote = jest.mocked(Quote_Model.update); + +beforeEach(() => { + jest.resetAllMocks(); + connection.authenticate.mockResolvedValue(undefined); + connection.close.mockResolvedValue(undefined); + connection.transaction.mockImplementation(async (work: (t: object) => Promise) => work(transaction)); + createAdapter.mockReturnValue({ connection } as unknown as ReturnType); + checkSchema.mockResolvedValue(undefined); + users.mockResolvedValue(undefined); +}); + +it("refuses an empty selection before creating a connection", async () => { + await expect(initializeData(options)).rejects.toThrow("Select at least one"); + expect(createAdapter).not.toHaveBeenCalled(); +}); + +it.each(["admin", "sample"] as const)("initializes only the requested %s accounts after checking the schema", async (operation) => { + await initializeData({ ...options, [operation]: true }); + expect(users).toHaveBeenCalledWith({ ...options, [operation]: true }); + expect(connection.authenticate.mock.invocationCallOrder[0]).toBeLessThan(checkSchema.mock.invocationCallOrder[0]); + expect(checkSchema.mock.invocationCallOrder[0]).toBeLessThan(users.mock.invocationCallOrder[0]); + expect(dealer).not.toHaveBeenCalled(); + expect(quote).not.toHaveBeenCalled(); + expect(connection.close).toHaveBeenCalledTimes(1); + expect(createAdapter).toHaveBeenCalledWith({ logging: false }); +}); + +it("backfills only NULL tax labels using one transaction and does not create accounts", async () => { + await initializeData({ ...options, taxLabels: true }); + expect(dealer).toHaveBeenCalledWith({ default_tax_label: "% IVA" }, { where: { default_tax_label: null }, transaction }); + expect(quote).toHaveBeenCalledWith({ tax_label: "% IVA" }, { where: { tax_label: null }, transaction }); + expect(connection.transaction).toHaveBeenCalledTimes(1); + expect(users).not.toHaveBeenCalled(); + expect(connection.close).toHaveBeenCalledTimes(1); +}); + +it.each(["connection", "schema check", "tax labels", "accounts"])("closes the pool and reports a sanitized %s error", async (stage) => { + const failure = new Error("synthetic private detail"); + const steps = { connection: connection.authenticate, "schema check": checkSchema, "tax labels": quote, accounts: users }; + steps[stage as keyof typeof steps].mockRejectedValue(failure); + await expect(initializeData({ admin: true, sample: true, taxLabels: true })).rejects.toMatchObject({ + message: `Data initialization failed during ${stage}.`, cause: failure, + }); + expect(connection.close).toHaveBeenCalledTimes(1); +}); + +it("reports connection cleanup errors", async () => { + connection.close.mockRejectedValue(new Error("synthetic private detail")); + await expect(initializeData({ ...options, admin: true })).rejects.toThrow("connection cleanup"); +}); diff --git a/server/src/infrastructure/sequelize/initializeData.ts b/server/src/infrastructure/sequelize/initializeData.ts new file mode 100644 index 0000000..90e0448 --- /dev/null +++ b/server/src/infrastructure/sequelize/initializeData.ts @@ -0,0 +1,56 @@ +import { createSequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; +import { + Dealer_Model, + DEFAULT_TAX_LABEL, +} from "@/contexts/sales/infrastructure/sequelize/dealer.model"; +import { + DEFAULT_QUOTE_TAX_LABEL, + Quote_Model, +} from "@/contexts/sales/infrastructure/sequelize/quote.model"; +import { checkDatabaseSchema } from "./checkDatabaseSchema"; +import { insertUsers, UserInitializationOptions } from "./initData"; + +export type DataInitializationOptions = UserInitializationOptions & { taxLabels: boolean }; + +/** Explicit DML only. The schema must already exist; this is not a migration. */ +export const initializeData = async (options: DataInitializationOptions): Promise => { + if (!options.admin && !options.sample && !options.taxLabels) { + throw new Error("Select at least one data initialization operation."); + } + let adapter: ReturnType | undefined; + let stage = "connection"; + try { + // Seed errors and SQL logging must not expose account data or password hashes. + adapter = createSequelizeAdapter({ logging: false }); + await adapter.connection.authenticate(); + stage = "schema check"; + await checkDatabaseSchema(adapter.connection); + + if (options.taxLabels) { + stage = "tax labels"; + await adapter.connection.transaction(async (transaction) => { + await Dealer_Model.update( + { default_tax_label: DEFAULT_TAX_LABEL }, + { where: { default_tax_label: null }, transaction } + ); + await Quote_Model.update( + { tax_label: DEFAULT_QUOTE_TAX_LABEL }, + { where: { tax_label: null }, transaction } + ); + }); + } + if (options.admin || options.sample) { + stage = "accounts"; + await insertUsers(options); + } + } catch (cause) { + // Only this static message is printed by the CLI; the original cause stays private. + throw new Error(`Data initialization failed during ${stage}.`, { cause }); + } finally { + try { + await adapter?.connection.close(); + } catch (cause) { + throw new Error("Data initialization failed during connection cleanup.", { cause }); + } + } +};