Deploy stack su Proxmox / deploy (push) Failing after 3s
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
164 lines
6.3 KiB
TypeScript
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}` } }
|
|
}
|
|
|