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:
parent
73ab42cc16
commit
da362fef64
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
9
apps/server/src/server-module-composition.ts
Normal file
9
apps/server/src/server-module-composition.ts
Normal 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");
|
||||
}
|
||||
192
apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts
Normal file
192
apps/server/src/testing/e2e/bootstrap-e2e-data.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
133
apps/server/src/testing/e2e/bootstrap-e2e-data.ts
Normal file
133
apps/server/src/testing/e2e/bootstrap-e2e-data.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
41
apps/server/src/testing/e2e/e2e-schema-database-config.ts
Normal file
41
apps/server/src/testing/e2e/e2e-schema-database-config.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
178
apps/server/src/testing/e2e/prepare-e2e-schema.test.ts
Normal file
178
apps/server/src/testing/e2e/prepare-e2e-schema.test.ts
Normal 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;
|
||||
}
|
||||
124
apps/server/src/testing/e2e/prepare-e2e-schema.ts
Normal file
124
apps/server/src/testing/e2e/prepare-e2e-schema.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
85
modules/core/src/api/modules/module-runtime.test.ts
Normal file
85
modules/core/src/api/modules/module-runtime.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
17
package.json
17
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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
24
playwright.external.config.ts
Normal file
24
playwright.external.config.ts
Normal 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"] } }],
|
||||
});
|
||||
@ -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
|
||||
|
||||
|
||||
15
tests/e2e/docker-compose.yml
Normal file
15
tests/e2e/docker-compose.yml
Normal 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
|
||||
1
tests/e2e/fixtures/company-certificates.json
Normal file
1
tests/e2e/fixtures/company-certificates.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
80
tests/e2e/support/e2e-contract.test.ts
Normal file
80
tests/e2e/support/e2e-contract.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
67
tests/e2e/support/e2e-contract.ts
Normal file
67
tests/e2e/support/e2e-contract.ts
Normal 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>"}.`);
|
||||
}
|
||||
117
tests/e2e/support/e2e-database-environment.ts
Normal file
117
tests/e2e/support/e2e-database-environment.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
194
tests/e2e/support/e2e-lifecycle.test.ts
Normal file
194
tests/e2e/support/e2e-lifecycle.test.ts
Normal 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")
|
||||
);
|
||||
});
|
||||
37
tests/e2e/support/e2e-lifecycle.ts
Normal file
37
tests/e2e/support/e2e-lifecycle.ts
Normal 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;
|
||||
}
|
||||
17
tests/e2e/support/e2e-schema-database-config.ts
Normal file
17
tests/e2e/support/e2e-schema-database-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
37
tests/e2e/support/ensure-e2e-database.ts
Normal file
37
tests/e2e/support/ensure-e2e-database.ts
Normal 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");
|
||||
}
|
||||
64
tests/e2e/support/run-e2e.ts
Normal file
64
tests/e2e/support/run-e2e.ts
Normal 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;
|
||||
});
|
||||
93
tests/e2e/support/smoke-e2e-bootstrap.ts
Normal file
93
tests/e2e/support/smoke-e2e-bootstrap.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user