import { tmpdir } from "node:os" import { join } from "node:path" import { Writable } from "node:stream" import { sql } from "drizzle-orm" import { buildApp, type App } from "../src/app.js" import { dbEnvSchema, loadConfig, parseEnv, type Config } from "../src/config/env.js" import { createDb } from "../src/db/client.js" import { users } from "../src/db/schema/index.js" import { MemoryEmailService } from "../src/services/email.js" import { hashPassword } from "../src/services/password.js" import { MedusaMock } from "./medusa-mock.js" /** * URL del DB di test: TEST_DATABASE_URL oppure DATABASE_URL con suffisso "_test" sul nome del DB. * Rifiuta qualunque database il cui nome non finisca con "_test" (i test lo svuotano). */ export function testDatabaseUrl(): string { const explicit = process.env.TEST_DATABASE_URL const url = new URL(explicit ?? parseEnv(dbEnvSchema).DATABASE_URL) if (!explicit) url.pathname = `${url.pathname}_test` if (!url.pathname.endsWith("_test")) { throw new Error(`Il DB dei test deve terminare con "_test" (trovato ${url.pathname.slice(1)})`) } return url.toString() } /** Radice dello storage nei test (per processo). */ export const TEST_UPLOAD_DIR = join(tmpdir(), `iti-api-test-uploads-${process.pid}`) /** Config completa per i test: valori fissi, indipendenti dal .env (tranne il DB). */ export function testConfig(overrides: Record = {}): Config { return loadConfig({ NODE_ENV: "test", DATABASE_URL: testDatabaseUrl(), CORS_ORIGINS: "http://localhost:4200", COOKIE_SECRET: "c".repeat(32), JWT_SECRET: "j".repeat(32), MEDUSA_PUBLISHABLE_KEY: "pk_test", INTERNAL_API_SECRET: "i".repeat(32), WEBHOOK_SECRET: "w".repeat(32), PRIVATE_APP_URL: "http://localhost:4300", AUTH_LOGIN_RATE_MAX: "1000", AUTH_EMAIL_RATE_MAX: "1000", RATE_LIMIT_MAX: "10000", // Storage dei test in una cartella temporanea (mai la uploads/ di sviluppo) UPLOAD_DIR: TEST_UPLOAD_DIR, PUBLIC_UPLOADS_URL: "http://localhost:3000/uploads", ...overrides, }) } /** Stream che accumula le righe di log (per verificare che non contengano segreti). */ export class LogCapture extends Writable { readonly lines: string[] = [] override _write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) { this.lines.push(chunk.toString()) cb() } get text() { return this.lines.join("") } } export type TestApp = { app: App; email: MemoryEmailService; logs: LogCapture; medusa: MedusaMock } /** * App di test con email in memoria, log catturati e Medusa mockato (nessuna chiamata di rete); * `setup` aggiunge route di prova prima di ready(). */ export async function buildTestApp( overrides: Record = {}, setup?: (app: App) => void, ): Promise { const email = new MemoryEmailService() const logs = new LogCapture() const config = testConfig(overrides) const medusa = new MedusaMock(config.INTERNAL_API_SECRET) const app = await buildApp(config, { email, medusaFetch: medusa.fetch, logger: { level: "trace", stream: logs } }) setup?.(app) await app.ready() return { app, email, logs, medusa } } /** Svuota tutte le tabelle applicative del DB di test (non la tabella delle migrazioni). */ export async function truncateAll(): Promise { const { db, close } = createDb(testDatabaseUrl(), { max: 1 }) try { await db.execute(sql` DO $$ DECLARE t text; BEGIN SELECT string_agg(format('%I', tablename), ', ') INTO t FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'spatial_ref_sys'; IF t IS NOT NULL THEN EXECUTE 'TRUNCATE ' || t || ' RESTART IDENTITY CASCADE'; END IF; END $$`) } finally { await close() } } /** Estrae il token da un link presente nel testo di un'email (?token=...). */ export function tokenFromEmail(text: string | undefined): string { const match = text?.match(/[?&]token=([^\s&]+)/) if (!match?.[1]) throw new Error("token non trovato nell'email") return decodeURIComponent(match[1]) } /** Valore del cookie indicato dalla risposta (header set-cookie). */ export function cookieFrom(setCookie: string | string[] | undefined, name: string): string | undefined { const all = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [] const raw = all.find((c) => c.startsWith(`${name}=`)) return raw?.split(";")[0]?.slice(name.length + 1) } export const TEST_PASSWORD = "Password-di-test-1" /** Crea un utente (email verificata salvo indicazione) e fa login: restituisce utente e header Authorization. */ export async function createUserAndLogin( app: App, email: string, role: "customer" | "company" | "installer" | "admin" = "customer", extra: Partial = {}, ) { const [user] = await app.db .insert(users) .values({ email, passwordHash: await hashPassword(TEST_PASSWORD), role, firstName: "Nome", lastName: "Cognome", emailVerifiedAt: new Date(), ...extra, }) .returning() const res = await app.inject({ method: "POST", url: "/auth/login", payload: { email, password: TEST_PASSWORD } }) if (res.statusCode !== 200) throw new Error(`login fallito per ${email}: ${res.body}`) return { user: user!, headers: { authorization: `Bearer ${res.json().accessToken as string}` } } } /** Body multipart/form-data per app.inject: campi testuali e file ({ filename, contentType, content }). */ export function multipartBody( fields: Record, files: { field?: string; filename: string; contentType: string; content: Buffer | string }[], ) { const boundary = `----iti${Math.random().toString(16).slice(2)}` const CRLF = "\r\n" const chunks: Buffer[] = [] for (const [name, value] of Object.entries(fields)) { chunks.push(Buffer.from(`--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}`)) } for (const f of files) { chunks.push( Buffer.from( `--${boundary}${CRLF}Content-Disposition: form-data; name="${f.field ?? "file"}"; filename="${f.filename}"${CRLF}` + `Content-Type: ${f.contentType}${CRLF}${CRLF}`, ), Buffer.isBuffer(f.content) ? f.content : Buffer.from(f.content), Buffer.from(CRLF), ) } chunks.push(Buffer.from(`--${boundary}--${CRLF}`)) return { payload: Buffer.concat(chunks), headers: { "content-type": `multipart/form-data; boundary=${boundary}` } } }