/** * E2E transaction lifecycle (gap-analysis v2 §7.8 / §10.8). * * Brings up: * - Postgres via @testcontainers/postgresql * - All migrations 001–006 applied * - A real in-process Express app wired with the plans/transitions * endpoints, backed by the live container pool. * * Skipped unless RUN_E2E=1 and Docker is reachable. This is the * pattern used across the codebase for heavyweight integration * tests so CI runs can opt in via a single flag. * * NB: Chain-138 RPC, SWIFT gateway, and Redis are all mocked-local * by default. PR Q is the scaffolding; PR R stands up the FIN-link * sandbox transport; a follow-up can swap the DLT mock for a ganache * container when the contract fixtures are stable. */ import { describe, it, expect, beforeAll, afterAll } from "@jest/globals"; import express from "express"; import request from "supertest"; const shouldRun = process.env.RUN_E2E === "1"; // Use describe.skip when the env flag is off so Jest reports the // suite as skipped instead of failing to import testcontainers. const d = shouldRun ? describe : describe.skip; d("E2E transaction lifecycle (Postgres testcontainer)", () => { let pgContainer: unknown; let connectionString = ""; let app: express.Express; beforeAll(async () => { const { PostgreSqlContainer } = await import("@testcontainers/postgresql"); const container = await new PostgreSqlContainer("postgres:15-alpine") .withDatabase("ccflow_e2e") .withUsername("ccflow") .withPassword("ccflow") .start(); pgContainer = container; connectionString = container.getConnectionUri(); process.env.DATABASE_URL = connectionString; process.env.SESSION_SECRET = "e2e-session-secret-must-be-at-least-32-chars-long!"; process.env.NODE_ENV = "test"; // Import after env set so migrations/pool read the container URL. const { getPool, query } = await import("../../src/db/postgres"); await query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`); // schema.sql contains $$...$$ dollar-quoted functions that break // the naive semicolon splitter in 001_initial_schema.ts. Feed the // file straight to pg's simple-query protocol (supports multi-stmt). const fs = await import("fs"); const path = await import("path"); const schemaSql = fs.readFileSync( path.join(__dirname, "../../src/db/schema.sql"), "utf-8", ); const pool = getPool(); const client = await pool.connect(); try { await client.query(schemaSql); } finally { client.release(); } // Run the numbered migrations after schema.sql. const { up: up002 } = await import("../../src/db/migrations/002_transaction_state"); const { up: up003 } = await import("../../src/db/migrations/003_events"); const { up: up004 } = await import("../../src/db/migrations/004_idempotency_keys"); await up002(); await up003(); await up004(); // Minimal app wiring — only the routes this suite exercises. const { createPlan, getPlan } = await import("../../src/api/plans"); app = express(); app.use(express.json()); app.post("/api/plans", createPlan); app.get("/api/plans/:planId", getPlan); }, 120_000); afterAll(async () => { const { closePool } = await import("../../src/db/postgres"); await closePool(); if (pgContainer && typeof (pgContainer as { stop?: () => Promise }).stop === "function") { await (pgContainer as { stop: () => Promise }).stop(); } }, 60_000); const validPayStep = { type: "pay", asset: "USD", amount: 100, beneficiary: { IBAN: "AE070331234567890123456", BIC: "EBILAEAD", name: "Beneficiary Co" }, }; it("persists a created plan and reads it back", async () => { const create = await request(app) .post("/api/plans") .send({ creator: "0xtest-creator", steps: [validPayStep], }) .expect(201); expect(create.body.plan_id).toBeDefined(); expect(create.body.plan_hash).toMatch(/^[0-9a-fA-F]{64}$/); const read = await request(app) .get(`/api/plans/${create.body.plan_id}`) .expect(200); expect(read.body.plan_id).toBe(create.body.plan_id); }, 30_000); it("publishes a signed event row via the live event bus", async () => { const create = await request(app) .post("/api/plans") .send({ creator: "0xtest-creator-2", steps: [validPayStep], }) .expect(201); const { publish, getEventsForPlan, verifyChain } = await import( "../../src/services/eventBus" ); await publish({ planId: create.body.plan_id, type: "transaction.created", actor: "e2e", payload: { plan_hash: create.body.plan_hash }, }); await publish({ planId: create.body.plan_id, type: "transaction.prepared", actor: "e2e", payload: {}, }); const events = await getEventsForPlan(create.body.plan_id); expect(events).toHaveLength(2); expect(events[0].prev_hash).toBeNull(); expect(events[1].prev_hash).toBe(events[0].signature); const chain = await verifyChain(create.body.plan_id); expect(chain.ok).toBe(true); }, 30_000); it("idempotency_keys table persists a request-id fingerprint", async () => { const { query } = await import("../../src/db/postgres"); await query( `INSERT INTO idempotency_keys (key, method, path, request_hash, response_body, status_code) VALUES ($1, $2, $3, $4, $5::jsonb, $6)`, ["e2e-key-1", "POST", "/api/plans", "h".repeat(64), JSON.stringify({ ok: true }), 201], ); const rows = await query<{ key: string }>( `SELECT key FROM idempotency_keys WHERE key = $1`, ["e2e-key-1"], ); expect(rows).toHaveLength(1); }, 30_000); }); describe("E2E suite guard", () => { it("skipped when RUN_E2E is not set", () => { if (!shouldRun) { expect(shouldRun).toBe(false); return; } expect(true).toBe(true); }); });