Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
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
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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}` } }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user