From 8fcbf422f4a03b23200030747f2eb94854bd9558 Mon Sep 17 00:00:00 2001 From: David Arranz Date: Thu, 22 Oct 2026 10:33:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implementar=20pruebas=20para=20la=20ini?= =?UTF-8?q?cializaci=C3=B3n=20de=20usuarios=20y=20estructuras=20en=20la=20?= =?UTF-8?q?base=20de=20datos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/infrastructure/http/server.test.ts | 206 ++++++++++++++++++ server/src/infrastructure/http/server.ts | 174 +++++++-------- .../infrastructure/sequelize/initData.test.ts | 56 +++++ .../src/infrastructure/sequelize/initData.ts | 4 +- .../sequelize/initStructure.test.ts | 32 +++ .../infrastructure/sequelize/initStructure.ts | 5 +- 6 files changed, 387 insertions(+), 90 deletions(-) create mode 100644 server/src/infrastructure/http/server.test.ts create mode 100644 server/src/infrastructure/sequelize/initData.test.ts create mode 100644 server/src/infrastructure/sequelize/initStructure.test.ts diff --git a/server/src/infrastructure/http/server.test.ts b/server/src/infrastructure/http/server.test.ts new file mode 100644 index 0000000..b335116 --- /dev/null +++ b/server/src/infrastructure/http/server.test.ts @@ -0,0 +1,206 @@ +import { EventEmitter } from "events"; + +const sync = jest.fn(); +const closeDatabase = jest.fn(); +const createAdapter = jest.fn(); +const updateDealer = jest.fn(); +const updateQuote = jest.fn(); +const prepareStructure = jest.fn(); +const prepareUsers = jest.fn(); +const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; +const server = Object.assign(new EventEmitter(), { + listening: false, + listen: jest.fn(), + close: jest.fn(), +}); + +jest.mock("http", () => ({ createServer: () => server })); +jest.mock("@/config", () => ({ config: { environment: "test", server: { port: 3001 } } })); +jest.mock("../express/app", () => ({ app: {} })); +jest.mock("../logger", () => ({ initLogger: () => logger })); +jest.mock("@/contexts/common/infrastructure/sequelize", () => ({ createSequelizeAdapter: () => createAdapter() })); +jest.mock("@/contexts/sales/infrastructure/sequelize/dealer.model", () => ({ + DEFAULT_TAX_LABEL: "% IVA", Dealer_Model: { update: (...args: unknown[]) => updateDealer(...args) }, +})); +jest.mock("@/contexts/sales/infrastructure/sequelize/quote.model", () => ({ + DEFAULT_QUOTE_TAX_LABEL: "% IVA", Quote_Model: { update: (...args: unknown[]) => updateQuote(...args) }, +})); +jest.mock("../sequelize", () => ({ + initStructure: (...args: unknown[]) => prepareStructure(...args), + insertUsers: () => prepareUsers(), +})); + +const originalExitCode = process.exitCode; +let handlers: Record void>; +let exit: jest.SpyInstance; +const flush = () => new Promise((resolve) => setImmediate(resolve)); +const loadServer = () => { jest.isolateModules(() => { require("./server"); }); }; + +beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + server.removeAllListeners(); + server.listening = false; + handlers = {}; + jest.spyOn(process, "on").mockImplementation(((event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler; + return process; + }) as typeof process.on); + jest.spyOn(process, "once").mockImplementation(((event: string, handler: (...args: unknown[]) => void) => { + handlers[event] = handler; + return process; + }) as typeof process.once); + exit = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never); + sync.mockResolvedValue(undefined); + closeDatabase.mockResolvedValue(undefined); + createAdapter.mockReturnValue({ sync, connection: { close: closeDatabase } }); + updateDealer.mockResolvedValue(undefined); + updateQuote.mockResolvedValue(undefined); + prepareStructure.mockResolvedValue(undefined); + prepareUsers.mockResolvedValue(undefined); + server.listen.mockImplementation(() => { + server.listening = true; + server.emit("listening"); + }); + server.close.mockImplementation((callback: (error?: Error) => void) => { + server.listening = false; + callback(); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + process.exitCode = originalExitCode; +}); + +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], + ] 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(exit).not.toHaveBeenCalled(); +}); + +it.each(["structure", "users"])("does not listen while %s initialization is pending", async (stage) => { + let finish!: () => void; + const step = stage === "structure" ? prepareStructure : prepareUsers; + step.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + loadServer(); + await flush(); + expect(server.listen).not.toHaveBeenCalled(); + finish(); + await flush(); + 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 }; + const failure = new Error("Initialization failed"); + steps[stage as keyof typeof steps].mockRejectedValue(failure); + loadServer(); + await flush(); + expect(server.listen).not.toHaveBeenCalled(); + expect(server.close).not.toHaveBeenCalled(); + expect(closeDatabase).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + expect(logger.error).toHaveBeenCalledWith("Server startup or execution failed", failure); +}); + +it("handles synchronous adapter creation failure", async () => { + createAdapter.mockImplementationOnce(() => { throw new Error("Adapter failed"); }); + loadServer(); + await flush(); + expect(server.listen).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); +}); + +it("handles an occupied port and closes the database without closing an unstarted HTTP server", async () => { + server.listen.mockImplementation(() => { server.emit("error", Object.assign(new Error("Port occupied"), { code: "EADDRINUSE" })); }); + loadServer(); + await flush(); + expect(server.close).not.toHaveBeenCalled(); + expect(closeDatabase).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); +}); + +it.each(["SIGINT", "SIGTERM"])("closes HTTP then the database on %s, only once", async (signal) => { + loadServer(); + await flush(); + handlers[signal](); + handlers[signal](); + await flush(); + expect(server.close).toHaveBeenCalledTimes(1); + expect(closeDatabase).toHaveBeenCalledTimes(1); + expect(server.close.mock.invocationCallOrder[0]).toBeLessThan(closeDatabase.mock.invocationCallOrder[0]); + expect(exit).toHaveBeenCalledWith(0); +}); + +it.each(["uncaughtException", "unhandledRejection"])("terminates on %s instead of continuing to serve", async (event) => { + loadServer(); + await flush(); + handlers[event](new Error("Fatal failure")); + await flush(); + expect(server.close).toHaveBeenCalledTimes(1); + expect(closeDatabase).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); +}); + +it("handles HTTP errors after startup", async () => { + loadServer(); + await flush(); + server.emit("error", new Error("HTTP failure")); + await flush(); + expect(exit).toHaveBeenCalledWith(1); + expect(closeDatabase).toHaveBeenCalledTimes(1); +}); + +it("still closes the database if HTTP close fails", async () => { + server.close.mockImplementation((callback: (error?: Error) => void) => callback(new Error("Close failed"))); + loadServer(); + await flush(); + handlers.SIGTERM(); + await flush(); + expect(closeDatabase).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); +}); + +it("exits with an error if database close fails", async () => { + closeDatabase.mockRejectedValue(new Error("Close failed")); + loadServer(); + await flush(); + handlers.SIGTERM(); + await flush(); + expect(exit).toHaveBeenCalledWith(1); +}); + +it("does not open HTTP if interrupted during initialization", async () => { + let finish!: () => void; + prepareStructure.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + loadServer(); + await flush(); + handlers.SIGTERM(); + finish(); + await flush(); + expect(server.listen).not.toHaveBeenCalled(); + expect(prepareUsers).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(0); +}); + +it("forces an error exit when HTTP shutdown exceeds the deadline", async () => { + loadServer(); + await flush(); + jest.useFakeTimers(); + server.close.mockImplementation(() => undefined); + handlers.SIGTERM(); + jest.advanceTimersByTime(30000); + expect(exit).toHaveBeenCalledWith(1); +}); diff --git a/server/src/infrastructure/http/server.ts b/server/src/infrastructure/http/server.ts index 9f37740..ed1583d 100644 --- a/server/src/infrastructure/http/server.ts +++ b/server/src/infrastructure/http/server.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-use-before-define */ import rTracer from "cls-rtracer"; import http from "http"; import { assign } from "lodash"; @@ -13,7 +12,6 @@ import { DEFAULT_QUOTE_TAX_LABEL, Quote_Model, } from "@/contexts/sales/infrastructure/sequelize/quote.model"; -import { trace } from "console"; import { config } from "../../config"; import { app } from "../express/app"; import { initLogger } from "../logger"; @@ -37,55 +35,6 @@ export const currentState = assign( config ); -const serverStop = (server: http.Server) => { - const forceTimeout = 30000; - - return new Promise((resolve, reject) => { - logger.warn("⚡️ Shutting down server"); - - setTimeout(() => { - logger.error("Could not close connections in time, forcefully shutting down"); - resolve(); - }, forceTimeout).unref(); - - server.close((err) => { - if (err) { - return reject(err); - } - - logger.info("Closed out remaining connections."); - logger.info("Bye!"); - resolve(); - }); - }); - - /*const now = DateTime.now(); - // Destroy server and available connections. - logger.info(`Time: ${now.toLocaleString()}`); - logger.info('Shutting down at: ' + new Date()); - if (server) { - server.close(); - } - - logger.info('Bye!'); - process.exit(1);*/ -}; - -const serverError = (error: any) => { - if (error.code === "EADDRINUSE") { - logger.debug(`⛔️ Server wasn't able to start properly.`); - logger.error(`The port ${error.port} is already used by another application.`); - } else { - logger.debug(`⛔️ Server wasn't able to start properly.`); - logger.error(error); - trace(error); - } - - serverStop(server); - - return; -}; - const serverConnection = (conn: any) => { const key = `${conn.remoteAddress}:${conn.remotePort}`; currentState.connections[key] = conn; @@ -95,57 +44,108 @@ const serverConnection = (conn: any) => { }); }; -const sequelizeConn = createSequelizeAdapter(); -//const firebirdConn = createFirebirdAdapter(); +let sequelizeConn: ReturnType | undefined; +let stopping = false; -const server: http.Server = http +const server = http .createServer(app) - .once("listening", () => - process.on("SIGINT", () => { - //firebirdConn.disconnect(); - serverStop(server); - }) - ) .on("close", () => logger.info(`Shut down at: ${DateTime.now().toLocaleString(DateTime.DATETIME_FULL)}`) ) - .on("connection", serverConnection) - .on("error", serverError); + .on("connection", serverConnection); + +const stopServer = async (exitCode: number) => { + if (exitCode !== 0) process.exitCode = exitCode; + if (stopping) return; + stopping = true; + logger.warn("⚡️ Shutting down server"); + + const timeout = setTimeout(() => { + logger.error("Could not close connections in time, forcefully shutting down"); + process.exit(1); + }, 30000); + timeout.unref(); + + try { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + } catch (error) { + process.exitCode = 1; + logger.error("Could not close HTTP server", error); + } + + try { + await sequelizeConn?.connection.close(); + } catch (error) { + process.exitCode = 1; + logger.error("Could not close database connection", error); + } finally { + clearTimeout(timeout); + process.exit(process.exitCode || exitCode); + } +}; + +const failServer = (error: unknown) => { + logger.error("Server startup or execution failed", error); + void stopServer(1); +}; + +process.once("SIGINT", () => { void stopServer(0); }); +process.once("SIGTERM", () => { void stopServer(0); }); +process.on("uncaughtException", failServer); +process.on("unhandledRejection", failServer); + +const startServer = async () => { + try { + sequelizeConn = createSequelizeAdapter(); + await sequelizeConn.sync({ force: false, alter: true }); + if (stopping) return; -try { - //firebirdConn.sync().then(() => { - sequelizeConn.sync({ force: false, alter: true }).then(async () => { 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; - initStructure(sequelizeConn.connection); - insertUsers(); + await initStructure(sequelizeConn.connection); + if (stopping) return; + await insertUsers(); + if (stopping) return; - // Launch server - server.listen(currentState.server.port, () => { - const now = DateTime.now(); - logger.info(`Time: ${now.toLocaleString(DateTime.DATETIME_FULL)} ${now.zoneName}`); - logger.info(`Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms`); - logger.info(`Environment: ${currentState.environment}`); - logger.info(`Process PID: ${process.pid}`); - logger.info("To shut down your server, press + C at any time"); - logger.info(`⚡️ Server: http://${currentState.server.hostname}:${currentState.server.port}`); + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.removeListener("listening", onListening); + reject(error); + }; + const onListening = () => { + server.removeListener("error", onError); + server.on("error", failServer); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(currentState.server.port); }); - }); - //}); -} catch (error) { - serverError(error); -} + if (stopping) return; -process.on("uncaughtException", (error: any) => { - //firebirdConn.disconnect(); - logger.error(`${new Date().toUTCString()} uncaughtException:`, error.message); - logger.error(error.stack); - //process.exit(1); -}); + const now = DateTime.now(); + logger.info(`Time: ${now.toLocaleString(DateTime.DATETIME_FULL)} ${now.zoneName}`); + logger.info(`Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms`); + logger.info(`Environment: ${currentState.environment}`); + logger.info(`Process PID: ${process.pid}`); + logger.info("To shut down your server, press + C at any time"); + logger.info(`⚡️ Server: http://${currentState.server.hostname}:${currentState.server.port}`); + } catch (error) { + failServer(error); + } +}; + +void startServer(); diff --git a/server/src/infrastructure/sequelize/initData.test.ts b/server/src/infrastructure/sequelize/initData.test.ts new file mode 100644 index 0000000..a833cef --- /dev/null +++ b/server/src/infrastructure/sequelize/initData.test.ts @@ -0,0 +1,56 @@ +import { insertUsers } from "./initData"; +import { initializeAdmin, initializeSampleDealer, initializeSampleUser } from "@/contexts/users/application/userServices"; + +jest.mock("@/config", () => ({ config: { defaults: {} } })); +jest.mock("@/contexts/common/domain", () => ({ RepositoryManager: { getInstance: () => ({ getRepository: jest.fn() }) } })); +jest.mock("@/contexts/common/infrastructure/sequelize", () => ({ createSequelizeAdapter: jest.fn() })); +jest.mock("@/contexts/sales/infrastructure/Dealer.repository", () => ({ registerDealerRepository: jest.fn() })); +jest.mock("@/contexts/users/infrastructure/User.repository", () => ({ registerUserRepository: jest.fn() })); +jest.mock("@/contexts/users/application/userServices", () => ({ + initializeAdmin: jest.fn(), initializeSampleUser: jest.fn(), initializeSampleDealer: jest.fn(), +})); +const admin = jest.mocked(initializeAdmin); +const user = jest.mocked(initializeSampleUser); +const dealer = jest.mocked(initializeSampleDealer); + +beforeEach(() => { + jest.resetAllMocks(); + admin.mockResolvedValue(undefined); + user.mockResolvedValue(false); + 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(); + expect(user).not.toHaveBeenCalled(); + finish(); + await pending; + expect(user).toHaveBeenCalledTimes(1); + expect(dealer).not.toHaveBeenCalled(); +}); + +it.each(["admin", "user", "dealer"])("propagates %s initialization errors", async (stage) => { + user.mockResolvedValue({} as Exclude>, boolean>); + const failure = new Error("Initialization failed"); + ({ admin, user, dealer })[stage as "admin" | "user" | "dealer"].mockRejectedValue(failure); + await expect(insertUsers()).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>); + let finish!: () => void; + dealer.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + let completed = false; + const pending = insertUsers().then(() => { completed = true; }); + await Promise.resolve(); + await Promise.resolve(); + expect(dealer).toHaveBeenCalledTimes(1); + expect(completed).toBe(false); + finish(); + await pending; + expect(completed).toBe(true); +}); diff --git a/server/src/infrastructure/sequelize/initData.ts b/server/src/infrastructure/sequelize/initData.ts index b95d34c..cf0ca0d 100644 --- a/server/src/infrastructure/sequelize/initData.ts +++ b/server/src/infrastructure/sequelize/initData.ts @@ -20,14 +20,14 @@ export const insertUsers = async () => { registerUserRepository(context); registerDealerRepository(context); - initializeAdmin(context.adapter, context.repositoryManager.getRepository("User")); + await initializeAdmin(context.adapter, context.repositoryManager.getRepository("User")); const user = await initializeSampleUser( context.adapter, context.repositoryManager.getRepository("User") ); if (user) { - initializeSampleDealer( + await initializeSampleDealer( user, context.adapter, context.repositoryManager.getRepository("Dealer") diff --git a/server/src/infrastructure/sequelize/initStructure.test.ts b/server/src/infrastructure/sequelize/initStructure.test.ts new file mode 100644 index 0000000..9cf7190 --- /dev/null +++ b/server/src/infrastructure/sequelize/initStructure.test.ts @@ -0,0 +1,32 @@ +import { Sequelize } from "sequelize"; +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; + return []; + }); + await expect(initStructure({ query } as unknown as Sequelize)).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}`); +}); + +it("propagates metadata query 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); + expect(query).toHaveBeenCalledTimes(1); +}); + +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"); +}); diff --git a/server/src/infrastructure/sequelize/initStructure.ts b/server/src/infrastructure/sequelize/initStructure.ts index 169824d..53ac9ac 100644 --- a/server/src/infrastructure/sequelize/initStructure.ts +++ b/server/src/infrastructure/sequelize/initStructure.ts @@ -49,6 +49,7 @@ const createCatalogTableIfNotExists = async (sequelize: Sequelize) => { } } catch (error) { logger.error('Error al crear la tabla "catalog":', error); + throw error; } }; @@ -97,6 +98,7 @@ const createCatalogTranslationsTableIfNotExists = async (sequelize: Sequelize) = } } catch (error) { logger.error('Error al crear la tabla "catalog_translations":', error); + throw error; } }; @@ -138,10 +140,11 @@ const createVCatalogViewIfNotExists = async (sequelize: Sequelize) => { } } catch (error) { logger.error('Error al crear la vista "v_catalog":', error); + throw error; } }; -export const initStructure = async (sequelize) => { +export const initStructure = async (sequelize: Sequelize) => { await createCatalogTableIfNotExists(sequelize); await createCatalogTranslationsTableIfNotExists(sequelize); await createVCatalogViewIfNotExists(sequelize);