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.
This commit is contained in:
David Arranz 2026-09-22 16:49:08 +02:00
parent 73ab42cc16
commit da362fef64
31 changed files with 1600 additions and 94 deletions

View File

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

View File

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

View File

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

View File

@ -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<string, unknown> }> = [];
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<void>) => callback({}),
query: async (sql: string, options: { replacements: Record<string, unknown> }) => {
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<void>) => {
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;
}
}

View File

@ -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<Sequelize>;
}
): Promise<E2EBootstrapResult> {
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<E2EBootstrapResult> {
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<string, unknown>) =>
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<Sequelize> {
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.");
}
}

View File

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

View File

@ -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<void>; close: () => Promise<void> };
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;
}

View File

@ -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<Sequelize>;
readonly registerComposition: () => void;
readonly initializeStructure: (database: Sequelize) => Promise<void>;
}
export async function prepareE2ESchema(
config: E2ESchemaDatabaseConfig,
dependencies: E2ESchemaPreparationDependencies = createDefaultDependencies()
): Promise<void> {
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,
},
};
}

View File

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

View File

@ -42,12 +42,22 @@ export async function initModules(
warmup: { timeoutMs: number; strict: boolean };
databaseSyncMode: string;
}
): Promise<ModulesRuntime> {
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<ModulesRuntime> {
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<void> {
async function initializeModels(database: Sequelize, logger: ILogger): Promise<void> {
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<void> {
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(

View File

@ -18,7 +18,7 @@ export const CustomerAdditionalConfigEditor = ({
return (
<RdxPanel
description={t("form_groups.preferences.description")}
icon={<Settings2Icon className="size-6" />}
icon={<Settings2Icon className="size-5" />}
title={t("form_groups.preferences.title")}
variant="card"
>

View File

@ -18,7 +18,7 @@ export const CustomerAddressEditor = ({
return (
<RdxPanel
description={t("form_groups.address.description")}
icon={<MapPinIcon className="size-6" />}
icon={<MapPinIcon className="size-5" />}
title={t("form_groups.address.title")}
variant="card"
>

View File

@ -34,7 +34,7 @@ export const CustomerBasicInfoEditor = ({
return (
<RdxPanel
description={t("form_groups.basic_info.description")}
icon={<FileTextIcon className="size-6" />}
icon={<FileTextIcon className="size-5" />}
title={t("form_groups.basic_info.title")}
variant="card"
>

View File

@ -25,7 +25,7 @@ export const CustomerContactEditor = ({
return (
<RdxPanel
description={t("form_groups.contact_info.description")}
icon={<PhoneIcon className="size-6" />}
icon={<PhoneIcon className="size-5" />}
title={t("form_groups.contact_info.title")}
variant="card"
>

View File

@ -29,7 +29,7 @@ export const CustomerFiscalEditor = ({
return (
<RdxPanel
description={t("form_groups.fiscal_info.description")}
icon={<LandmarkIcon className="size-6" />}
icon={<LandmarkIcon className="size-5" />}
title={t("form_groups.fiscal_info.title")}
variant="card"
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
{}

View File

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

View File

@ -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<string, string | undefined>;
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<Record<string, string>> = {
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] ?? "<missing>"}.`
);
}
}
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 ?? "<missing>"}.`);
}

View File

@ -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<string, string>;
destroy(): Promise<void>;
}
function run(command: string, args: string[], environment: NodeJS.ProcessEnv): Promise<string> {
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<E2EDatabaseEnvironment> {
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;
}
}

View File

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

View File

@ -0,0 +1,37 @@
export interface E2EEnvironment {
readonly environment: Record<string, string>;
destroy(): Promise<void>;
}
export async function runE2ELifecycle<TEnvironment extends E2EEnvironment>(params: {
provision: () => Promise<TEnvironment>;
prepare: (environment: TEnvironment) => Promise<void>;
bootstrap: (environment: TEnvironment) => Promise<void>;
runChild: (environment: Record<string, string>) => Promise<number>;
}): Promise<number> {
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;
}

View File

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

View File

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

View File

@ -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<string, string | undefined> {
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");
}

View File

@ -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<void> {
let child: ReturnType<typeof spawn> | 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<number>((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;
});

View File

@ -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<void> {
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<any[]>(
"SELECT id, password_hash FROM identity_accounts"
);
const [companies] = await connection.query<any[]>(
"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<any[]>(`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<any[]>(`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;
}