/** * AI Software Factory — Integration test containers (shared singleton). * * Starts Testcontainers-managed PostgreSQL 16 and Redis 7 ONCE per vitest * process (module-level promise dedup). All integration specs in a single * `vitest run` share these containers; data-level isolation is the spec's * responsibility (each spec creates its own workspaces/users via the API). * * Env contract (kept in sync with .env.example): * DATABASE_URL postgresql://factory:factory@127.0.0.1:/factory * REDIS_URL redis://127.0.0.1: * * The Prisma schema is applied by helpers/api.ts (prisma migrate deploy) * AFTER the containers are up — see createTestApp(). * * Call `stopAllContainers()` from your spec's `afterAll` (or rely on * Testcontainers' reaper at process exit) to release the ports. * * Recorded fix (2026-09-26, TASK-0091): the Node Testcontainers library is * the SINGLE `testcontainers` npm package — the Java-style * `@testcontainers/postgres` / `@testcontainers/redis` packages DO NOT exist * on npm (404), so the scaffold's dependency spec could never have * installed. `GenericContainer` with the image + env + healthcheck recipe * (the library's documented module-less postgres recipe) replaces them; * this file's exported API is unchanged. */ import { GenericContainer, type Container } from "testcontainers"; const PG_USER = "factory"; const PG_PASSWORD = "factory"; const PG_DB = "factory"; const PG_PORT = 5432; const REDIS_PORT = 6379; let postgresPromise: Promise | null = null; let redisPromise: Promise | null = null; let opensearchPromise: Promise | null = null; const OPENSEARCH_PORT = 9200; /** * Shared Postgres 16 container. `POSTGRES_DB=factory` creates the database * on first boot; migrations then run against it via prisma migrate deploy. * Readiness is `pg_isready` (the TCP port opens before auth is served). */ export function getPostgres(): Promise { if (!postgresPromise) { postgresPromise = new GenericContainer("postgres:16-alpine") .withEnvironment({ POSTGRES_USER: PG_USER, POSTGRES_PASSWORD: PG_PASSWORD, POSTGRES_DB: PG_DB, }) .withExposedPorts(PG_PORT) .withHealthCheck({ test: ["CMD-SHELL", `pg_isready -U ${PG_USER} -d ${PG_DB}`], interval: 2_000, timeout: 3_000, retries: 30, }) .start() .catch((err: unknown) => { postgresPromise = null; // allow a clean retry on the next call throw err; }); } return postgresPromise; } /** Shared Redis 7 container (BullMQ queues, rate limiting, sessions). */ export function getRedis(): Promise { if (!redisPromise) { redisPromise = new GenericContainer("redis:7-alpine") .withExposedPorts(REDIS_PORT) .start() .catch((err: unknown) => { redisPromise = null; // allow a clean retry on the next call throw err; }); } return redisPromise; } /** * Shared OpenSearch 2 container (search-index integration, TASK-0096). * Single-node, security disabled (CI parity: `plugins.security.disabled`). * OpenSearch is SLOW to boot on constrained runners (30–90 s), so the * healthcheck gets a long retry budget; `getOpenSearchUrl()` also waits on * `/_cluster/health` over HTTP before resolving. */ export function getOpenSearch(): Promise { if (!opensearchPromise) { opensearchPromise = new GenericContainer("opensearchproject/opensearch:2") .withEnvironment({ "discovery.type": "single-node", "plugins.security.disabled": "true", OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m", }) .withExposedPorts(OPENSEARCH_PORT) .withHealthCheck({ test: ["CMD-SHELL", `curl -sf http://localhost:${OPENSEARCH_PORT}/_cluster/health || exit 1`], interval: 5_000, timeout: 10_000, retries: 60, }) .start() .catch((err: unknown) => { opensearchPromise = null; // allow a clean retry on the next call throw err; }); } return opensearchPromise; } /** The container host (v9 `getHost()` is a promise, v10 a string — `await` * is safe for both). */ async function containerHost(c: Container): Promise { return await c.getHost(); } /** Postgres connection string for DATABASE_URL (postgresql:// form). */ export async function getDatabaseUrl(): Promise { const pg = await getPostgres(); const host = await containerHost(pg); const port = await pg.getMappedPort(PG_PORT); return `postgresql://${PG_USER}:${PG_PASSWORD}@${host}:${port}/${PG_DB}`; } /** Redis connection string for REDIS_URL. */ export async function getRedisUrl(): Promise { const redis = await getRedis(); const host = await containerHost(redis); const port = await redis.getMappedPort(REDIS_PORT); return `redis://${host}:${port}`; } /** OpenSearch base URL for SEARCH_URL (http://host:9200). */ export async function getOpenSearchUrl(): Promise { const os = await getOpenSearch(); const host = await containerHost(os); const port = await os.getMappedPort(OPENSEARCH_PORT); return `http://${host}:${port}`; } /** * Stop every shared container (idempotent; safe to call more than once and * from multiple specs' afterAll hooks). */ export async function stopAllContainers(): Promise { const pg = postgresPromise ? await postgresPromise.catch(() => null) : null; const r = redisPromise ? await redisPromise.catch(() => null) : null; const os = opensearchPromise ? await opensearchPromise.catch(() => null) : null; postgresPromise = null; redisPromise = null; opensearchPromise = null; await pg?.stop().catch(() => {}); await r?.stop().catch(() => {}); await os?.stop().catch(() => {}); }