feat: implementar pruebas para la inicialización de usuarios y estructuras en la base de datos

This commit is contained in:
David Arranz 2026-10-22 10:33:23 +02:00
parent e4d355d00f
commit 8fcbf422f4
6 changed files with 387 additions and 90 deletions

View File

@ -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<string, (...args: unknown[]) => void>;
let exit: jest.SpyInstance;
const flush = () => new Promise<void>((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<void>((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<void>((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);
});

View File

@ -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<void>((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<typeof createSequelizeAdapter> | 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<void>((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 <CTRL> + C at any time");
logger.info(`⚡️ Server: http://${currentState.server.hostname}:${currentState.server.port}`);
await new Promise<void>((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 <CTRL> + C at any time");
logger.info(`⚡️ Server: http://${currentState.server.hostname}:${currentState.server.port}`);
} catch (error) {
failServer(error);
}
};
void startServer();

View File

@ -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<void>((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<Awaited<ReturnType<typeof initializeSampleUser>>, 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<Awaited<ReturnType<typeof initializeSampleUser>>, boolean>);
let finish!: () => void;
dealer.mockReturnValue(new Promise<void>((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);
});

View File

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

View File

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

View File

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