import { rm } from "node:fs/promises" import { eq } from "drizzle-orm" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { PROVINCES } from "../src/configurator/rules.js" import { installerProfiles, quoteRequests, users } from "../src/db/schema/index.js" import { PROVINCE_CENTROIDS } from "../src/services/geo.js" import { buildTestApp, createUserAndLogin, multipartBody, TEST_UPLOAD_DIR, type TestApp, truncateAll } from "./helpers.js" import { MOCK_PRODUCT, MOCK_VARIANT } from "./medusa-mock.js" const MONZA = { lat: 45.585, lng: 9.274 } let t: TestApp let admin: Record type Seed = { email: string; city: { lat: number; lng: number } | null; radius: number; status?: "pending" | "approved" | "rejected"; disabled?: boolean } async function seedInstaller(s: Seed) { const [u] = await t.app.db .insert(users) .values({ email: s.email, passwordHash: "x", role: "installer", firstName: "I", lastName: s.email, disabledAt: s.disabled ? new Date() : null, }) .returning() await t.app.db.insert(installerProfiles).values({ userId: u!.id, businessName: `Impianti ${s.email.split("@")[0]}`, location: s.city, serviceRadiusKm: s.radius, status: s.status ?? "approved", }) return u! } beforeAll(async () => { t = await buildTestApp() }) afterAll(async () => { await t.app.close() await rm(TEST_UPLOAD_DIR, { recursive: true, force: true }) }) beforeEach(async () => { await t.app.flushSyncs() await truncateAll() t.medusa.reset() t.email.clear() t.app.shop.clearCache() admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers }) describe("ricerca per zona (PostGIS)", () => { beforeEach(async () => { await seedInstaller({ email: "torino@example.com", city: { lat: 45.07, lng: 7.687 }, radius: 20 }) // ~135 km await seedInstaller({ email: "bergamo@example.com", city: { lat: 45.698, lng: 9.677 }, radius: 40 }) // ~34 km await seedInstaller({ email: "lecco@example.com", city: { lat: 45.856, lng: 9.398 }, radius: 10 }) // ~31 km, raggio piccolo await seedInstaller({ email: "milano@example.com", city: { lat: 45.464, lng: 9.19 }, radius: 50 }) // ~15 km await seedInstaller({ email: "attesa@example.com", city: MONZA, radius: 50, status: "pending" }) await seedInstaller({ email: "disabilitato@example.com", city: MONZA, radius: 50, disabled: true }) await seedInstaller({ email: "senzasede@example.com", city: null, radius: 300 }) }) it("solo gli installatori approvati entro il proprio raggio, in ordine di distanza", async () => { const res = await t.app.inject({ method: "GET", url: `/admin/installers/nearby?lat=${MONZA.lat}&lng=${MONZA.lng}`, headers: admin }) expect(res.statusCode).toBe(200) const list = res.json() as { email: string; distanceKm: number }[] expect(list.map((i) => i.email)).toEqual(["milano@example.com", "bergamo@example.com"]) expect(list[0]!.distanceKm).toBeGreaterThan(13) expect(list[0]!.distanceKm).toBeLessThan(17) expect(list[1]!.distanceKm).toBeGreaterThan(30) expect(list[1]!.distanceKm).toBeLessThan(37) }) it("da una richiesta di preventivo: provincia → capoluogo → installatori", async () => { const [r] = await t.app.db .insert(quoteRequests) .values({ email: "c@example.com", name: "C", province: "MB", configuratorInput: {} }) .returning() const res = await t.app.inject({ method: "GET", url: `/admin/quote-requests/${r!.id}/installers`, headers: admin }) expect(res.json()).toMatchObject({ province: "MB", point: PROVINCE_CENTROIDS.MB }) expect(res.json().installers.map((i: { email: string }) => i.email)).toEqual(["milano@example.com", "bergamo@example.com"]) const [noProv] = await t.app.db.insert(quoteRequests).values({ email: "d@example.com", name: "D", configuratorInput: {} }).returning() expect((await t.app.inject({ method: "GET", url: `/admin/quote-requests/${noProv!.id}/installers`, headers: admin })).json().error.code).toBe( "PROVINCE_MISSING", ) }) it("coordinate non valide o fuori dall'Italia → 400; solo admin", async () => { expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=48.85&lng=2.35", headers: admin })).statusCode).toBe(400) expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=abc&lng=9", headers: admin })).statusCode).toBe(400) const customer = (await createUserAndLogin(t.app, "c@example.com")).headers expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=45&lng=9", headers: customer })).statusCode).toBe(403) }) it("ogni provincia ha le coordinate del capoluogo, in Italia", () => { expect(PROVINCES.filter((p) => !PROVINCE_CENTROIDS[p.code])).toEqual([]) expect(Object.keys(PROVINCE_CENTROIDS)).toHaveLength(PROVINCES.length) }) }) describe("profilo installatore", () => { it("GET/PUT del proprio profilo; coordinate validate; stato e note non modificabili", async () => { const inst = await createUserAndLogin(t.app, "inst@example.com", "installer") await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl", notes: "nota interna" }) const got = await t.app.inject({ method: "GET", url: "/installer/profile", headers: inst.headers }) expect(got.json()).toMatchObject({ businessName: "Impianti Srl", status: "pending", location: null, serviceRadiusKm: 50 }) expect(got.body).not.toContain("nota interna") const body = { businessName: "Impianti Rossi Srl", vatNumber: "IT01234567890", phone: "0391234567", address: "Via Italia 1, Monza", location: MONZA, serviceRadiusKm: 35, certifications: ["FER", "F-GAS", "FER"], status: "approved", } const put = await t.app.inject({ method: "PUT", url: "/installer/profile", headers: inst.headers, payload: body }) expect(put.statusCode).toBe(200) expect(put.json()).toMatchObject({ businessName: "Impianti Rossi Srl", location: MONZA, serviceRadiusKm: 35, certifications: ["FER", "F-GAS"], status: "pending" }) for (const bad of [{ location: { lat: 48.85, lng: 2.35 } }, { location: { lat: 95, lng: 9 } }, { serviceRadiusKm: 0 }]) { expect((await t.app.inject({ method: "PUT", url: "/installer/profile", headers: inst.headers, payload: { ...body, ...bad } })).statusCode).toBe(400) } const customer = (await createUserAndLogin(t.app, "c@example.com")).headers expect((await t.app.inject({ method: "GET", url: "/installer/profile", headers: customer })).statusCode).toBe(403) }) }) describe("approvazione", () => { const PDF = Buffer.from("%PDF-1.7\n%%EOF\n") async function installerDoc() { const body = multipartBody({ title: "Listino tecnico", visibility: "installer" }, [{ filename: "l.pdf", contentType: "application/pdf", content: PDF }]) const res = await t.app.inject({ method: "POST", url: "/admin/documents", payload: body.payload, headers: { ...body.headers, ...admin } }) return res.json().id as string } const price = async (headers: Record) => (await t.app.inject({ method: "GET", url: `/shop/products/${MOCK_PRODUCT.handle}`, headers })).json().variants[0].price it("pending: niente documenti installer né prezzi riservati; dopo l'approvazione sì (stesso token); dopo il rifiuto no", async () => { const inst = await createUserAndLogin(t.app, "inst@example.com", "installer") await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" }) const docId = await installerDoc() await t.app.flushSyncs() expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(403) expect(await price(inst.headers)).toBe(MOCK_VARIANT.price * 100) const approved = await t.app.inject({ method: "POST", url: `/admin/installers/${inst.user.id}/approve`, headers: admin, payload: {} }) expect(approved.statusCode).toBe(200) expect(approved.json()).toMatchObject({ status: "approved", medusaSynced: true }) expect(approved.json().approvedAt).not.toBeNull() const customerId = t.medusa.accountByEmail("inst@example.com")!.id expect(t.medusa.installerGroup.has(customerId)).toBe(true) expect(t.email.last("installer-approved")?.to).toBe("inst@example.com") t.app.shop.clearCache() expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(200) expect(await price(inst.headers)).toBe(MOCK_VARIANT.installerPrice * 100) const rejected = await t.app.inject({ method: "POST", url: `/admin/installers/${inst.user.id}/reject`, headers: admin, payload: { reason: "Certificazioni scadute" }, }) expect(rejected.json()).toMatchObject({ status: "rejected", approvedAt: null, medusaSynced: true }) expect(t.medusa.installerGroup.has(customerId)).toBe(false) expect(t.email.last("installer-rejected")?.text).toContain("Certificazioni scadute") t.app.shop.clearCache() expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(403) expect(await price(inst.headers)).toBe(MOCK_VARIANT.price * 100) }) it("Medusa giù all'approvazione: approvato comunque (medusaSynced false), gruppo allineato al login successivo", async () => { const inst = await createUserAndLogin(t.app, "inst@example.com", "installer") await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" }) await t.app.flushSyncs() t.medusa.down = true const res = await t.app.inject({ method: "POST", url: `/admin/installers/${inst.user.id}/approve`, headers: admin, payload: {} }) expect(res.json()).toMatchObject({ status: "approved", medusaSynced: false }) t.medusa.down = false await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "inst@example.com", password: "Password-di-test-1" } }) await t.app.flushSyncs() const customerId = t.medusa.accountByEmail("inst@example.com")!.id expect(t.medusa.installerGroup.has(customerId)).toBe(true) }) it("approvare un customer che ha un profilo lo rende installer; elenco per stato e note interne", async () => { const c = await createUserAndLogin(t.app, "ex-cliente@example.com", "customer") await t.app.db.insert(installerProfiles).values({ userId: c.user.id, businessName: "Nuova Ditta" }) expect((await t.app.inject({ method: "GET", url: "/admin/installers?status=pending", headers: admin })).json().total).toBe(1) await t.app.inject({ method: "POST", url: `/admin/installers/${c.user.id}/approve`, headers: admin, payload: { notes: "Verificata visura" } }) const [u] = await t.app.db.select().from(users).where(eq(users.id, c.user.id)) expect(u!.role).toBe("installer") const detail = (await t.app.inject({ method: "GET", url: `/admin/installers/${c.user.id}`, headers: admin })).json() expect(detail).toMatchObject({ status: "approved", notes: "Verificata visura", email: "ex-cliente@example.com" }) expect((await t.app.inject({ method: "GET", url: "/admin/installers?status=pending", headers: admin })).json().total).toBe(0) }) })