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,245 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { installerProfiles, users } from "../src/db/schema/index.js"
|
||||
import { type FetchFn, MedusaClient, MedusaError } from "../src/services/medusa-client.js"
|
||||
import { ensureMedusaCustomer, syncInstallerGroup } from "../src/services/medusa-sync.js"
|
||||
import { hashPassword } from "../src/services/password.js"
|
||||
import { buildTestApp, type TestApp, tokenFromEmail, truncateAll } from "./helpers.js"
|
||||
|
||||
const PASSWORD = "Password-di-test-1"
|
||||
|
||||
let t: TestApp
|
||||
const db = () => t.app.db
|
||||
|
||||
const register = (body: Record<string, unknown>) =>
|
||||
t.app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { password: PASSWORD, firstName: "Mario", lastName: "Rossi", acceptPrivacy: true, ...body },
|
||||
})
|
||||
|
||||
const login = (email: string) => t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
|
||||
|
||||
const userByEmail = async (email: string) => (await db().select().from(users).where(eq(users.email, email)))[0]!
|
||||
|
||||
/** Registra e conferma l'email con il link ricevuto. */
|
||||
async function verifyEmailOf(to: string) {
|
||||
const mail = t.email.sent.filter((m) => m.to === to && m.tag === "verify-email").at(-1)
|
||||
const res = await t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token: tokenFromEmail(mail?.text) } })
|
||||
expect(res.statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp({}, (app) => {
|
||||
app.get("/__test/medusa-down", async () => {
|
||||
throw new MedusaError("unavailable", undefined, undefined, "Medusa non raggiungibile (GET /segreto)")
|
||||
})
|
||||
})
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.email.clear()
|
||||
t.medusa.reset()
|
||||
})
|
||||
|
||||
describe("client Medusa", () => {
|
||||
const ok = z.object({ ok: z.boolean() })
|
||||
const client = (fetch: FetchFn, timeoutMs = 1000) =>
|
||||
new MedusaClient({ baseUrl: "http://medusa.test/", publishableKey: "pk_x", internalSecret: "s".repeat(32), timeoutMs, fetch })
|
||||
|
||||
it("usa la publishable key per la Store API e il segreto per le route interne", async () => {
|
||||
const seen: { url: string; headers: Record<string, string> }[] = []
|
||||
const c = client(async (input, init) => {
|
||||
seen.push({ url: String(input), headers: init?.headers as Record<string, string> })
|
||||
return Response.json({ ok: true })
|
||||
})
|
||||
await c.store.request("/store/products", ok, { query: { limit: 5, q: undefined } })
|
||||
await c.internal.request("/internal/x", ok, { body: { a: 1 } })
|
||||
|
||||
expect(seen[0]!.url).toBe("http://medusa.test/store/products?limit=5")
|
||||
expect(seen[0]!.headers["x-publishable-api-key"]).toBe("pk_x")
|
||||
expect(seen[0]!.headers["x-internal-secret"]).toBeUndefined()
|
||||
expect(seen[1]!.headers["x-internal-secret"]).toBe("s".repeat(32))
|
||||
expect(seen[1]!.headers["x-publishable-api-key"]).toBeUndefined()
|
||||
expect(seen[1]!.headers["content-type"]).toBe("application/json")
|
||||
})
|
||||
|
||||
it("errori uniformi: rete, timeout, 5xx → unavailable; 4xx → rejected; formato errato → invalid_response", async () => {
|
||||
const fails = async (fetch: FetchFn, timeoutMs?: number) => {
|
||||
const err = await client(fetch, timeoutMs).store.request("/store/x", ok).catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(MedusaError)
|
||||
return err as MedusaError
|
||||
}
|
||||
|
||||
expect((await fails(async () => { throw new TypeError("fetch failed") })).kind).toBe("unavailable")
|
||||
|
||||
const hanging: FetchFn = (_input, init) =>
|
||||
new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason)))
|
||||
const timeout = await fails(hanging, 50)
|
||||
expect(timeout.kind).toBe("unavailable")
|
||||
expect(timeout.message).toContain("timeout")
|
||||
|
||||
const e500 = await fails(async () => Response.json({ type: "unknown_error", message: "boom" }, { status: 500 }))
|
||||
expect([e500.kind, e500.status]).toEqual(["unavailable", 500])
|
||||
|
||||
const e404 = await fails(async () => Response.json({ type: "not_found", message: "Customer non trovato" }, { status: 404 }))
|
||||
expect([e404.kind, e404.status, e404.type]).toEqual(["rejected", 404, "not_found"])
|
||||
|
||||
expect((await fails(async () => Response.json({ altro: 1 }))).kind).toBe("invalid_response")
|
||||
})
|
||||
|
||||
it("un MedusaError non gestito dalla route diventa 503 senza dettagli interni", async () => {
|
||||
const res = await t.app.inject({ method: "GET", url: "/__test/medusa-down" })
|
||||
expect(res.statusCode).toBe(503)
|
||||
expect(res.json().error.code).toBe("SHOP_UNAVAILABLE")
|
||||
expect(res.body).not.toContain("segreto")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sincronizzazione account", () => {
|
||||
it("la registrazione crea il customer Medusa e ne salva l'id", async () => {
|
||||
const res = await register({ email: "Mario@Example.com", accountType: "company", companyName: "Rossi Srl", vatNumber: "IT01234567890" })
|
||||
expect(res.statusCode).toBe(202)
|
||||
await t.app.flushSyncs()
|
||||
|
||||
const customer = t.medusa.accountByEmail("mario@example.com")
|
||||
expect(customer).toMatchObject({ first_name: "Mario", last_name: "Rossi", company_name: "Rossi Srl" })
|
||||
expect((await userByEmail("mario@example.com")).medusaCustomerId).toBe(customer!.id)
|
||||
expect(t.medusa.calls[0]!.headers["x-internal-secret"]).toBe(t.app.config.INTERNAL_API_SECRET)
|
||||
})
|
||||
|
||||
it("con Medusa spento la registrazione riesce e il customer viene recuperato al login", async () => {
|
||||
t.medusa.down = true
|
||||
expect((await register({ email: "spento@example.com" })).statusCode).toBe(202)
|
||||
await t.app.flushSyncs()
|
||||
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBeNull()
|
||||
expect(t.logs.text).toContain("sincronizzazione Medusa non riuscita")
|
||||
|
||||
// Ancora spento: il login non fallisce
|
||||
expect((await login("spento@example.com")).statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBeNull()
|
||||
|
||||
t.medusa.down = false
|
||||
expect((await login("spento@example.com")).statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
const customer = t.medusa.accountByEmail("spento@example.com")
|
||||
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBe(customer!.id)
|
||||
})
|
||||
|
||||
it("ensureMedusaCustomer è idempotente e non richiama Medusa se l'id c'è già", async () => {
|
||||
await register({ email: "idem@example.com" })
|
||||
await t.app.flushSyncs()
|
||||
const user = await userByEmail("idem@example.com")
|
||||
const calls = t.medusa.calls.length
|
||||
|
||||
expect(await ensureMedusaCustomer(t.app, user)).toBe(user.medusaCustomerId)
|
||||
expect(t.medusa.calls.length).toBe(calls)
|
||||
|
||||
// Id perso in DB: l'upsert per email restituisce lo stesso customer
|
||||
await db().update(users).set({ medusaCustomerId: null }).where(eq(users.id, user.id))
|
||||
expect(await ensureMedusaCustomer(t.app, { ...user, medusaCustomerId: null })).toBe(user.medusaCustomerId)
|
||||
expect(t.medusa.customers.size).toBe(1)
|
||||
})
|
||||
|
||||
it("ensureMedusaCustomer propaga l'errore se Medusa non risponde (per le operazioni shop)", async () => {
|
||||
await register({ email: "shop@example.com" })
|
||||
await t.app.flushSyncs()
|
||||
const user = { ...(await userByEmail("shop@example.com")), medusaCustomerId: null }
|
||||
t.medusa.down = true
|
||||
await expect(ensureMedusaCustomer(t.app, user)).rejects.toMatchObject({ kind: "unavailable" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("ordini guest", () => {
|
||||
it("un ordine guest precedente viene collegato solo dopo la verifica dell'email", async () => {
|
||||
const orderId = t.medusa.addGuestOrder("cliente@example.com")
|
||||
const other = t.medusa.addGuestOrder("altro@example.com")
|
||||
|
||||
await register({ email: "Cliente@Example.com" })
|
||||
await t.app.flushSyncs()
|
||||
const customerId = (await userByEmail("cliente@example.com")).medusaCustomerId!
|
||||
|
||||
// Email non verificata: il login non fa il claim
|
||||
await login("cliente@example.com")
|
||||
await t.app.flushSyncs()
|
||||
expect(t.medusa.ordersOf(customerId)).toEqual([])
|
||||
expect(t.medusa.calledPaths()).not.toContain("POST /internal/orders/claim")
|
||||
|
||||
await verifyEmailOf("cliente@example.com")
|
||||
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
|
||||
expect(t.medusa.ordersOf(customerId)).not.toContain(other)
|
||||
const claim = t.medusa.calls.find((c) => c.path === "/internal/orders/claim")
|
||||
expect(claim?.body).toEqual({ customer_id: customerId, email: "cliente@example.com" })
|
||||
})
|
||||
|
||||
it("se Medusa è spento alla verifica, il claim viene recuperato al login successivo", async () => {
|
||||
const orderId = t.medusa.addGuestOrder("tardi@example.com")
|
||||
t.medusa.down = true
|
||||
await register({ email: "tardi@example.com" })
|
||||
await verifyEmailOf("tardi@example.com")
|
||||
expect((await userByEmail("tardi@example.com")).medusaCustomerId).toBeNull()
|
||||
|
||||
t.medusa.down = false
|
||||
await login("tardi@example.com")
|
||||
await t.app.flushSyncs()
|
||||
const customerId = (await userByEmail("tardi@example.com")).medusaCustomerId!
|
||||
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
|
||||
})
|
||||
|
||||
it("il reset password (che verifica l'email) collega gli ordini guest", async () => {
|
||||
const orderId = t.medusa.addGuestOrder("reset@example.com")
|
||||
await register({ email: "reset@example.com" })
|
||||
await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "reset@example.com" } })
|
||||
const res = await t.app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/reset-password",
|
||||
payload: { token: tokenFromEmail(t.email.last("reset-password")?.text), password: "Nuova-password-1" },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
const customerId = (await userByEmail("reset@example.com")).medusaCustomerId!
|
||||
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncInstallerGroup", () => {
|
||||
async function installer(status: "pending" | "approved" | "rejected", extra: Partial<typeof users.$inferInsert> = {}) {
|
||||
const [u] = await db()
|
||||
.insert(users)
|
||||
.values({ email: "inst@example.com", passwordHash: await hashPassword(PASSWORD), role: "installer", firstName: "I", lastName: "N", ...extra })
|
||||
.returning()
|
||||
await db().insert(installerProfiles).values({ userId: u!.id, businessName: "Impianti Srl", status })
|
||||
return u!
|
||||
}
|
||||
|
||||
it("approvato → aggiunto al gruppo (creando il customer se manca); poi rifiutato → rimosso", async () => {
|
||||
const user = await installer("approved")
|
||||
expect(await syncInstallerGroup(t.app, user)).toEqual({ member: true })
|
||||
const customerId = (await userByEmail("inst@example.com")).medusaCustomerId!
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
|
||||
|
||||
await db().update(installerProfiles).set({ status: "rejected" }).where(eq(installerProfiles.userId, user.id))
|
||||
expect(await syncInstallerGroup(t.app, await userByEmail("inst@example.com"))).toEqual({ member: false })
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(false)
|
||||
})
|
||||
|
||||
it("in attesa o disabilitato → non membro", async () => {
|
||||
const user = await installer("pending")
|
||||
expect(await syncInstallerGroup(t.app, user)).toEqual({ member: false })
|
||||
|
||||
await db().update(installerProfiles).set({ status: "approved" }).where(eq(installerProfiles.userId, user.id))
|
||||
await db().update(users).set({ disabledAt: new Date() }).where(eq(users.id, user.id))
|
||||
expect(await syncInstallerGroup(t.app, await userByEmail("inst@example.com"))).toEqual({ member: false })
|
||||
expect(t.medusa.installerGroup.size).toBe(0)
|
||||
})
|
||||
|
||||
it("propaga l'errore se Medusa non risponde", async () => {
|
||||
const user = await installer("approved")
|
||||
t.medusa.failStatus = 500
|
||||
await expect(syncInstallerGroup(t.app, user)).rejects.toBeInstanceOf(MedusaError)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user