feat: mejorar la inicialización de la estructura de la base de datos con validaciones de tablas e índices

This commit is contained in:
David Arranz 2026-10-22 12:31:46 +02:00
parent 0f4aee0dc1
commit b7df37707b
21 changed files with 682 additions and 147 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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"
},

View File

@ -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,
}));
});

View File

@ -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",

View File

@ -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,
});
};

View File

@ -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<unknown>) => 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<IUserRepository>;
const dealerRepository = jest.fn(() => dealers) as unknown as RepositoryBuilder<IDealerRepository>;
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();
});

View File

@ -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;
}
});
};

View File

@ -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<void>((resolve) => { finish = resolve; }));
checkSchema.mockReturnValue(new Promise<void>((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();

View File

@ -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<void>((resolve, reject) => {
const onError = (error: Error) => {
server.removeListener("listening", onListening);

View File

@ -16,36 +16,36 @@ const dealer = jest.mocked(initializeSampleDealer);
beforeEach(() => {
jest.resetAllMocks();
admin.mockResolvedValue(undefined);
user.mockResolvedValue(false);
user.mockResolvedValue({} as Awaited<ReturnType<typeof initializeSampleUser>>);
dealer.mockResolvedValue(undefined);
});
it("awaits admin initialization before initializing the sample user", async () => {
let finish!: () => void;
admin.mockReturnValue(new Promise<void>((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<Awaited<ReturnType<typeof initializeSampleUser>>, boolean>);
user.mockResolvedValue({} as Awaited<ReturnType<typeof initializeSampleUser>>);
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<Awaited<ReturnType<typeof initializeSampleUser>>, boolean>);
user.mockResolvedValue({} as Awaited<ReturnType<typeof initializeSampleUser>>);
let finish!: () => void;
dealer.mockReturnValue(new Promise<void>((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);
});

View File

@ -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<IUserRepository>("User"));
}
if (sample) {
const user = await initializeSampleUser(
context.adapter,
context.repositoryManager.getRepository<IUserRepository>("User")
);
await initializeSampleDealer(
user,
context.adapter,
context.repositoryManager.getRepository("Dealer")
context.repositoryManager.getRepository<IDealerRepository>("Dealer")
);
}
};

View File

@ -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<Table>;
let descriptions: Record<Table, Record<string, { primaryKey: boolean }>>;
let indexes: Record<Table, Index[]>;
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");
});

View File

@ -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);
};

View File

@ -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.");
});

View File

@ -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<number> => {
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; });
}

View File

@ -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<void>) => work(transaction));
createAdapter.mockReturnValue({ connection } as unknown as ReturnType<typeof createSequelizeAdapter>);
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");
});

View File

@ -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<void> => {
if (!options.admin && !options.sample && !options.taxLabels) {
throw new Error("Select at least one data initialization operation.");
}
let adapter: ReturnType<typeof createSequelizeAdapter> | 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 });
}
}
};