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,190 @@
|
||||
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<string, unknown>, headers: Record<string, string> = {}) =>
|
||||
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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user