diff --git a/doc/capitulos-cotizaciones-integracion-legacy.md b/doc/capitulos-cotizaciones-integracion-legacy.md index 26fbb40..6e0cc99 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 ejecuta sincronización automática del esquema, por lo que no debe utilizarse como mecanismo de inspección. +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. ### Matriz mínima de aceptación del extractor diff --git a/doc/capitulos-esquema.md b/doc/capitulos-esquema.md index 78f3e1d..ff1125f 100644 --- a/doc/capitulos-esquema.md +++ b/doc/capitulos-esquema.md @@ -31,12 +31,12 @@ 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 existente ejecuta `sync({ force: false, alter: true })`; hay que verificar si conserva la FK manual y sus índices, también en un segundo arranque. No añadir DDL automático en hooks ni cambiar la estrategia global de sincronización dentro de esta fase. +**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. ## Comprobaciones para una aplicación autorizada 1. Trabajar con una copia aislada y recuperable del esquema existente; revisar las definiciones físicas de UUID, FK e índices antes de materializar el cambio. -2. Verificar tabla nueva, columnas, defaults e índices, incluyendo el comportamiento del `sync({ alter: true })` actual. Revisar por separado la FK compuesta anterior. +2. Verificar tabla nueva, columnas, defaults e índices sobre el motor y versión de destino. Revisar por separado la FK compuesta anterior. El arranque valida la presencia de columnas, pero no instala ni repara el esquema. 3. Leer, editar, guardar y duplicar una cotización anterior al cambio: cero capítulos, referencias null, mismo orden y mismos importes. 4. Crear una cotización con dos capítulos y textos; guardar y recargar. Reordenar capítulos y trasladar detalles verificando sus posiciones. 5. Inyectar un fallo al insertar capítulos y otro al insertar detalles. Comprobar en MariaDB que cabecera, capítulos y detalles permanecen en el estado previo, sin escrituras parciales. @@ -73,7 +73,9 @@ El usuario de pruebas necesita crear y eliminar esquemas temporales y sus tablas Antes de cualquier DDL se exige que `SELECT VERSION()` identifique MariaDB; MySQL se rechaza expresamente. Se crea un nombre aleatorio `uecko_chapters_it_` sin `IF NOT EXISTS`. Solo se elimina ese esquema si su creación en esta ejecución tuvo éxito. Se cierran los pools y se elimina el esquema en la limpieza de Jest, también si un test falla. Si se mata el proceso con SIGKILL, el esquema puede quedar para limpieza manual tras comprobar su nombre; no se hace limpieza por prefijos sobre otros esquemas. -### Cobertura preparada (14 casos; pendientes de ejecución real) +### Cobertura preparada con sincronización histórica (14 casos pendientes de ejecución real) + +La suite siguiente todavía usa `sync` dentro de su esquema desechable: no reproduce el nuevo arranque de la API ni certifica migraciones. Deberá adaptarse cuando exista el ejecutor de migraciones. - Registra los cinco modelos reales de usuarios, distribuidores, cotizaciones, capítulos y detalles, con las asociaciones y convenciones de persistencia del adaptador. No inicia la API, los seeds ni los otros servicios. - Construye la forma anterior a capítulos retirando exclusivamente las tres incorporaciones nuevas del esquema temporal. Inserta usuario/distribuidor ficticios y una cotización libre con tres detalles antes de ejecutar `sync({ force: false, alter: true })`. diff --git a/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts b/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts index ba8e4ae..b42caac 100644 --- a/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts +++ b/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts @@ -71,10 +71,6 @@ export class SequelizeAdapter implements ISequelizeAdapter { return new SequelizeBusinessTransaction(this._connection); } - public sync(params) { - return this._connection.sync(params); - } - public getModel(modelName: string): any { if (this.hasModel(modelName)) { return this._models[modelName]; diff --git a/server/src/infrastructure/http/server.test.ts b/server/src/infrastructure/http/server.test.ts index b335116..f9c7675 100644 --- a/server/src/infrastructure/http/server.test.ts +++ b/server/src/infrastructure/http/server.test.ts @@ -1,6 +1,8 @@ import { EventEmitter } from "events"; const sync = jest.fn(); +const authenticate = jest.fn(); +const checkSchema = jest.fn(); const closeDatabase = jest.fn(); const createAdapter = jest.fn(); const updateDealer = jest.fn(); @@ -27,6 +29,7 @@ jest.mock("@/contexts/sales/infrastructure/sequelize/quote.model", () => ({ })); jest.mock("../sequelize", () => ({ initStructure: (...args: unknown[]) => prepareStructure(...args), + checkDatabaseSchema: (...args: unknown[]) => checkSchema(...args), insertUsers: () => prepareUsers(), })); @@ -51,9 +54,11 @@ beforeEach(() => { return process; }) as typeof process.once); exit = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never); - sync.mockResolvedValue(undefined); + sync.mockImplementation(() => { throw new Error("Startup must not synchronize the schema"); }); + authenticate.mockResolvedValue(undefined); + checkSchema.mockResolvedValue(undefined); closeDatabase.mockResolvedValue(undefined); - createAdapter.mockReturnValue({ sync, connection: { close: closeDatabase } }); + createAdapter.mockReturnValue({ sync, connection: { close: closeDatabase, authenticate } }); updateDealer.mockResolvedValue(undefined); updateQuote.mockResolvedValue(undefined); prepareStructure.mockResolvedValue(undefined); @@ -77,22 +82,24 @@ afterEach(() => { it("waits for each initialization step before opening HTTP", async () => { const order: string[] = []; for (const [name, step] of [ - ["sync", sync], ["dealer", updateDealer], ["quote", updateQuote], - ["structure", prepareStructure], ["users", prepareUsers], + ["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(["sync", "dealer", "quote", "structure", "users", "listen"]); - expect(sync).toHaveBeenCalledWith({ force: false, alter: true }); + expect(order).toEqual(["authenticate", "schema", "dealer", "quote", "users", "listen"]); + expect(sync).not.toHaveBeenCalled(); + expect(prepareStructure).not.toHaveBeenCalled(); + expect(checkSchema).toHaveBeenCalledWith(expect.objectContaining({ authenticate })); expect(exit).not.toHaveBeenCalled(); }); -it.each(["structure", "users"])("does not listen while %s initialization is pending", async (stage) => { +it.each(["schema", "users"])("does not listen while %s initialization is pending", async (stage) => { let finish!: () => void; - const step = stage === "structure" ? prepareStructure : prepareUsers; + const step = stage === "schema" ? checkSchema : prepareUsers; step.mockReturnValue(new Promise((resolve) => { finish = resolve; })); loadServer(); await flush(); @@ -102,8 +109,8 @@ it.each(["structure", "users"])("does not listen while %s initialization is pend expect(server.listen).toHaveBeenCalledTimes(1); }); -it.each(["sync", "dealer", "quote", "structure", "users"])("closes the database and exits on %s failure without opening HTTP", async (stage) => { - const steps = { sync, dealer: updateDealer, quote: updateQuote, structure: prepareStructure, users: prepareUsers }; +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 }; const failure = new Error("Initialization failed"); steps[stage as keyof typeof steps].mockRejectedValue(failure); loadServer(); @@ -184,7 +191,7 @@ it("exits with an error if database close fails", async () => { it("does not open HTTP if interrupted during initialization", async () => { let finish!: () => void; - prepareStructure.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + checkSchema.mockReturnValue(new Promise((resolve) => { finish = resolve; })); loadServer(); await flush(); handlers.SIGTERM(); diff --git a/server/src/infrastructure/http/server.ts b/server/src/infrastructure/http/server.ts index ed1583d..c781446 100644 --- a/server/src/infrastructure/http/server.ts +++ b/server/src/infrastructure/http/server.ts @@ -15,7 +15,7 @@ import { import { config } from "../../config"; import { app } from "../express/app"; import { initLogger } from "../logger"; -import { initStructure, insertUsers } from "../sequelize"; +import { checkDatabaseSchema, insertUsers } from "../sequelize"; process.env.TZ = "UTC"; Settings.defaultLocale = "es-ES"; @@ -101,7 +101,9 @@ process.on("unhandledRejection", failServer); const startServer = async () => { try { sequelizeConn = createSequelizeAdapter(); - await sequelizeConn.sync({ force: false, alter: true }); + await sequelizeConn.connection.authenticate(); + if (stopping) return; + await checkDatabaseSchema(sequelizeConn.connection); if (stopping) return; await Dealer_Model.update( @@ -115,8 +117,6 @@ const startServer = async () => { ); if (stopping) return; - await initStructure(sequelizeConn.connection); - if (stopping) return; await insertUsers(); if (stopping) return; diff --git a/server/src/infrastructure/sequelize/checkDatabaseSchema.test.ts b/server/src/infrastructure/sequelize/checkDatabaseSchema.test.ts new file mode 100644 index 0000000..e499015 --- /dev/null +++ b/server/src/infrastructure/sequelize/checkDatabaseSchema.test.ts @@ -0,0 +1,92 @@ +import { DataTypes, Sequelize } from "sequelize"; +import { checkDatabaseSchema } from "./checkDatabaseSchema"; +import initQuote from "@/contexts/sales/infrastructure/sequelize/quote.model"; +import initItems from "@/contexts/sales/infrastructure/sequelize/quoteItem.model"; +import initChapters from "@/contexts/sales/infrastructure/sequelize/quoteChapter.model"; + +let connection: Sequelize; +let describe: jest.SpyInstance; +let query: jest.SpyInstance; + +beforeEach(() => { + connection = new Sequelize("metadata_test", "", "", { dialect: "mysql", logging: false }); + describe = jest.spyOn(connection.getQueryInterface(), "describeTable"); + query = jest.spyOn(connection, "query").mockRejectedValue(new Error("Unexpected SQL execution")); +}); + +afterEach(() => { jest.restoreAllMocks(); }); + +it("checks physical columns and timestamps without executing DDL or connecting", async () => { + connection.define("Example", { + title: { type: DataTypes.TEXT, field: "description" }, + label: { type: DataTypes.VIRTUAL }, + }, { tableName: "examples", underscored: true }); + describe.mockResolvedValue({ id: {}, description: {}, created_at: {}, updated_at: {}, extra_column: {} }); + + await expect(checkDatabaseSchema(connection)).resolves.toBeUndefined(); + expect(describe).toHaveBeenCalledWith("examples"); + expect(query).not.toHaveBeenCalled(); +}); + +it.each([undefined, "example_schema"])("combines required columns of models sharing one table in schema %s", async (schema) => { + connection.define("FirstUser", { name: DataTypes.TEXT }, { tableName: "users", schema, timestamps: false }); + connection.define("SecondUser", { email: DataTypes.TEXT }, { tableName: "users", schema, timestamps: false }); + describe.mockResolvedValue({ id: {}, name: {} }); + + await expect(checkDatabaseSchema(connection)).rejects.toThrow('is missing columns: email'); + expect(describe).toHaveBeenCalledTimes(1); + expect(query).not.toHaveBeenCalled(); +}); + +it("checks views even when their models disable sync", async () => { + const view = connection.define("CatalogView", { description: DataTypes.TEXT }, { tableName: "v_catalog", timestamps: false }); + const sync = jest.spyOn(view, "sync"); + describe.mockResolvedValue({ id: {} }); + + await expect(checkDatabaseSchema(connection)).rejects.toThrow('"v_catalog" is missing columns: description'); + expect(sync).not.toHaveBeenCalled(); +}); + +it("fails on missing objects or metadata permissions without creating anything", async () => { + connection.define("Example", {}, { tableName: "examples", timestamps: false }); + const cause = new Error("Table absent or metadata denied"); + describe.mockRejectedValue(cause); + + await expect(checkDatabaseSchema(connection)).rejects.toMatchObject({ + message: expect.stringContaining('Cannot inspect database table or view "examples"'), cause, + }); + expect(query).not.toHaveBeenCalled(); +}); + +it("uses schema-qualified table names", async () => { + connection.define("Example", {}, { tableName: "examples", schema: "example_schema", timestamps: false }); + describe.mockResolvedValue({}); + await expect(checkDatabaseSchema(connection)).rejects.toThrow('"example_schema.examples" is missing columns: id'); + expect(describe).toHaveBeenCalledWith(expect.objectContaining({ tableName: "examples", schema: "example_schema" })); +}); + +it("does not accept an empty model registry as a valid schema", async () => { + await expect(checkDatabaseSchema(connection)).rejects.toThrow("no Sequelize models are registered"); + expect(describe).not.toHaveBeenCalled(); +}); + +it.each([ + ["Quote_Model", "show_chapter_line_amounts"], + ["QuoteItem_Model", "chapter_id"], + ["QuoteChapter_Model", "description"], +])("detects missing chapter columns in %s", async (modelName, missingColumn) => { + initQuote(connection); + initItems(connection); + initChapters(connection); + const models = Object.values(connection.models); + // Synthetic metadata represents an otherwise compatible physical schema. + describe.mockImplementation(async (tableName: string) => { + const model = models.find((candidate) => candidate.getTableName() === tableName)!; + return Object.fromEntries(Object.entries(model.getAttributes()) + .filter(([name]) => model.name !== modelName || name !== missingColumn) + .map(([name, attribute]) => [attribute.field ?? name, {}])); + }); + + await expect(checkDatabaseSchema(connection)).rejects.toThrow(`is missing columns: ${missingColumn}`); + expect(query).not.toHaveBeenCalled(); +}); diff --git a/server/src/infrastructure/sequelize/checkDatabaseSchema.ts b/server/src/infrastructure/sequelize/checkDatabaseSchema.ts new file mode 100644 index 0000000..f1c5403 --- /dev/null +++ b/server/src/infrastructure/sequelize/checkDatabaseSchema.ts @@ -0,0 +1,51 @@ +import { DataTypes, Sequelize } from "sequelize"; + +/** + * Read-only startup check until schema version checks are provided by migrations. + * Checks required columns, including views and models that share a table. + * Does not certify column types, defaults, indexes or foreign keys. + */ +export const checkDatabaseSchema = async (connection: Sequelize): Promise => { + const models = Object.values(connection.models); + if (models.length === 0) { + throw new Error("Cannot check database schema: no Sequelize models are registered."); + } + + const tables = new Map; + columns: Set; + }>(); + + for (const model of models) { + const name = model.getTableName(); + const key = typeof name === "string" ? name : JSON.stringify([name.schema, name.tableName]); + const table = tables.get(key) ?? { name, columns: new Set() }; + for (const [attributeName, attribute] of Object.entries(model.getAttributes())) { + if (!(attribute.type instanceof DataTypes.VIRTUAL)) { + table.columns.add(attribute.field ?? attributeName); + } + } + tables.set(key, table); + } + + const queryInterface = connection.getQueryInterface(); + for (const { name, columns } of tables.values()) { + const label = typeof name === "string" ? name : `${name.schema}.${name.tableName}`; + let description: Awaited>; + try { + description = await queryInterface.describeTable(name); + } catch (cause) { + throw new Error( + `Cannot inspect database table or view "${label}". Check connectivity, permissions and apply the required schema changes before starting the API.`, + { cause } + ); + } + + const missing = [...columns].filter((column) => !Object.hasOwn(description, column)); + if (missing.length > 0) { + throw new Error( + `Incompatible database schema: "${label}" is missing columns: ${missing.join(", ")}. Apply the required schema changes before starting the API.` + ); + } + } +}; diff --git a/server/src/infrastructure/sequelize/index.ts b/server/src/infrastructure/sequelize/index.ts index 2ef20ad..6c02d3c 100644 --- a/server/src/infrastructure/sequelize/index.ts +++ b/server/src/infrastructure/sequelize/index.ts @@ -1,2 +1,3 @@ export * from "./initData"; export * from "./initStructure"; +export * from "./checkDatabaseSchema";