Files
iti-technology/apps/api/test/helpers.ts
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
2026-10-20 09:30:48 +02:00

164 lines
6.3 KiB
TypeScript

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<string, string> = {}): 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<string, string> = {},
setup?: (app: App) => void,
): Promise<TestApp> {
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<void> {
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<typeof users.$inferInsert> = {},
) {
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<string, string>,
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}` } }
}