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) => 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 }[] = [] const c = client(async (input, init) => { seen.push({ url: String(input), headers: init?.headers as Record }) 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 = {}) { 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) }) })