import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { consents, showroomBookings } from "../src/db/schema/index.js" import { createFormToken } from "../src/services/form-guard.js" import { SHOWROOM, type ShowroomConfig } from "../src/showroom/config.js" import { bookableSlots, easterSunday, findBookableSlot, slotsOfDay, zonedTime } from "../src/showroom/slots.js" import { buildTestApp, createUserAndLogin, type TestApp, truncateAll } from "./helpers.js" let t: TestApp let ipSeq = 0 /** Token emesso 10 secondi fa (supera il tempo minimo di compilazione). */ const formToken = () => createFormToken("c".repeat(32), new Date(Date.now() - 10_000)).token const book = (payload: Record, headers: Record = {}) => t.app.inject({ method: "POST", url: "/showroom/bookings", headers, // IP diverso a ogni richiesta: il rate limit della route (5 ogni 15 minuti) non interferisce remoteAddress: `10.0.0.${++ipSeq % 250}`, payload: { name: "Mario Rossi", email: "Mario@Example.com", acceptPrivacy: true, formToken: formToken(), people: 1, ...payload }, }) /** Primo slot prenotabile secondo GET /showroom/slots. */ async function firstSlot() { const res = await t.app.inject({ method: "GET", url: "/showroom/slots?days=31" }) expect(res.statusCode).toBe(200) const slot = res.json().days[0]?.slots[0] expect(slot).toBeDefined() return slot as { start: string; end: string; available: number } } beforeAll(async () => { t = await buildTestApp() }) afterAll(() => t.app.close()) beforeEach(async () => { await truncateAll() t.email.clear() }) describe("slot dello showroom (funzioni pure)", () => { const cfg: ShowroomConfig = { ...SHOWROOM, closedDates: ["2026-10-09"] } it("orari di un giorno feriale, del sabato e della domenica (ora legale e solare)", () => { // lunedì 5 ottobre 2026 (ora legale, UTC+2): 9-12 e 14-18 → 3 + 4 slot da un'ora const monday = slotsOfDay(cfg, "2026-10-05") expect(monday).toHaveLength(7) expect(monday[0]!.start.toISOString()).toBe("2026-10-05T07:00:00.000Z") expect(monday[3]!.start.toISOString()).toBe("2026-10-05T12:00:00.000Z") expect(monday[6]!.end.toISOString()).toBe("2026-10-05T16:00:00.000Z") // sabato 7 novembre 2026 (ora solare, UTC+1): solo mattina const saturday = slotsOfDay(cfg, "2026-11-07") expect(saturday.map((s) => s.start.toISOString())).toEqual([ "2026-11-07T08:00:00.000Z", "2026-11-07T09:00:00.000Z", "2026-11-07T10:00:00.000Z", ]) expect(slotsOfDay(cfg, "2026-10-04")).toEqual([]) // domenica }) it("cambio d'ora: lunedì 26 ottobre 2026 è già in ora solare", () => { expect(zonedTime("2026-10-24", "09:00", "Europe/Rome").toISOString()).toBe("2026-10-24T07:00:00.000Z") expect(zonedTime("2026-10-26", "09:00", "Europe/Rome").toISOString()).toBe("2026-10-26T08:00:00.000Z") expect(zonedTime("2026-03-30", "09:00", "Europe/Rome").toISOString()).toBe("2026-03-30T07:00:00.000Z") }) it("chiusure: festività fisse, Pasquetta, date straordinarie", () => { expect(easterSunday(2026)).toBe("2026-04-05") expect(easterSunday(2027)).toBe("2027-03-28") expect(slotsOfDay(cfg, "2026-04-06")).toEqual([]) // Pasquetta expect(slotsOfDay(cfg, "2026-12-08")).toEqual([]) // Immacolata (martedì) expect(slotsOfDay(cfg, "2026-10-09")).toEqual([]) // chiusura straordinaria (venerdì) expect(slotsOfDay(cfg, "2026-10-08")).toHaveLength(7) }) it("preavviso minimo e orizzonte di prenotazione", () => { const now = new Date("2026-10-05T10:30:00Z") // lunedì 12:30 a Roma const days = bookableSlots(cfg, now, "2026-10-05", 3) // lunedì escluso (preavviso 24 h); martedì dalle 14:00 (12:30 + 24 h → primo slot alle 14) expect(days.map((d) => d.date)).toEqual(["2026-10-06", "2026-10-07"]) expect(days[0]!.slots[0]!.start.toISOString()).toBe("2026-10-06T12:00:00.000Z") expect(findBookableSlot(cfg, now, new Date("2026-10-06T12:00:00Z"))).not.toBeNull() expect(findBookableSlot(cfg, now, new Date("2026-10-06T09:00:00Z"))).toBeNull() // entro il preavviso expect(findBookableSlot(cfg, now, new Date("2026-10-06T12:30:00Z"))).toBeNull() // non è un inizio di slot expect(findBookableSlot(cfg, now, new Date("2026-10-06T17:00:00Z"))).toBeNull() // dopo la chiusura expect(findBookableSlot(cfg, now, new Date("2026-12-07T08:00:00Z"))).toBeNull() // oltre 60 giorni }) }) describe("prenotazioni showroom", () => { it("prenotazione confermata con email, consensi e notifica allo staff; posti scalati", async () => { await createUserAndLogin(t.app, "admin@iti.local", "admin") t.email.clear() const slot = await firstSlot() expect(slot.available).toBe(SHOWROOM.capacity) const res = await book({ requestedAt: slot.start, people: 2, acceptMarketing: true, notes: "Interessati alle pompe di calore" }) expect(res.statusCode, res.body).toBe(201) expect(res.json()).toMatchObject({ email: "mario@example.com", people: 2, status: "confirmed", requestedAt: slot.start }) expect(t.email.last("showroom-booking-confirmed")?.to).toBe("mario@example.com") expect(t.email.last("staff-notification")?.to).toBe("admin@iti.local") const saved = await t.app.db.select().from(consents) expect(saved.map((c) => [c.type, c.source, c.email]).sort()).toEqual([ ["marketing", "showroom", "mario@example.com"], ["privacy", "showroom", "mario@example.com"], ]) expect((await firstSlot()).available).toBe(SHOWROOM.capacity - 2) }) it("oltre la capienza la seconda prenotazione è rifiutata (409 SLOT_FULL)", async () => { const slot = await firstSlot() expect((await book({ requestedAt: slot.start, people: 4 })).statusCode).toBe(201) const second = await book({ requestedAt: slot.start, people: 3 }) expect(second.statusCode).toBe(409) expect(second.json().error.code).toBe("SLOT_FULL") expect((await book({ requestedAt: slot.start, people: 2 })).statusCode).toBe(201) expect((await book({ requestedAt: slot.start, people: 1 })).statusCode).toBe(409) expect((await firstSlot()).start).toBe(slot.start) // lo slot pieno resta elencato… const res = await t.app.inject({ method: "GET", url: "/showroom/slots?days=31" }) expect(res.json().days[0].slots[0].available).toBe(0) // …con 0 posti }) it("prenotazioni concorrenti sullo stesso slot non superano la capienza", async () => { const slot = await firstSlot() const results = await Promise.all(Array.from({ length: 5 }, () => book({ requestedAt: slot.start, people: 4 }))) expect(results.map((r) => r.statusCode).sort()).toEqual([201, 409, 409, 409, 409]) expect(await t.app.db.select().from(showroomBookings)).toHaveLength(1) }) it("slot non valido, persone oltre il massimo, consenso privacy mancante → 400", async () => { const slot = await firstSlot() const offSlot = new Date(new Date(slot.start).getTime() + 30 * 60_000).toISOString() const bad = await book({ requestedAt: offSlot }) expect(bad.statusCode).toBe(400) expect(bad.json().error.code).toBe("INVALID_SLOT") expect((await book({ requestedAt: new Date(Date.now() + 3_600_000).toISOString() })).json().error.code).toBe("INVALID_SLOT") expect((await book({ requestedAt: slot.start, people: SHOWROOM.maxPeoplePerBooking + 1 })).statusCode).toBe(400) const noPrivacy = await book({ requestedAt: slot.start, acceptPrivacy: false }) expect(noPrivacy.statusCode).toBe(400) expect(noPrivacy.json().error.code).toBe("VALIDATION_ERROR") expect((await book({ requestedAt: slot.start, acceptPrivacy: undefined })).statusCode).toBe(400) expect(await t.app.db.select().from(showroomBookings)).toHaveLength(0) expect(await t.app.db.select().from(consents)).toHaveLength(0) }) it("anti-spam: honeypot e invio troppo rapido scartati in silenzio, token falso → 400", async () => { const slot = await firstSlot() const honeypot = await book({ requestedAt: slot.start, website: "http://spam.example" }) expect(honeypot.statusCode).toBe(200) const fast = await book({ requestedAt: slot.start, formToken: createFormToken("c".repeat(32)).token }) expect(fast.statusCode).toBe(200) expect(await t.app.db.select().from(showroomBookings)).toHaveLength(0) expect(t.email.sent).toHaveLength(0) const forged = await book({ requestedAt: slot.start, formToken: createFormToken("x".repeat(32), new Date(Date.now() - 10_000)).token }) expect(forged.statusCode).toBe(400) expect(forged.json().error.code).toBe("FORM_EXPIRED") }) it("admin: elenco, annullamento libera i posti, riattivazione ricontrolla la capienza e conferma via email", async () => { const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers const user = await createUserAndLogin(t.app, "luigi@example.com") const slot = await firstSlot() const first = (await book({ requestedAt: slot.start, people: 4 }, user.headers)).json() expect(first.email).toBe("mario@example.com") const list = await t.app.inject({ method: "GET", url: "/admin/showroom/bookings?status=confirmed", headers: admin }) expect(list.json()).toMatchObject({ total: 1, items: [{ id: first.id, userId: user.user.id }] }) expect((await t.app.inject({ method: "GET", url: "/admin/showroom/bookings", headers: user.headers })).statusCode).toBe(403) const patch = (id: string, status: string) => t.app.inject({ method: "PATCH", url: `/admin/showroom/bookings/${id}`, headers: admin, payload: { status } }) expect((await patch(first.id, "cancelled")).json().status).toBe("cancelled") expect((await firstSlot()).available).toBe(SHOWROOM.capacity) expect((await book({ requestedAt: slot.start, people: 3 })).statusCode).toBe(201) const reactivate = await patch(first.id, "confirmed") expect(reactivate.statusCode).toBe(409) expect(reactivate.json().error.code).toBe("SLOT_FULL") t.email.clear() const second = (await t.app.inject({ method: "GET", url: "/admin/showroom/bookings?status=confirmed", headers: admin })).json().items[0] await patch(second.id, "cancelled") expect((await patch(first.id, "confirmed")).json().status).toBe("confirmed") expect(t.email.last("showroom-booking-confirmed")?.to).toBe("mario@example.com") expect((await patch(first.id, "completed")).json().status).toBe("completed") }) })