import { eq } from "drizzle-orm" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { blogPosts, users } from "../src/db/schema/index.js" import { renderMarkdown, slugify } from "../src/services/blog.js" import { hashPassword } from "../src/services/password.js" import { buildTestApp, type TestApp, truncateAll } from "./helpers.js" const PASSWORD = "Password-di-test-1" let t: TestApp let admin: string let customer: string async function login(email: string, role: "admin" | "customer") { await t.app.db.insert(users).values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "A", lastName: "B" }) const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } }) return res.json().accessToken as string } const asAdmin = (method: "GET" | "POST" | "PATCH" | "DELETE", url: string, payload?: object) => t.app.inject({ method, url, headers: { authorization: `Bearer ${admin}` }, ...(payload ? { payload } : {}) }) const createPost = async (body: Record) => { const res = await asAdmin("POST", "/admin/blog/posts", body) expect(res.statusCode, res.body).toBe(201) return res.json() } const publicList = async (query = "") => (await t.app.inject({ method: "GET", url: `/blog/posts${query}` })).json() beforeAll(async () => { t = await buildTestApp() }) afterAll(() => t.app.close()) beforeEach(async () => { await truncateAll() admin = await login("admin@iti.local", "admin") customer = await login("cliente@example.com", "customer") }) describe("ricerca full-text", () => { it('"pompa calore" trova "Pompe di calore: guida" (stemming italiano + unaccent), ordinato per rilevanza', async () => { await createPost({ title: "Pompe di calore: guida", contentMd: "Come scegliere la pompa di calore.", status: "published" }) await createPost({ title: "Fotovoltaico e accumulo", contentMd: "Con una pompa di calore il fotovoltaico rende di più.", status: "published" }) await createPost({ title: "Addolcitori per l'acqua", contentMd: "Calcare e durezza.", status: "published" }) const res = await publicList("?q=pompa%20calore") expect(res.items.map((p: { title: string }) => p.title)).toEqual(["Pompe di calore: guida", "Fotovoltaico e accumulo"]) expect(res.total).toBe(2) }) it("ricerca insensibile agli accenti e sintassi websearch (frasi, esclusioni)", async () => { await createPost({ title: "Perché l'efficienza è importante", contentMd: "Risparmio energetico.", status: "published" }) await createPost({ title: "Efficienza della caldaia", contentMd: "Manutenzione.", status: "published" }) expect((await publicList("?q=perche")).total).toBe(1) expect((await publicList("?q=efficienza%20-caldaia")).items.map((p: { title: string }) => p.title)).toEqual([ "Perché l'efficienza è importante", ]) expect((await publicList("?q=%22")).total).toBe(0) // query vuota per tsquery: nessun errore }) }) describe("visibilità pubblica", () => { it("bozze e post programmati nel futuro non sono visibili via API pubblica", async () => { const draft = await createPost({ title: "Bozza segreta", contentMd: "x" }) const future = await createPost({ title: "Programmato", contentMd: "x", status: "published", publishedAt: new Date(Date.now() + 86_400_000).toISOString(), }) const live = await createPost({ title: "Pubblicato", contentMd: "x", status: "published" }) const list = await publicList() expect(list.items.map((p: { slug: string }) => p.slug)).toEqual([live.slug]) expect((await publicList("?q=bozza")).total).toBe(0) for (const slug of [draft.slug, future.slug]) { expect((await t.app.inject({ method: "GET", url: `/blog/posts/${slug}` })).statusCode).toBe(404) } expect((await t.app.inject({ method: "GET", url: `/blog/posts/${live.slug}` })).statusCode).toBe(200) const sitemap = (await t.app.inject({ method: "GET", url: "/sitemap-data" })).json() expect(sitemap.posts.map((p: { slug: string }) => p.slug)).toEqual([live.slug]) }) it("filtro per categoria e categorie pubbliche", async () => { const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Energia rinnovabile" })).json() expect(cat.slug).toBe("energia-rinnovabile") await createPost({ title: "Con categoria", contentMd: "x", status: "published", categoryId: cat.id }) await createPost({ title: "Senza categoria", contentMd: "x", status: "published" }) const res = await publicList("?category=energia-rinnovabile") expect(res.items).toHaveLength(1) expect(res.items[0].category).toMatchObject({ slug: "energia-rinnovabile", name: "Energia rinnovabile" }) expect((await t.app.inject({ method: "GET", url: "/blog/categories" })).json()).toHaveLength(1) }) it("dettaglio: markdown + HTML, copertina con URL pubblico", async () => { const p = await createPost({ title: "Guida", contentMd: "## Titolo\n\nTesto **forte**.", coverPath: "images/2026/10/abc.png", status: "published", }) const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json() expect(detail.contentMd).toBe("## Titolo\n\nTesto **forte**.") expect(detail.contentHtml).toContain("

Titolo

") expect(detail.contentHtml).toContain("forte") expect(detail.coverUrl).toBe("http://localhost:3000/uploads/images/2026/10/abc.png") }) }) describe("sanitizzazione", () => { it("script, handler on*, javascript:, iframe, style rimossi", () => { const html = renderMarkdown( [ "Testo ", '', 'ok', "[link](javascript:alert(3))", 'esterno', '', '

stile

', "![data](data:image/png;base64,AAAA)", ].join("\n\n"), ) expect(html).not.toMatch(/') expect(html).toContain('esterno') }) it("il dettaglio pubblico e la preview restituiscono HTML ripulito", async () => { const md = 'Ciao ' const p = await createPost({ title: "XSS", contentMd: md, status: "published" }) const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json() expect(detail.contentHtml).not.toMatch(/ { const html = renderMarkdown("| a | b |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst x = 1\n```\n\n- uno\n- due") expect(html).toContain("") expect(html).toContain('') expect(html).toContain("
  • uno
  • ") }) }) describe("admin", () => { it("permessi: customer 403, anonimo 401 su tutte le route admin del blog", async () => { const routes: ["GET" | "POST", string][] = [ ["GET", "/admin/blog/posts"], ["POST", "/admin/blog/posts"], ["POST", "/admin/blog/preview"], ["POST", "/admin/blog/categories"], ] for (const [method, url] of routes) { const asCustomer = await t.app.inject({ method, url, headers: { authorization: `Bearer ${customer}` }, payload: {} }) expect(asCustomer.statusCode, `${method} ${url}`).toBe(403) expect((await t.app.inject({ method, url, payload: {} })).statusCode, `${method} ${url}`).toBe(401) } }) it("slug dal titolo, univoco, stabile al cambio titolo; slug esplicito duplicato → 409", async () => { expect(slugify("Pompe di calore: guida 2026!")).toBe("pompe-di-calore-guida-2026") expect(slugify("Perché l'acqua è dura?")).toBe("perche-lacqua-e-dura") const a = await createPost({ title: "Pompe di calore: guida" }) const b = await createPost({ title: "Pompe di calore: guida" }) const c = await createPost({ title: "Pompe di calore guida" }) expect([a.slug, b.slug, c.slug]).toEqual(["pompe-di-calore-guida", "pompe-di-calore-guida-2", "pompe-di-calore-guida-3"]) const renamed = (await asAdmin("PATCH", `/admin/blog/posts/${a.id}`, { title: "Titolo nuovo" })).json() expect(renamed.slug).toBe("pompe-di-calore-guida") const dup = await asAdmin("PATCH", `/admin/blog/posts/${b.id}`, { slug: "pompe-di-calore-guida" }) expect(dup.statusCode).toBe(409) expect(dup.json().error.code).toBe("SLUG_TAKEN") expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", slug: "Non Valido!" })).statusCode).toBe(400) }) it("stato: pubblicazione imposta published_at, PATCH parziale non tocca stato e contenuto", async () => { const p = await createPost({ title: "Stato", contentMd: "Contenuto originale" }) expect(p).toMatchObject({ status: "draft", publishedAt: null }) const pub = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "published" })).json() expect(pub.status).toBe("published") expect(Date.now() - Date.parse(pub.publishedAt)).toBeLessThan(10_000) // Regressione: un PATCH del solo titolo non deve rimettere in bozza né svuotare il contenuto const titled = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { title: "Stato aggiornato" })).json() expect(titled).toMatchObject({ status: "published", contentMd: "Contenuto originale", publishedAt: pub.publishedAt }) const back = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "draft" })).json() expect(back.status).toBe("draft") expect((await publicList()).total).toBe(0) }) it("lista admin con bozze e filtro stato; DELETE; autore salvato", async () => { await createPost({ title: "Bozza" }) const live = await createPost({ title: "Live", status: "published" }) expect((await asAdmin("GET", "/admin/blog/posts")).json().total).toBe(2) expect((await asAdmin("GET", "/admin/blog/posts?status=draft")).json().items.map((p: { title: string }) => p.title)).toEqual(["Bozza"]) const [row] = await t.app.db.select().from(blogPosts).where(eq(blogPosts.id, live.id)) expect(row!.authorId).not.toBeNull() expect((await asAdmin("DELETE", `/admin/blog/posts/${live.id}`)).statusCode).toBe(204) expect((await asAdmin("GET", `/admin/blog/posts/${live.id}`)).statusCode).toBe(404) }) it("categoria inesistente → 400; eliminare una categoria lascia i post senza categoria", async () => { const bad = await asAdmin("POST", "/admin/blog/posts", { title: "X", categoryId: "00000000-0000-4000-8000-000000000000" }) expect(bad.json().error.code).toBe("CATEGORY_NOT_FOUND") const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Acqua" })).json() const p = await createPost({ title: "Con cat", categoryId: cat.id }) expect((await asAdmin("DELETE", `/admin/blog/categories/${cat.id}`)).statusCode).toBe(204) expect((await asAdmin("GET", `/admin/blog/posts/${p.id}`)).json().category).toBeNull() }) it("copertina: solo percorsi dell'area immagini", async () => { expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "../private/documents/x.pdf" })).statusCode).toBe(400) expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "documents/2026/10/x.pdf" })).statusCode).toBe(400) }) })