From da362fef6407c59ac236fc9d8519144ba9f3e008 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 22 Sep 2026 16:49:08 +0200 Subject: [PATCH] feat(e2e): implement end-to-end testing framework with database preparation and lifecycle management - Add E2ESchemaDatabaseConfig interface for database configuration validation. - Implement assertE2ESchemaDatabaseConfig function to enforce schema requirements. - Create prepareE2ESchema function to handle schema preparation and initialization. - Develop E2E lifecycle management functions to provision, prepare, and run tests. - Introduce Docker Compose setup for E2E database environment. - Add Playwright configuration for E2E testing. - Implement various test cases to validate E2E schema preparation and lifecycle. - Create utility functions for environment variable management and database assertions. - Establish smoke tests to verify initial data setup and integrity. --- apps/server/.env.e2e.example | 16 +- apps/server/src/index.ts | 7 +- apps/server/src/server-module-composition.ts | 9 + .../testing/e2e/bootstrap-e2e-data.test.ts | 192 +++++++++++++++++ .../src/testing/e2e/bootstrap-e2e-data.ts | 133 ++++++++++++ .../testing/e2e/e2e-schema-database-config.ts | 41 ++++ .../testing/e2e/prepare-e2e-schema.test.ts | 178 ++++++++++++++++ .../src/testing/e2e/prepare-e2e-schema.ts | 124 +++++++++++ .../src/api/modules/module-runtime.test.ts | 85 ++++++++ .../core/src/api/modules/module-runtime.ts | 34 +-- .../customer-additional-config-fields.tsx | 2 +- .../ui/editor/customer-address-editor.tsx | 2 +- .../ui/editor/customer-basic-info-editor.tsx | 2 +- .../ui/editor/customer-contact-fields.tsx | 2 +- .../ui/editor/customer-fiscal-editor.tsx | 2 +- package.json | 17 +- playwright.config.ts | 44 ++-- playwright.external.config.ts | 24 +++ tests/e2e/README.md | 28 ++- tests/e2e/docker-compose.yml | 15 ++ tests/e2e/fixtures/company-certificates.json | 1 + tests/e2e/support/e2e-contract.test.ts | 80 ++++++++ tests/e2e/support/e2e-contract.ts | 67 ++++++ tests/e2e/support/e2e-database-environment.ts | 117 +++++++++++ tests/e2e/support/e2e-lifecycle.test.ts | 194 ++++++++++++++++++ tests/e2e/support/e2e-lifecycle.ts | 37 ++++ .../e2e/support/e2e-schema-database-config.ts | 17 ++ tests/e2e/support/ensure-e2e-database.mjs | 30 --- tests/e2e/support/ensure-e2e-database.ts | 37 ++++ tests/e2e/support/run-e2e.ts | 64 ++++++ tests/e2e/support/smoke-e2e-bootstrap.ts | 93 +++++++++ 31 files changed, 1600 insertions(+), 94 deletions(-) create mode 100644 apps/server/src/server-module-composition.ts create mode 100644 apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts create mode 100644 apps/server/src/testing/e2e/bootstrap-e2e-data.ts create mode 100644 apps/server/src/testing/e2e/e2e-schema-database-config.ts create mode 100644 apps/server/src/testing/e2e/prepare-e2e-schema.test.ts create mode 100644 apps/server/src/testing/e2e/prepare-e2e-schema.ts create mode 100644 modules/core/src/api/modules/module-runtime.test.ts create mode 100644 playwright.external.config.ts create mode 100644 tests/e2e/docker-compose.yml create mode 100644 tests/e2e/fixtures/company-certificates.json create mode 100644 tests/e2e/support/e2e-contract.test.ts create mode 100644 tests/e2e/support/e2e-contract.ts create mode 100644 tests/e2e/support/e2e-database-environment.ts create mode 100644 tests/e2e/support/e2e-lifecycle.test.ts create mode 100644 tests/e2e/support/e2e-lifecycle.ts create mode 100644 tests/e2e/support/e2e-schema-database-config.ts delete mode 100644 tests/e2e/support/ensure-e2e-database.mjs create mode 100644 tests/e2e/support/ensure-e2e-database.ts create mode 100644 tests/e2e/support/run-e2e.ts create mode 100644 tests/e2e/support/smoke-e2e-bootstrap.ts diff --git a/apps/server/.env.e2e.example b/apps/server/.env.e2e.example index 0f5447ed..6742c72f 100644 --- a/apps/server/.env.e2e.example +++ b/apps/server/.env.e2e.example @@ -4,20 +4,24 @@ API_BASE_PATH=/api/v1 FRONTEND_URL=http://127.0.0.1:5173 TRUST_PROXY=0 TIMEZONE=Europe/Madrid + DB_DIALECT=mysql -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_NAME=uecko_erp_e2e -DB_USER=replace-with-e2e-user -DB_PASS=replace-with-e2e-password +DB_HOST=provided-by-e2e-launcher +DB_PORT=1 +DB_NAME=factuges_erp_e2e_placeholder +DB_USER=provided-by-e2e-launcher +DB_PASS=provided-by-e2e-launcher DB_LOGGING=false DB_SSL=false DB_SYNC_MODE=none + WARMUP_TIMEOUT_MS=30000 WARMUP_STRICT=false + JWT_SECRET=replace-with-a-dedicated-e2e-secret-of-at-least-32-characters JWT_ACCESS_EXPIRATION=15m JWT_REFRESH_EXPIRATION=7d + TEMPLATES_PATH=./storage/e2e/templates DOCUMENTS_PATH=./storage/e2e/documents SIGNED_DOCUMENTS_PATH=./storage/e2e/signed-documents @@ -26,7 +30,7 @@ SIGNING_SERVICE_URL=http://127.0.0.1:3003 SIGNING_SERVICE_METHOD=POST SIGNING_SERVICE_TIMEOUT_MS=5000 SIGNING_SERVICE_MAX_RETRIES=0 -COMPANY_CERTIFICATES_PATH=./storage/e2e/certificates +COMPANY_CERTIFICATES_PATH=../../tests/e2e/fixtures/company-certificates.json E2E_USER_EMAIL=replace-with-e2e-user-email E2E_USER_PASSWORD=replace-with-e2e-user-password diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3b0106db..ab31e775 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -8,11 +8,9 @@ import { OperationAuditContextProvider, SequelizeAuditStore, SequelizeTransactionContext, - auditModelInitializers, getServiceScoped, initModules, listServices, - registerModels, startModules, } from "@erp/core/api"; import { Audit } from "@repo/rdx-audit"; @@ -27,7 +25,7 @@ import { registerHealthRoutes } from "./health.ts"; import { registerAuditRoutes } from "./infrastructure/audit/audit.routes"; import { SequelizeAuditReader } from "./infrastructure/audit/audit-reader"; import { listRoutes, logger } from "./lib/index.ts"; -import { registerModules } from "./register-modules.ts"; +import { registerServerModuleComposition } from "./server-module-composition.ts"; z.config(z.locales.es()); @@ -141,7 +139,7 @@ const serverConnection = (conn: any) => { }; // Cargar paquetes de la aplicación: customers, invoices, routes... -registerModules(); +registerServerModuleComposition(); const app = createApp(config, operationContext); @@ -251,7 +249,6 @@ process.on("uncaughtException", async (error: Error) => { // ➕ Rutas de salud disponibles desde el inicio del proceso registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: isReady })); - registerModels(auditModelInitializers, "server-infrastructure"); const auditStore = new SequelizeAuditStore( AuditEventModel, AuditChangeModel, diff --git a/apps/server/src/server-module-composition.ts b/apps/server/src/server-module-composition.ts new file mode 100644 index 00000000..b11e270b --- /dev/null +++ b/apps/server/src/server-module-composition.ts @@ -0,0 +1,9 @@ +import { auditModelInitializers, registerModels } from "@erp/core/api"; + +import { registerModules } from "./register-modules"; + +/** Registra la composición estructural única del backend, sin iniciar HTTP. */ +export function registerServerModuleComposition(): void { + registerModules(); + registerModels(auditModelInitializers, "server-infrastructure"); +} diff --git a/apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts b/apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts new file mode 100644 index 00000000..befc4f97 --- /dev/null +++ b/apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { bootstrapE2EData } from "./bootstrap-e2e-data"; + +const databaseConfig = { + executionId: "run", + host: "127.0.0.1", + port: 3307, + database: "factuges_erp_e2e_run", + username: "e2e", + password: "secret", +}; + +const bootstrapConfig = { + executionId: "run", + email: "e2e@example.test", + password: "secret-password", + companyName: "E2E Company", + companySlug: "e2e-company", +}; + +test("crea el grafo mínimo y nunca persiste la contraseña en claro", async () => { + const queries: Array<{ sql: string; replacements: Record }> = []; + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = "test"; + try { + const result = await bootstrapE2EData( + databaseConfig, + { + executionId: "run", + email: "e2e@example.test", + password: "secret-password", + companyName: "E2E Company", + companySlug: "e2e-company", + }, + { + logger: {} as never, + createDatabase: async () => + ({ + transaction: async (callback: (transaction: unknown) => Promise) => callback({}), + query: async (sql: string, options: { replacements: Record }) => { + queries.push({ sql, replacements: options.replacements }); + }, + close: async () => undefined, + }) as never, + } + ); + assert.equal(queries.length, 6); + assert.equal(result.email, "e2e@example.test"); + assert.equal(result.companySlug, "e2e-company"); + const account = queries[0]; + assert.notEqual(account.replacements.passwordHash, "secret-password"); + assert.match(String(account.replacements.passwordHash), /^\$2/); + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previous; + } + } +}); + +test("rechaza destinos y configuración inseguros antes de conectar", async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousBaseUrl = process.env.E2E_BASE_URL; + process.env.NODE_ENV = "test"; + try { + for (const params of [ + { database: { ...databaseConfig, database: "development" }, bootstrap: bootstrapConfig }, + { database: { ...databaseConfig, host: "192.168.0.104" }, bootstrap: bootstrapConfig }, + { database: { ...databaseConfig, port: 0 }, bootstrap: bootstrapConfig }, + { database: { ...databaseConfig, executionId: " " }, bootstrap: bootstrapConfig }, + { database: { ...databaseConfig, username: "" }, bootstrap: bootstrapConfig }, + { database: databaseConfig, bootstrap: { ...bootstrapConfig, password: "" } }, + ]) { + let connected = false; + await assert.rejects( + () => + bootstrapE2EData(params.database, params.bootstrap, { + logger: {} as never, + createDatabase: async () => { + connected = true; + return {} as never; + }, + }), + /Unsafe E2E database|local database host|Invalid E2E database port|execution ID|credentials|bootstrap/ + ); + assert.equal(connected, false); + } + + process.env.E2E_BASE_URL = "http://external.example"; + await assert.rejects( + () => bootstrapE2EData(databaseConfig, bootstrapConfig, { logger: {} as never }), + /E2E_BASE_URL/ + ); + } finally { + restoreEnvironment("NODE_ENV", previousNodeEnv); + restoreEnvironment("E2E_BASE_URL", previousBaseUrl); + } +}); + +test("rechaza NODE_ENV distinto de test antes de conectar", async () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + let connected = false; + try { + await assert.rejects( + () => + bootstrapE2EData(databaseConfig, bootstrapConfig, { + logger: {} as never, + createDatabase: async () => { + connected = true; + return {} as never; + }, + }), + /NODE_ENV=test/ + ); + assert.equal(connected, false); + } finally { + restoreEnvironment("NODE_ENV", previous); + } +}); + +for (const [name, failureAt] of [ + ["company", 2], + ["membership", 3], + ["role", 4], + ["onboarding", 6], +] as const) { + test(`revierte la transacción si falla ${name}`, async () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = "test"; + const events: string[] = []; + try { + await assert.rejects( + () => + bootstrapE2EData( + databaseConfig, + bootstrapConfig, + dependenciesWithFailure(failureAt, events) + ), + new RegExp(`${name} failed`) + ); + assert.deepEqual(events, [ + "begin", + ...Array.from({ length: failureAt }, (_, index) => `query:${index + 1}`), + "rollback", + "close", + ]); + } finally { + restoreEnvironment("NODE_ENV", previous); + } + }); +} + +function dependenciesWithFailure(failureAt: number, events: string[]) { + return { + logger: {} as never, + createDatabase: async () => + ({ + transaction: async (callback: (transaction: unknown) => Promise) => { + events.push("begin"); + try { + await callback({}); + events.push("commit"); + } catch (error) { + events.push("rollback"); + throw error; + } + }, + query: async (_sql: string) => { + const position = events.filter((event) => event.startsWith("query:")).length + 1; + events.push(`query:${position}`); + if (position === failureAt) { + throw new Error( + `${["account", "company", "membership", "role", "membership-role", "onboarding"][position - 1]} failed` + ); + } + }, + close: async () => events.push("close"), + }) as never, + }; +} + +function restoreEnvironment(name: "NODE_ENV" | "E2E_BASE_URL", value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/apps/server/src/testing/e2e/bootstrap-e2e-data.ts b/apps/server/src/testing/e2e/bootstrap-e2e-data.ts new file mode 100644 index 00000000..5ca6cfd3 --- /dev/null +++ b/apps/server/src/testing/e2e/bootstrap-e2e-data.ts @@ -0,0 +1,133 @@ +import { randomUUID } from "node:crypto"; + +import { createSequelize } from "@erp/core/api"; +import { BcryptPasswordHasher } from "@erp/identity/api"; +import { IdentityPermissionCodes } from "@erp/identity/common"; +import type { ILogger } from "@repo/rdx-logger"; +import type { Sequelize } from "sequelize"; + +import type { E2ESchemaDatabaseConfig } from "./e2e-schema-database-config"; +import { assertE2ESchemaDatabaseConfig } from "./e2e-schema-database-config"; + +export interface E2EBootstrapConfig { + readonly executionId: string; + readonly email: string; + readonly password: string; + readonly companyName: string; + readonly companySlug: string; +} + +export interface E2EBootstrapResult { + readonly accountId: string; + readonly companyId: string; + readonly companySlug: string; + readonly email: string; +} + +/** Crea el grafo mínimo autenticable sobre un schema E2E ya preparado. */ +export async function bootstrapE2EData( + databaseConfig: E2ESchemaDatabaseConfig, + config: E2EBootstrapConfig, + dependencies: { + readonly logger: ILogger; + readonly createDatabase?: (config: E2ESchemaDatabaseConfig) => Promise; + } +): Promise { + assertE2ESchemaDatabaseConfig(databaseConfig); + assertBootstrapConfig(config, databaseConfig.executionId); + + const database = await (dependencies.createDatabase ?? createDatabase)( + databaseConfig, + dependencies.logger + ); + try { + return await createBootstrapData(database, config); + } finally { + await database.close(); + } +} + +async function createBootstrapData( + database: Sequelize, + config: E2EBootstrapConfig +): Promise { + const passwordHashResult = await new BcryptPasswordHasher({ rounds: 10 }).hash(config.password); + if (passwordHashResult.isFailure) { + throw passwordHashResult.error; + } + + const accountId = randomUUID(); + const companyId = randomUUID(); + const membershipId = randomUUID(); + const roleId = randomUUID(); + const onboardingId = randomUUID(); + + await database.transaction(async (transaction) => { + const query = (sql: string, replacements: Record) => + database.query(sql, { replacements, transaction }); + + await query( + "INSERT INTO identity_accounts (id, email, password_hash, name, avatar_url, language_code, status, created_at, updated_at) VALUES (:id, :email, :passwordHash, :name, NULL, 'es', 'active', NOW(), NOW())", + { + id: accountId, + email: config.email, + passwordHash: passwordHashResult.data.toPrimitive(), + name: "E2E User", + } + ); + await query( + "INSERT INTO companies (id, legal_name, slug, status, created_at, updated_at) VALUES (:id, :name, :slug, 'active', NOW(), NOW())", + { id: companyId, name: config.companyName, slug: config.companySlug } + ); + await query( + "INSERT INTO identity_company_memberships (id, account_id, company_id, status, created_at, updated_at) VALUES (:id, :accountId, :companyId, 'active', NOW(), NOW())", + { id: membershipId, accountId, companyId } + ); + await query( + "INSERT INTO identity_roles (id, company_id, code, name, is_system, permission_codes, created_at, updated_at) VALUES (:id, :companyId, 'admin', 'Administrator', true, :permissions, NOW(), NOW())", + { id: roleId, companyId, permissions: JSON.stringify(Object.values(IdentityPermissionCodes)) } + ); + await query( + "INSERT INTO identity_company_membership_roles (membership_id, role_id) VALUES (:membershipId, :roleId)", + { membershipId, roleId } + ); + await query( + "INSERT INTO initial_onboardings (id, account_id, status, company_id, created_at, updated_at, completed_at) VALUES (:id, :accountId, 'completed', :companyId, NOW(), NOW(), NOW())", + { id: onboardingId, accountId, companyId } + ); + }); + + return { accountId, companyId, companySlug: config.companySlug, email: config.email }; +} + +async function createDatabase( + config: E2ESchemaDatabaseConfig, + logger: ILogger +): Promise { + return await createSequelize( + { + dialect: "mysql", + host: config.host, + port: config.port, + name: config.database, + user: config.username, + password: config.password, + logging: false, + ssl: false, + timezone: "Europe/Madrid", + }, + logger + ); +} + +function assertBootstrapConfig(config: E2EBootstrapConfig, executionId: string): void { + if ( + config.executionId !== executionId || + !config.email || + !config.password || + !config.companyName || + !config.companySlug + ) { + throw new Error("Invalid E2E bootstrap configuration."); + } +} diff --git a/apps/server/src/testing/e2e/e2e-schema-database-config.ts b/apps/server/src/testing/e2e/e2e-schema-database-config.ts new file mode 100644 index 00000000..86f4c609 --- /dev/null +++ b/apps/server/src/testing/e2e/e2e-schema-database-config.ts @@ -0,0 +1,41 @@ +export interface E2ESchemaDatabaseConfig { + readonly executionId: string; + readonly host: string; + readonly port: number; + readonly database: string; + readonly username: string; + readonly password: string; +} + +const E2E_DATABASE_NAME = /^factuges_erp_e2e_[a-z0-9_]+$/; +const LOCAL_E2E_HOSTS = new Set(["127.0.0.1", "localhost"]); + +export function assertE2ESchemaDatabaseConfig(config: E2ESchemaDatabaseConfig): void { + if (process.env.NODE_ENV !== "test") { + throw new Error("E2E schema preparation requires NODE_ENV=test."); + } + + if (process.env.E2E_BASE_URL) { + throw new Error("E2E schema preparation rejects E2E_BASE_URL."); + } + + if (!E2E_DATABASE_NAME.test(config.database)) { + throw new Error("Unsafe E2E database name."); + } + + if (!LOCAL_E2E_HOSTS.has(config.host)) { + throw new Error("E2E schema preparation requires a local database host."); + } + + if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65_535) { + throw new Error("Invalid E2E database port."); + } + + if (!config.executionId.trim()) { + throw new Error("E2E schema preparation requires an execution ID."); + } + + if (!(config.username.trim() && config.password)) { + throw new Error("E2E schema preparation requires explicit database credentials."); + } +} diff --git a/apps/server/src/testing/e2e/prepare-e2e-schema.test.ts b/apps/server/src/testing/e2e/prepare-e2e-schema.test.ts new file mode 100644 index 00000000..2e7d7231 --- /dev/null +++ b/apps/server/src/testing/e2e/prepare-e2e-schema.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import type { Sequelize } from "sequelize"; + +import type { E2ESchemaDatabaseConfig } from "./e2e-schema-database-config"; +import { type E2ESchemaPreparationDependencies, prepareE2ESchema } from "./prepare-e2e-schema"; + +const config: E2ESchemaDatabaseConfig = { + executionId: "run", + host: "127.0.0.1", + port: 3307, + database: "factuges_erp_e2e_run", + username: "e2e", + password: "secret", +}; + +const originalNodeEnv = process.env.NODE_ENV; +const originalE2EBaseUrl = process.env.E2E_BASE_URL; + +afterEach(() => { + setEnvironmentVariable("NODE_ENV", originalNodeEnv); + setEnvironmentVariable("E2E_BASE_URL", originalE2EBaseUrl); +}); + +test("rechaza NODE_ENV distinto de test antes de crear la conexión", async () => { + process.env.NODE_ENV = "development"; + await assert.rejects(() => prepareE2ESchema(config, dependencies()), /NODE_ENV=test/); +}); + +test("rechaza E2E_BASE_URL antes de crear la conexión", async () => { + process.env.NODE_ENV = "test"; + process.env.E2E_BASE_URL = "http://external.example"; + await assert.rejects(() => prepareE2ESchema(config, dependencies()), /rejects E2E_BASE_URL/); +}); + +test("rechaza destinos E2E inseguros antes de crear la conexión", async () => { + process.env.NODE_ENV = "test"; + + for (const unsafeConfig of [ + { ...config, database: "development" }, + { ...config, host: "192.168.0.104" }, + { ...config, port: 0 }, + { ...config, executionId: " " }, + { ...config, username: "" }, + { ...config, password: "" }, + ]) { + let connectionCreated = false; + await assert.rejects( + () => + prepareE2ESchema( + unsafeConfig, + dependencies({ onCreate: () => (connectionCreated = true) }) + ), + /Unsafe E2E database name|local database host|Invalid E2E database port|execution ID|credentials/ + ); + assert.equal(connectionCreated, false); + } +}); + +test("compone, inicializa, sincroniza sin opciones y cierra", async () => { + process.env.NODE_ENV = "test"; + const calls: unknown[] = []; + + await prepareE2ESchema( + config, + dependencies({ + calls, + database: { + sync: async (...args: unknown[]) => { + calls.push(args); + }, + close: async () => { + calls.push("close"); + }, + }, + }) + ); + + assert.deepEqual(calls, ["create", "composition", "initialize", [], "close"]); +}); + +test("cierra la conexión cuando falla la inicialización sin sincronizar", async () => { + process.env.NODE_ENV = "test"; + const calls: unknown[] = []; + const initializationError = new Error("initialization failed"); + + await assert.rejects( + () => prepareE2ESchema(config, dependencies({ calls, initializeError: initializationError })), + initializationError + ); + + assert.deepEqual(calls, ["create", "composition", "initialize", "close"]); +}); + +test("cierra la conexión y propaga el fallo de sync", async () => { + process.env.NODE_ENV = "test"; + const calls: unknown[] = []; + const syncError = new Error("sync failed"); + + await assert.rejects( + () => prepareE2ESchema(config, dependencies({ calls, syncError })), + syncError + ); + + assert.deepEqual(calls, ["create", "composition", "initialize", [], "close"]); +}); + +test("preserva los fallos de la operación principal y del cierre", async () => { + process.env.NODE_ENV = "test"; + const syncError = new Error("sync failed"); + const closeError = new Error("close failed"); + + await assert.rejects( + () => prepareE2ESchema(config, dependencies({ syncError, closeError })), + (error: unknown) => { + assert.ok(error instanceof AggregateError); + assert.deepEqual(error.errors, [syncError, closeError]); + return true; + } + ); +}); + +function dependencies( + options: { + calls?: unknown[]; + database?: { sync: (...args: unknown[]) => Promise; close: () => Promise }; + initializeError?: Error; + syncError?: Error; + closeError?: Error; + onCreate?: () => void; + } = {} +): E2ESchemaPreparationDependencies { + const calls = options.calls ?? []; + const database = + options.database ?? + ({ + sync: async (...args: unknown[]) => { + calls.push(args); + if (options.syncError) { + throw options.syncError; + } + }, + close: async () => { + calls.push("close"); + if (options.closeError) { + throw options.closeError; + } + }, + } as const); + + return { + createDatabase: async () => { + calls.push("create"); + options.onCreate?.(); + return database as unknown as Sequelize; + }, + registerComposition: () => calls.push("composition"), + initializeStructure: async () => { + calls.push("initialize"); + if (options.initializeError) { + throw options.initializeError; + } + }, + }; +} + +function setEnvironmentVariable( + name: "NODE_ENV" | "E2E_BASE_URL", + value: string | undefined +): void { + if (value === undefined) { + delete process.env[name]; + return; + } + + process.env[name] = value; +} diff --git a/apps/server/src/testing/e2e/prepare-e2e-schema.ts b/apps/server/src/testing/e2e/prepare-e2e-schema.ts new file mode 100644 index 00000000..c16edbbb --- /dev/null +++ b/apps/server/src/testing/e2e/prepare-e2e-schema.ts @@ -0,0 +1,124 @@ +import { + AsyncLocalStorageOperationContextStore, + AuditChangeModel, + AuditEventModel, + OperationAuditContextProvider, + SequelizeAuditStore, + SequelizeTransactionContext, + createSequelize, + initializeModuleStructure, +} from "@erp/core/api"; +import { Audit } from "@repo/rdx-audit"; +import type { Sequelize } from "sequelize"; + +import { createApp } from "../../app.ts"; +import { type ConfigType, initConfig } from "../../config"; +import { logger } from "../../lib/logger.ts"; +import { registerServerModuleComposition } from "../../server-module-composition.ts"; + +import { + type E2ESchemaDatabaseConfig, + assertE2ESchemaDatabaseConfig, +} from "./e2e-schema-database-config"; + +const AUDIT_SOURCE = { type: "application", id: "factuges-erp" } as const; + +export interface E2ESchemaPreparationDependencies { + readonly createDatabase: (config: E2ESchemaDatabaseConfig) => Promise; + readonly registerComposition: () => void; + readonly initializeStructure: (database: Sequelize) => Promise; +} + +export async function prepareE2ESchema( + config: E2ESchemaDatabaseConfig, + dependencies: E2ESchemaPreparationDependencies = createDefaultDependencies() +): Promise { + assertE2ESchemaDatabaseConfig(config); + const database = await dependencies.createDatabase(config); + let mainError: unknown; + + try { + dependencies.registerComposition(); + await dependencies.initializeStructure(database); + await database.sync(); + } catch (error) { + mainError = error; + } + + try { + await database.close(); + } catch (closeError) { + if (mainError) { + throw new AggregateError( + [mainError, closeError], + "E2E schema preparation and database close failed." + ); + } + + throw closeError; + } + + if (mainError) { + throw mainError; + } +} + +function createDefaultDependencies(): E2ESchemaPreparationDependencies { + let serverConfig: ConfigType | null = null; + + return { + createDatabase: async (config) => { + serverConfig = createE2EServerConfig(config); + return await createSequelize(serverConfig.database, logger); + }, + registerComposition: registerServerModuleComposition, + initializeStructure: async (database) => { + if (!serverConfig) { + throw new Error("E2E schema preparation database configuration was not initialized."); + } + + const operationContext = new AsyncLocalStorageOperationContextStore(); + const transactionContext = new SequelizeTransactionContext(); + const auditContextProvider = new OperationAuditContextProvider(operationContext); + const auditStore = new SequelizeAuditStore( + AuditEventModel, + AuditChangeModel, + transactionContext + ); + const audit = new Audit({ + source: AUDIT_SOURCE, + contextProvider: auditContextProvider, + store: auditStore, + }); + + await initializeModuleStructure({ + config: serverConfig, + app: createApp(serverConfig, operationContext), + database, + logger, + operationContext, + transactionContext, + audit, + getService: () => { + throw new Error("Services are only available from a module-scoped context."); + }, + }); + }, + }; +} + +function createE2EServerConfig(config: E2ESchemaDatabaseConfig): ConfigType { + const serverConfig = initConfig(logger); + + return { + ...serverConfig, + database: { + ...serverConfig.database, + host: config.host, + port: config.port, + name: config.database, + user: config.username, + password: config.password, + }, + }; +} diff --git a/modules/core/src/api/modules/module-runtime.test.ts b/modules/core/src/api/modules/module-runtime.test.ts new file mode 100644 index 00000000..36fb641e --- /dev/null +++ b/modules/core/src/api/modules/module-runtime.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + initModules, + initializeModuleStructure, + registerModule, + resetModuleRuntimeForTests, +} from "./module-runtime"; + +const events: string[] = []; + +function registerFixture(): void { + registerModule({ + name: "customer-invoices", + setup: async () => ({ + internal: { proformas: { recurringProformas: {} } }, + models: [ + () => { + events.push("init:A"); + return { name: "ModelA", associate: () => events.push("associate:A") }; + }, + () => { + events.push("init:B"); + return { name: "ModelB", associate: () => events.push("associate:B") }; + }, + ], + }), + warmup: async () => events.push("warmup"), + } as never); +} + +function params(syncMode: string) { + return { + database: { + sync: async (options?: unknown) => events.push(`sync:${JSON.stringify(options)}`), + }, + logger: { info: () => undefined, warn: () => undefined }, + warmup: { timeoutMs: 1_000, strict: true }, + databaseSyncMode: syncMode, + } as never; +} + +test.afterEach(() => { + resetModuleRuntimeForTests(); + events.length = 0; +}); + +test("initializeModuleStructure inicializa todos los modelos antes de asociarlos, sin sync ni warmup", async () => { + registerFixture(); + await initializeModuleStructure(params("none")); + assert.deepEqual(events, ["init:A", "init:B", "associate:A", "associate:B"]); +}); + +test("initModules conserva none y ejecuta warmup después de la estructura", async () => { + registerFixture(); + await initModules(params("none")); + assert.deepEqual(events, ["init:A", "init:B", "associate:A", "associate:B", "warmup"]); +}); + +test("initModules conserva alter antes del warmup", async () => { + registerFixture(); + await initModules(params("alter")); + assert.deepEqual(events, [ + "init:A", + "init:B", + "associate:A", + "associate:B", + 'sync:{"force":false,"alter":true}', + "warmup", + ]); +}); + +test("initModules conserva force antes del warmup", async () => { + registerFixture(); + await initModules(params("force")); + assert.deepEqual(events, [ + "init:A", + "init:B", + "associate:A", + "associate:B", + 'sync:{"force":true,"alter":false}', + "warmup", + ]); +}); diff --git a/modules/core/src/api/modules/module-runtime.ts b/modules/core/src/api/modules/module-runtime.ts index f83e0add..860edcbf 100644 --- a/modules/core/src/api/modules/module-runtime.ts +++ b/modules/core/src/api/modules/module-runtime.ts @@ -42,12 +42,22 @@ export async function initModules( warmup: { timeoutMs: number; strict: boolean }; databaseSyncMode: string; } +): Promise { + const runtime = await initializeModuleStructure(params); + await applyDatabaseSyncMode(params.database, params.databaseSyncMode); + await warmupModules(params); + validateDependencies(); + return runtime; +} + +/** Inicializa módulos, modelos y asociaciones sin sincronizar ni ejecutar warmups. */ +export async function initializeModuleStructure( + params: ModuleParams & { logger: ILogger; database: Sequelize } ): Promise { for (const name of modules.keys()) { await setupModule(name, params, []); } - await initializeModels(params.database, params.databaseSyncMode, params.logger); - await warmupModules(params); + await initializeModels(params.database, params.logger); validateDependencies(); const customerInvoices = internals.get("customer-invoices") as @@ -127,11 +137,7 @@ async function setupModule( visiting.delete(name); } -async function initializeModels( - database: Sequelize, - syncMode: string, - logger: ILogger -): Promise { +async function initializeModels(database: Sequelize, logger: ILogger): Promise { if (modelsInitialized) { return; } @@ -154,6 +160,14 @@ async function initializeModels( associate.associate?.(database); } + logger.info("Backend module models initialized", { + label: "moduleRuntime", + modelCount: registered.size, + }); + modelsInitialized = true; +} + +async function applyDatabaseSyncMode(database: Sequelize, syncMode: string): Promise { if (syncMode === "alter") { await database.sync({ force: false, alter: true }); } @@ -161,12 +175,6 @@ async function initializeModels( if (syncMode === "force") { await database.sync({ force: true, alter: false }); } - - logger.info("Backend module models initialized", { - label: "moduleRuntime", - modelCount: registered.size, - }); - modelsInitialized = true; } async function warmupModules( diff --git a/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx b/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx index 03acd77e..d2b4a5b8 100644 --- a/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx @@ -18,7 +18,7 @@ export const CustomerAdditionalConfigEditor = ({ return ( } + icon={} title={t("form_groups.preferences.title")} variant="card" > diff --git a/modules/customers/src/web/update/ui/editor/customer-address-editor.tsx b/modules/customers/src/web/update/ui/editor/customer-address-editor.tsx index 7a13dda0..7c45231d 100644 --- a/modules/customers/src/web/update/ui/editor/customer-address-editor.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-address-editor.tsx @@ -18,7 +18,7 @@ export const CustomerAddressEditor = ({ return ( } + icon={} title={t("form_groups.address.title")} variant="card" > diff --git a/modules/customers/src/web/update/ui/editor/customer-basic-info-editor.tsx b/modules/customers/src/web/update/ui/editor/customer-basic-info-editor.tsx index 1f3da28d..840475cc 100644 --- a/modules/customers/src/web/update/ui/editor/customer-basic-info-editor.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-basic-info-editor.tsx @@ -34,7 +34,7 @@ export const CustomerBasicInfoEditor = ({ return ( } + icon={} title={t("form_groups.basic_info.title")} variant="card" > diff --git a/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx b/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx index 27252317..17488707 100644 --- a/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx @@ -25,7 +25,7 @@ export const CustomerContactEditor = ({ return ( } + icon={} title={t("form_groups.contact_info.title")} variant="card" > diff --git a/modules/customers/src/web/update/ui/editor/customer-fiscal-editor.tsx b/modules/customers/src/web/update/ui/editor/customer-fiscal-editor.tsx index 073a8530..04ba402a 100644 --- a/modules/customers/src/web/update/ui/editor/customer-fiscal-editor.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-fiscal-editor.tsx @@ -29,7 +29,7 @@ export const CustomerFiscalEditor = ({ return ( } + icon={} title={t("form_groups.fiscal_info.title")} variant="card" > diff --git a/package.json b/package.json index f7b3b2e6..8ec1da22 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,7 @@ "name": "factuges-erp-2025", "private": true, "version": "0.10.10", - "workspaces": [ - "apps/*", - "modules/*", - "packages/*" - ], + "workspaces": ["apps/*", "modules/*", "packages/*"], "scripts": { "build": "turbo run build", "build:templates": "bash scripts/build-templates.sh", @@ -25,9 +21,12 @@ "check": "biome check .", "check:write": "biome check --write --unsafe .", "test:frontend": "node --experimental-test-module-mocks --import tsx --test", - "test:e2e": "node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test", - "test:e2e:ui": "node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test --ui", - "test:e2e:headed": "node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test --headed", + "test:e2e": "node --env-file=apps/server/.env.e2e --import=tsx tests/e2e/support/run-e2e.ts", + "test:e2e:ui": "node --env-file=apps/server/.env.e2e --import=tsx tests/e2e/support/run-e2e.ts --ui", + "test:e2e:headed": "node --env-file=apps/server/.env.e2e --import=tsx tests/e2e/support/run-e2e.ts --headed", + "test:e2e:external": "E2E_EXECUTION_MODE=external node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test --config=playwright.external.config.ts", + "test:e2e:external:ui": "E2E_EXECUTION_MODE=external node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test --config=playwright.external.config.ts --ui", + "test:e2e:external:headed": "E2E_EXECUTION_MODE=external node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test --config=playwright.external.config.ts --headed", "test:unit": "node --import tsx --test", "typecheck": "tsc -p tsconfig.json --noEmit" }, @@ -53,4 +52,4 @@ "node": ">=24" }, "packageManager": "pnpm@11.25.0" -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts index 669a8a00..7580bb81 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,11 +1,17 @@ import { defineConfig, devices } from "@playwright/test"; +import { + assertOfficialE2EEnvironment, + assertOfficialE2EExecution, +} from "./tests/e2e/support/e2e-contract"; + const localBaseUrl = "http://127.0.0.1:5173"; -const baseURL = process.env.E2E_BASE_URL ?? localBaseUrl; -const startLocalStack = !process.env.E2E_BASE_URL; +assertOfficialE2EExecution(process.env); +assertOfficialE2EEnvironment(process.env); export default defineConfig({ testDir: "./tests/e2e", + testIgnore: "**/e2e-contract.test.ts", timeout: 30_000, expect: { timeout: 5_000 }, fullyParallel: true, @@ -13,28 +19,26 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: [["html", { open: "never" }]], use: { - baseURL, + baseURL: localBaseUrl, testIdAttribute: "data-testid", trace: "on-first-retry", screenshot: "only-on-failure", video: "retain-on-failure", }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], - webServer: startLocalStack - ? [ - { - command: - "node tests/e2e/support/ensure-e2e-database.mjs && cd apps/server && node --env-file=.env.e2e --import=tsx src/index.ts", - url: "http://127.0.0.1:3002/api/v1/__ready", - timeout: 120_000, - reuseExistingServer: !process.env.CI, - }, - { - command: "cd apps/web && pnpm dev -- --mode e2e --port 5173", - url: localBaseUrl, - timeout: 120_000, - reuseExistingServer: !process.env.CI, - }, - ] - : undefined, + webServer: [ + { + command: + "node --import=tsx tests/e2e/support/ensure-e2e-database.ts && cd apps/server && node --env-file=.env.e2e --import=tsx src/index.ts", + url: "http://127.0.0.1:3002/api/v1/__ready", + timeout: 120_000, + reuseExistingServer: false, + }, + { + command: "cd apps/web && pnpm dev -- --mode e2e --port 5173", + url: localBaseUrl, + timeout: 120_000, + reuseExistingServer: false, + }, + ], }); diff --git a/playwright.external.config.ts b/playwright.external.config.ts new file mode 100644 index 00000000..7ba6a900 --- /dev/null +++ b/playwright.external.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from "@playwright/test"; + +import { resolveE2EExecutionMode } from "./tests/e2e/support/e2e-contract"; + +resolveE2EExecutionMode(process.env); + +export default defineConfig({ + testDir: "./tests/e2e", + testIgnore: "**/e2e-contract.test.ts", + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [["html", { open: "never" }]], + use: { + baseURL: process.env.E2E_BASE_URL, + testIdAttribute: "data-testid", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index c5db5d17..7e72e95e 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,6 +1,6 @@ -# Infraestructura E2E +# Infraestructura browser E2E -Suite de Playwright para navegador, frontend, backend y MariaDB E2E. Aún no contiene pruebas funcionales. +Suite Playwright de navegador que valida frontend, backend y MariaDB reales. Es distinta de los tests HTTP/API integration de `apps/server/src/testing/http`, cuyo runtime y lifecycle son independientes. ## Estructura @@ -8,11 +8,15 @@ El primer nivel debe coincidir exactamente con el módulo propietario en `module ## Variables y base de datos -Copia `apps/server/.env.e2e.example` a `apps/server/.env.e2e` y completa una MariaDB exclusiva E2E; `DB_NAME` nunca debe ser la base de desarrollo. Copia `apps/web/.env.e2e.example` a `apps/web/.env.e2e`. +Copiar `apps/server/.env.e2e.example` a `apps/server/.env.e2e` y completar sus valores locales. Copiar `apps/web/.env.e2e.example` a `apps/web/.env.e2e`. -El backend arranca con `node --env-file=.env.e2e`, que prevalece sobre `.env`. La plantilla usa `DB_SYNC_MODE=none`; no hay reset, seed ni operación destructiva en esta fase. Las futuras operaciones destructivas deberán rechazar cualquier base que no sea E2E. +El modo oficial exige `NODE_ENV=test`, `DB_NAME` con prefijo `factuges_erp_e2e_`, `DB_SYNC_MODE=none`, backend en el puerto `3002`, frontend en `http://127.0.0.1:5173` y frontend apuntando a `http://127.0.0.1:3002/api/v1`. Estas validaciones evitan fallbacks a desarrollo. -`E2E_BASE_URL` permite usar un stack E2E ya iniciado y evita levantar procesos locales. Futuras credenciales: `E2E_USER_EMAIL` y `E2E_USER_PASSWORD`, nunca versionadas. +El launcher oficial crea una MariaDB `mariadb:lts-noble` exclusiva con puerto dinámico, credenciales generadas y nombre único; comprueba su readiness y `SELECT 1`. Después prepara el schema mediante la composición modular real del backend y `sequelize.sync()` sin `force`, `alter`, migrations ni SQL manual. El backend posterior recibe la misma DB con `DB_SYNC_MODE=none`, por lo que no la modifica. El launcher destruye exactamente su proyecto Compose al terminar, incluso si falla la preparación o Playwright. No usa la MariaDB remota anterior ni volúmenes persistentes. + +La preparación P3 crea sólo schema. P4 añade antes de Playwright una cuenta E2E, compañía `factuges-e2e`, membership activa, rol admin y onboarding completado. Usa `E2E_USER_EMAIL` y `E2E_USER_PASSWORD`; no crea customers, suppliers, documentos ni series de escenario. El fichero `fixtures/company-certificates.json` es un JSON vacío versionado para que el backend E2E no dependa de certificados locales. + +`E2E_USER_EMAIL` y `E2E_USER_PASSWORD` son credenciales locales no versionadas. ## Ejecución @@ -22,7 +26,19 @@ pnpm test:e2e:ui pnpm test:e2e:headed ``` -Localmente se espera backend en `http://127.0.0.1:3002/api/v1/__ready` y Vite en `http://127.0.0.1:5173`. +Los tres comandos oficiales arrancan su propio backend y Vite. Nunca reutilizan procesos existentes: si los puertos `3002` o `5173` están ocupados, la ejecución falla explícitamente. + +## Modo externo explícito + +Para depurar contra un stack ya iniciado se debe indicar deliberadamente el destino: + +```bash +E2E_BASE_URL=https://e2e.example.test pnpm test:e2e:external +``` + +También existen `test:e2e:external:ui` y `test:e2e:external:headed`. Sólo estos comandos aceptan `E2E_BASE_URL`; los comandos oficiales lo rechazan. + +El modo externo no arranca procesos locales ni valida la infraestructura remota: es una herramienta deliberada de debugging y no sustituye al comando oficial. ## Selectores diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml new file mode 100644 index 00000000..e3b475e8 --- /dev/null +++ b/tests/e2e/docker-compose.yml @@ -0,0 +1,15 @@ +services: + db: + image: mariadb:lts-noble + environment: + MARIADB_DATABASE: ${E2E_DB_NAME} + MARIADB_USER: ${E2E_DB_USER} + MARIADB_PASSWORD: ${E2E_DB_PASS} + MARIADB_ROOT_PASSWORD: ${E2E_DB_ROOT_PASS} + ports: + - "127.0.0.1::3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 1s + timeout: 5s + retries: 60 diff --git a/tests/e2e/fixtures/company-certificates.json b/tests/e2e/fixtures/company-certificates.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/e2e/fixtures/company-certificates.json @@ -0,0 +1 @@ +{} diff --git a/tests/e2e/support/e2e-contract.test.ts b/tests/e2e/support/e2e-contract.test.ts new file mode 100644 index 00000000..2a1fcd63 --- /dev/null +++ b/tests/e2e/support/e2e-contract.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + E2E_API_BASE_PATH, + E2E_BACKEND_PORT, + E2E_DATABASE_PREFIX, + E2E_FRONTEND_URL, + assertOfficialE2EEnvironment, + assertOfficialE2EExecution, + resolveE2EExecutionMode, +} from "./e2e-contract.ts"; + +const officialEnvironment = { + NODE_ENV: "test", + DB_NAME: `${E2E_DATABASE_PREFIX}test`, + DB_SYNC_MODE: "none", + PORT: E2E_BACKEND_PORT, + API_BASE_PATH: E2E_API_BASE_PATH, + FRONTEND_URL: E2E_FRONTEND_URL, +}; + +test("el contrato oficial acepta la configuración E2E completa", () => { + assert.doesNotThrow(() => assertOfficialE2EEnvironment(officialEnvironment)); + assert.doesNotThrow(() => assertOfficialE2EExecution(officialEnvironment)); +}); + +test("el contrato oficial rechaza una base de datos no E2E", () => { + assert.throws( + () => assertOfficialE2EEnvironment({ ...officialEnvironment, DB_NAME: "factuges_erp" }), + /DB_NAME with prefix/ + ); +}); + +test("el contrato oficial rechaza un NODE_ENV distinto de test", () => { + assert.throws( + () => assertOfficialE2EEnvironment({ ...officialEnvironment, NODE_ENV: "development" }), + /NODE_ENV=test/ + ); +}); + +test("el contrato oficial rechaza sync o puerto distintos del contrato", () => { + assert.throws( + () => assertOfficialE2EEnvironment({ ...officialEnvironment, DB_SYNC_MODE: "force" }), + /DB_SYNC_MODE=none/ + ); + assert.throws( + () => assertOfficialE2EEnvironment({ ...officialEnvironment, PORT: "3000" }), + /PORT=3002/ + ); +}); + +test("el contrato oficial rechaza E2E_BASE_URL heredada", () => { + assert.throws( + () => + assertOfficialE2EExecution({ ...officialEnvironment, E2E_BASE_URL: "https://example.test" }), + /does not accept E2E_BASE_URL/ + ); +}); + +test("el contrato oficial rechaza el modo externo heredado", () => { + assert.throws( + () => assertOfficialE2EExecution({ ...officialEnvironment, E2E_EXECUTION_MODE: "external" }), + /does not accept E2E_EXECUTION_MODE/ + ); +}); + +test("el modo externo requiere una URL absoluta y explícita", () => { + assert.throws( + () => resolveE2EExecutionMode({ E2E_EXECUTION_MODE: "external" }), + /requires E2E_BASE_URL/ + ); + assert.equal( + resolveE2EExecutionMode({ + E2E_EXECUTION_MODE: "external", + E2E_BASE_URL: "https://e2e.example.test", + }), + "external" + ); +}); diff --git a/tests/e2e/support/e2e-contract.ts b/tests/e2e/support/e2e-contract.ts new file mode 100644 index 00000000..b9a37288 --- /dev/null +++ b/tests/e2e/support/e2e-contract.ts @@ -0,0 +1,67 @@ +export const E2E_DATABASE_PREFIX = "factuges_erp_e2e_"; +export const E2E_BACKEND_PORT = "3002"; +export const E2E_API_BASE_PATH = "/api/v1"; +export const E2E_FRONTEND_URL = "http://127.0.0.1:5173"; +export const E2E_API_URL = "http://127.0.0.1:3002/api/v1"; + +type Environment = Record; + +export type E2EExecutionMode = "official" | "external"; + +/** Impide que el comando oficial herede un destino o modo externo. */ +export function assertOfficialE2EExecution(environment: Environment): void { + if (environment.E2E_EXECUTION_MODE !== undefined) { + throw new Error("Official E2E does not accept E2E_EXECUTION_MODE."); + } + + if (environment.E2E_BASE_URL) { + throw new Error( + "Official E2E does not accept E2E_BASE_URL. Use the explicit test:e2e:external command." + ); + } +} + +/** Valida las invariantes del backend que arranca el modo E2E oficial. */ +export function assertOfficialE2EEnvironment(environment: Environment): void { + const expectedValues: Readonly> = { + NODE_ENV: "test", + DB_SYNC_MODE: "none", + PORT: E2E_BACKEND_PORT, + API_BASE_PATH: E2E_API_BASE_PATH, + FRONTEND_URL: E2E_FRONTEND_URL, + }; + + for (const [name, expectedValue] of Object.entries(expectedValues)) { + if (environment[name] !== expectedValue) { + throw new Error( + `Official E2E requires ${name}=${expectedValue}; received ${environment[name] ?? ""}.` + ); + } + } + + if (!new RegExp(`^${E2E_DATABASE_PREFIX}[a-z0-9_]+$`).test(environment.DB_NAME ?? "")) { + throw new Error(`Official E2E requires DB_NAME with prefix ${E2E_DATABASE_PREFIX}.`); + } +} + +/** Determina el modo de ejecución sin permitir destinos externos implícitos. */ +export function resolveE2EExecutionMode(environment: Environment): E2EExecutionMode { + const mode = environment.E2E_EXECUTION_MODE; + const externalBaseUrl = environment.E2E_BASE_URL; + + if (mode === "external") { + if (!externalBaseUrl) { + throw new Error("External E2E requires E2E_BASE_URL."); + } + + try { + new URL(externalBaseUrl); + } catch { + throw new Error("External E2E requires E2E_BASE_URL to be an absolute URL."); + } + + return "external"; + } + + throw new Error(`Unsupported E2E_EXECUTION_MODE=${mode ?? ""}.`); +} diff --git a/tests/e2e/support/e2e-database-environment.ts b/tests/e2e/support/e2e-database-environment.ts new file mode 100644 index 00000000..7365a69c --- /dev/null +++ b/tests/e2e/support/e2e-database-environment.ts @@ -0,0 +1,117 @@ +import { spawn } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; + +import { E2E_DATABASE_PREFIX } from "./e2e-contract"; + +const composeFile = "tests/e2e/docker-compose.yml"; + +export interface E2EDatabaseEnvironment { + readonly executionId: string; + readonly databaseName: string; + readonly projectName: string; + readonly containerId: string; + readonly host: string; + readonly port: number; + readonly username: string; + readonly password: string; + readonly environment: Record; + destroy(): Promise; +} + +function run(command: string, args: string[], environment: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { env: environment, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + let errorOutput = ""; + child.stdout.on("data", (chunk: Buffer) => (output += chunk)); + child.stderr.on("data", (chunk: Buffer) => (errorOutput += chunk)); + child.on("error", reject); + child.on("close", (code) => + code === 0 + ? resolve(output.trim()) + : reject(new Error(`${command} ${args.join(" ")}: ${errorOutput}`)) + ); + }); +} + +/** Provisiona un único proyecto Compose y elimina exclusivamente ese proyecto. */ +export async function createE2EDatabaseEnvironment(): Promise { + const runId = randomUUID().replaceAll("-", ""); + const databaseName = `${E2E_DATABASE_PREFIX}${runId}`; + const projectName = `factuges_e2e_${runId}`; + const user = "e2e"; + const password = randomBytes(24).toString("hex"); + const rootPassword = randomBytes(24).toString("hex"); + const composeEnvironment = { + ...process.env, + E2E_DB_NAME: databaseName, + E2E_DB_USER: user, + E2E_DB_PASS: password, + E2E_DB_ROOT_PASS: rootPassword, + }; + const composeArgs = ["compose", "--project-name", projectName, "--file", composeFile]; + + try { + await run("docker", [...composeArgs, "up", "--detach", "--wait"], composeEnvironment); + const containerId = await run( + "docker", + [...composeArgs, "ps", "--quiet", "db"], + composeEnvironment + ); + const portOutput = await run( + "docker", + [...composeArgs, "port", "db", "3306"], + composeEnvironment + ); + const port = portOutput.match(/:(\d+)$/)?.[1]; + if (!(containerId && port)) { + throw new Error("Compose did not return the E2E MariaDB container and port."); + } + + const environment = { + DB_HOST: "127.0.0.1", + DB_PORT: port, + DB_NAME: databaseName, + DB_USER: user, + DB_PASS: password, + }; + await run( + "docker", + [ + "exec", + containerId, + "mariadb", + `--user=${user}`, + `--password=${password}`, + databaseName, + "--execute=SELECT 1", + ], + composeEnvironment + ); + + return { + executionId: runId, + databaseName, + projectName, + containerId, + host: "127.0.0.1", + port: Number(port), + username: user, + password, + environment, + destroy: async () => + run( + "docker", + [...composeArgs, "down", "--volumes", "--remove-orphans"], + composeEnvironment + ).then(() => undefined), + }; + } catch (error) { + await run( + "docker", + [...composeArgs, "down", "--volumes", "--remove-orphans"], + composeEnvironment + ).catch(() => undefined); + throw error; + } +} diff --git a/tests/e2e/support/e2e-lifecycle.test.ts b/tests/e2e/support/e2e-lifecycle.test.ts new file mode 100644 index 00000000..06e0db0e --- /dev/null +++ b/tests/e2e/support/e2e-lifecycle.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runE2ELifecycle } from "./e2e-lifecycle.ts"; + +test("prepara antes de ejecutar el hijo y limpia tras éxito", async () => { + const events: string[] = []; + const result = await runE2ELifecycle({ + provision: async () => ({ + environment: { DB_NAME: "e2e" }, + destroy: async () => events.push("cleanup"), + }), + prepare: async () => { + events.push("prepare"); + }, + bootstrap: async () => { + events.push("bootstrap"); + }, + runChild: async (env) => { + events.push(env.DB_NAME); + return 0; + }, + }); + assert.equal(result, 0); + assert.deepEqual(events, ["prepare", "bootstrap", "e2e", "cleanup"]); +}); + +test("limpia tras código de salida no cero y conserva su código", async () => { + let cleaned = false; + const result = await runE2ELifecycle({ + provision: async () => ({ + environment: {}, + destroy: async () => { + cleaned = true; + }, + }), + prepare: async () => undefined, + bootstrap: async () => undefined, + runChild: async () => 3, + }); + assert.equal(result, 3); + assert.equal(cleaned, true); +}); + +test("no intenta limpiar si falla el provisionado", async () => { + await assert.rejects( + () => + runE2ELifecycle({ + provision: async () => { + throw new Error("provision"); + }, + prepare: async () => undefined, + bootstrap: async () => undefined, + runChild: async () => 0, + }), + /provision/ + ); +}); + +test("un fallo de cleanup no oculta una excepción del hijo", async () => { + await assert.rejects( + () => + runE2ELifecycle({ + provision: async () => ({ + environment: {}, + destroy: async () => { + throw new Error("cleanup"); + }, + }), + prepare: async () => undefined, + bootstrap: async () => undefined, + runChild: async () => { + throw new Error("child"); + }, + }), + (error: unknown) => + error instanceof AggregateError && + error.errors.some((value) => value instanceof Error && value.message === "child") && + error.errors.some((value) => value instanceof Error && value.message === "cleanup") + ); +}); + +test("un fallo de cleanup tras éxito hace fallar el lifecycle", async () => { + await assert.rejects( + () => + runE2ELifecycle({ + provision: async () => ({ + environment: {}, + destroy: async () => { + throw new Error("cleanup"); + }, + }), + prepare: async () => undefined, + bootstrap: async () => undefined, + runChild: async () => 0, + }), + /cleanup/ + ); +}); + +test("un fallo de preparación impide el hijo y mantiene cleanup", async () => { + const events: string[] = []; + + await assert.rejects( + () => + runE2ELifecycle({ + provision: async () => ({ + environment: {}, + destroy: async () => { + events.push("cleanup"); + }, + }), + prepare: async () => { + events.push("prepare"); + throw new Error("prepare"); + }, + bootstrap: async () => undefined, + runChild: async () => { + events.push("child"); + return 0; + }, + }), + /prepare/ + ); + + assert.deepEqual(events, ["prepare", "cleanup"]); +}); + +test("preserva el fallo de preparación y el de cleanup", async () => { + await assert.rejects( + () => + runE2ELifecycle({ + provision: async () => ({ + environment: {}, + destroy: async () => { + throw new Error("cleanup"); + }, + }), + prepare: async () => { + throw new Error("prepare"); + }, + bootstrap: async () => undefined, + runChild: async () => 0, + }), + (error: unknown) => + error instanceof AggregateError && + error.errors.some((value) => value instanceof Error && value.message === "prepare") && + error.errors.some((value) => value instanceof Error && value.message === "cleanup") + ); +}); + +test("un fallo de bootstrap impide el hijo y mantiene cleanup", async () => { + const events: string[] = []; + await assert.rejects( + () => + runE2ELifecycle({ + provision: async () => ({ environment: {}, destroy: async () => events.push("cleanup") }), + prepare: async () => events.push("prepare"), + bootstrap: async () => { + events.push("bootstrap"); + throw new Error("bootstrap"); + }, + runChild: async () => { + events.push("child"); + return 0; + }, + }), + /bootstrap/ + ); + assert.deepEqual(events, ["prepare", "bootstrap", "cleanup"]); +}); + +test("preserva el fallo de bootstrap y el de cleanup", async () => { + await assert.rejects( + () => + runE2ELifecycle({ + provision: async () => ({ + environment: {}, + destroy: async () => { + throw new Error("cleanup"); + }, + }), + prepare: async () => undefined, + bootstrap: async () => { + throw new Error("bootstrap"); + }, + runChild: async () => 0, + }), + (error: unknown) => + error instanceof AggregateError && + error.errors.some((value) => value instanceof Error && value.message === "bootstrap") && + error.errors.some((value) => value instanceof Error && value.message === "cleanup") + ); +}); diff --git a/tests/e2e/support/e2e-lifecycle.ts b/tests/e2e/support/e2e-lifecycle.ts new file mode 100644 index 00000000..c696ce97 --- /dev/null +++ b/tests/e2e/support/e2e-lifecycle.ts @@ -0,0 +1,37 @@ +export interface E2EEnvironment { + readonly environment: Record; + destroy(): Promise; +} + +export async function runE2ELifecycle(params: { + provision: () => Promise; + prepare: (environment: TEnvironment) => Promise; + bootstrap: (environment: TEnvironment) => Promise; + runChild: (environment: Record) => Promise; +}): Promise { + const environment = await params.provision(); + let mainFailure: Error | undefined; + let exitCode: number | undefined; + + try { + await params.prepare(environment); + await params.bootstrap(environment); + exitCode = await params.runChild(environment.environment); + } catch (error) { + mainFailure = error instanceof Error ? error : new Error(String(error)); + } + + try { + await environment.destroy(); + } catch (cleanupError) { + if (!mainFailure) { + throw cleanupError; + } + throw new AggregateError([mainFailure, cleanupError], "E2E execution and cleanup failed."); + } + + if (mainFailure) { + throw mainFailure; + } + return exitCode ?? 1; +} diff --git a/tests/e2e/support/e2e-schema-database-config.ts b/tests/e2e/support/e2e-schema-database-config.ts new file mode 100644 index 00000000..a6d95701 --- /dev/null +++ b/tests/e2e/support/e2e-schema-database-config.ts @@ -0,0 +1,17 @@ +import type { E2ESchemaDatabaseConfig } from "../../../apps/server/src/testing/e2e/e2e-schema-database-config"; + +import type { E2EDatabaseEnvironment } from "./e2e-database-environment"; + +/** Adapta el recurso ya creado por P2 al contrato backend-owned de preparación. */ +export function toE2ESchemaDatabaseConfig( + environment: E2EDatabaseEnvironment +): E2ESchemaDatabaseConfig { + return { + executionId: environment.executionId, + host: environment.host, + port: environment.port, + database: environment.databaseName, + username: environment.username, + password: environment.password, + }; +} diff --git a/tests/e2e/support/ensure-e2e-database.mjs b/tests/e2e/support/ensure-e2e-database.mjs deleted file mode 100644 index d6f1adfd..00000000 --- a/tests/e2e/support/ensure-e2e-database.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; - -const envFile = resolve("apps/server/.env.e2e"); -const env = Object.fromEntries( - readFileSync(envFile, "utf8") - .split(/\r?\n/) - .filter((line) => line && !line.startsWith("#")) - .map((line) => { - const separatorIndex = line.indexOf("="); - - return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)]; - }) -); - -if (env.DB_NAME !== "factuges_erp_e2e") { - throw new Error("E2E requires DB_NAME=factuges_erp_e2e in apps/server/.env.e2e."); -} - -const certificatesPath = env.COMPANY_CERTIFICATES_PATH; -if (!certificatesPath) { - throw new Error("E2E requires COMPANY_CERTIFICATES_PATH in apps/server/.env.e2e."); -} - -const certificatesFile = resolve("apps/server", certificatesPath); -mkdirSync(dirname(certificatesFile), { recursive: true }); - -if (!existsSync(certificatesFile)) { - writeFileSync(certificatesFile, "{}\n"); -} diff --git a/tests/e2e/support/ensure-e2e-database.ts b/tests/e2e/support/ensure-e2e-database.ts new file mode 100644 index 00000000..7653bc7d --- /dev/null +++ b/tests/e2e/support/ensure-e2e-database.ts @@ -0,0 +1,37 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { E2E_API_URL, assertOfficialE2EEnvironment } from "./e2e-contract"; + +function readEnvironment(path: string): Record { + return Object.fromEntries( + readFileSync(path, "utf8") + .split(/\r?\n/) + .filter((line) => line && !line.startsWith("#")) + .map((line) => { + const separatorIndex = line.indexOf("="); + + return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)]; + }) + ); +} + +const env = process.env; +assertOfficialE2EEnvironment(env); + +const webEnv = readEnvironment(resolve("apps/web/.env.e2e")); +if (webEnv.VITE_API_SERVER_URL !== E2E_API_URL) { + throw new Error(`Official E2E requires VITE_API_SERVER_URL=${E2E_API_URL} in apps/web/.env.e2e.`); +} + +const certificatesPath = env.COMPANY_CERTIFICATES_PATH; +if (!certificatesPath) { + throw new Error("E2E requires COMPANY_CERTIFICATES_PATH in apps/server/.env.e2e."); +} + +const certificatesFile = resolve("apps/server", certificatesPath); +mkdirSync(dirname(certificatesFile), { recursive: true }); + +if (!existsSync(certificatesFile)) { + writeFileSync(certificatesFile, "{}\n"); +} diff --git a/tests/e2e/support/run-e2e.ts b/tests/e2e/support/run-e2e.ts new file mode 100644 index 00000000..8bbedac3 --- /dev/null +++ b/tests/e2e/support/run-e2e.ts @@ -0,0 +1,64 @@ +import { spawn } from "node:child_process"; + +import { logger } from "../../../apps/server/src/lib/logger"; +import { bootstrapE2EData } from "../../../apps/server/src/testing/e2e/bootstrap-e2e-data"; +import { prepareE2ESchema } from "../../../apps/server/src/testing/e2e/prepare-e2e-schema"; + +import { createE2EDatabaseEnvironment } from "./e2e-database-environment"; +import { runE2ELifecycle } from "./e2e-lifecycle"; +import { toE2ESchemaDatabaseConfig } from "./e2e-schema-database-config"; + +async function run(): Promise { + let child: ReturnType | undefined; + const stop = () => child?.kill("SIGTERM"); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + try { + const args = ["node_modules/@playwright/test/cli.js", "test", ...process.argv.slice(2)]; + const exitCode = await runE2ELifecycle({ + provision: createE2EDatabaseEnvironment, + prepare: async (environment) => + await prepareE2ESchema(toE2ESchemaDatabaseConfig(environment)), + bootstrap: async (environment) => + await bootstrapE2EData( + toE2ESchemaDatabaseConfig(environment), + { + executionId: environment.executionId, + email: requiredEnvironment("E2E_USER_EMAIL"), + password: requiredEnvironment("E2E_USER_PASSWORD"), + companyName: "FactuGes E2E", + companySlug: "factuges-e2e", + }, + { logger } + ), + runChild: async (environment) => + new Promise((resolve, reject) => { + child = spawn(process.execPath, args, { + env: { ...process.env, ...environment }, + stdio: "inherit", + }); + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); + }), + }); + if (exitCode !== 0) { + process.exitCode = exitCode; + } + } finally { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + } +} + +function requiredEnvironment(name: "E2E_USER_EMAIL" | "E2E_USER_PASSWORD"): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Official E2E requires ${name}.`); + } + return value; +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/e2e/support/smoke-e2e-bootstrap.ts b/tests/e2e/support/smoke-e2e-bootstrap.ts new file mode 100644 index 00000000..1e6656b9 --- /dev/null +++ b/tests/e2e/support/smoke-e2e-bootstrap.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; + +import mysql from "../../../apps/server/node_modules/mysql2/promise.js"; +import { logger } from "../../../apps/server/src/lib/logger"; +import { bootstrapE2EData } from "../../../apps/server/src/testing/e2e/bootstrap-e2e-data"; +import { prepareE2ESchema } from "../../../apps/server/src/testing/e2e/prepare-e2e-schema"; +import { BcryptPasswordHasher } from "../../../modules/identity/src/api/infrastructure/index.js"; + +import { createE2EDatabaseEnvironment } from "./e2e-database-environment"; +import { toE2ESchemaDatabaseConfig } from "./e2e-schema-database-config"; + +async function run(): Promise { +const email = required("E2E_USER_EMAIL"); +const password = required("E2E_USER_PASSWORD"); +const environment = await createE2EDatabaseEnvironment(); +try { + const databaseConfig = toE2ESchemaDatabaseConfig(environment); + await prepareE2ESchema(databaseConfig); + const result = await bootstrapE2EData( + databaseConfig, + { + executionId: environment.executionId, + email, + password, + companyName: "FactuGes E2E", + companySlug: "factuges-e2e", + }, + { logger } + ); + const connection = await mysql.createConnection({ + host: environment.host, + port: environment.port, + user: environment.username, + password: environment.password, + database: environment.databaseName, + }); + try { + const [accounts] = await connection.query( + "SELECT id, password_hash FROM identity_accounts" + ); + const [companies] = await connection.query( + "SELECT id FROM companies WHERE slug = 'factuges-e2e'" + ); + assert.equal(accounts.length, 1); + assert.equal(companies.length, 1); + assert.equal(accounts[0].id, result.accountId); + assert.equal(companies[0].id, result.companyId); + assert.notEqual(accounts[0].password_hash, password); + const verified = await new BcryptPasswordHasher({ rounds: 10 }).verify({ + plainPassword: password, + passwordHash: { toPrimitive: () => accounts[0].password_hash } as never, + }); + assert.equal(verified.data, true); + for (const table of [ + "identity_company_memberships", + "identity_roles", + "identity_company_membership_roles", + "initial_onboardings", + ]) { + const [rows] = await connection.query(`SELECT COUNT(*) AS count FROM ${table}`); + assert.equal(rows[0].count, 1); + } + for (const table of [ + "customers", + "suppliers", + "proformas", + "issued_invoices", + "recurring_proformas", + "document_series", + "tax_definitions", + "tax_regimes", + "payment_methods", + "payment_terms", + ]) { + const [rows] = await connection.query(`SELECT COUNT(*) AS count FROM ${table}`); + assert.equal(rows[0].count, 0); + } + console.log("P4.2a smoke passed"); + } finally { + await connection.end(); + } +} finally { + await environment.destroy(); +} +} +run().catch((error: unknown) => { console.error(error); process.exitCode = 1; }); +function required(name: "E2E_USER_EMAIL" | "E2E_USER_PASSWORD"): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing ${name}`); + } + return value; +}