Merge remote-tracking branch 'origin/feat/split-apps' into merge-split
# Conflicts: # apps/sito/compose.yaml
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
|
||||
describe('auth', () => {
|
||||
it('hash e verifica password', () => {
|
||||
const h = hashPassword('segreta123');
|
||||
expect(h).not.toBe('segreta123');
|
||||
expect(verifyPassword('segreta123', h)).toBe(true);
|
||||
expect(verifyPassword('sbagliata', h)).toBe(false);
|
||||
});
|
||||
|
||||
it('login corretto crea sessione recuperabile', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
const token = login(db, 'adriano', 'segreta123');
|
||||
expect(token).toBeTruthy();
|
||||
const user = getSessionUser(db, token!);
|
||||
expect(user).toMatchObject({ username: 'adriano' });
|
||||
});
|
||||
|
||||
it('login errato ritorna null', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
expect(login(db, 'adriano', 'sbagliata')).toBeNull();
|
||||
expect(login(db, 'inesistente', 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('sessione scaduta viene rifiutata ed eliminata', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
const token = login(db, 'adriano', 'segreta123')!;
|
||||
db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run();
|
||||
expect(getSessionUser(db, token)).toBeNull();
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 });
|
||||
});
|
||||
|
||||
it('logout elimina la sessione', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
const token = login(db, 'adriano', 'segreta123')!;
|
||||
logout(db, token);
|
||||
expect(getSessionUser(db, token)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { avvisi, avvisiAttivi, calendari, calendariDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
|
||||
|
||||
const d = (iso: string) => new Date(iso);
|
||||
|
||||
describe('calendariDaMostrare', () => {
|
||||
const idDi = (quando: string) => calendariDaMostrare(d(quando)).map((m) => `${m.cal.id}:${m.stato}`);
|
||||
|
||||
// Si vedono tutti insieme, non solo quello del momento: chi guarda ad agosto vuole sapere
|
||||
// com'è settembre per intero, e una griglia sola gli farebbe credere che valga per sempre.
|
||||
it('prima dell\'inizio: entrambi, marcati futuri', () => {
|
||||
expect(idDi('2026-08-17T10:00:00+02:00'))
|
||||
.toEqual(['settembre-1-18:futuro', 'settembre-21-in-poi:futuro']);
|
||||
});
|
||||
|
||||
it('durante il primo periodo: il primo è in corso, il secondo resta annunciato', () => {
|
||||
expect(idDi('2026-09-05T10:00:00+02:00'))
|
||||
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
|
||||
});
|
||||
|
||||
// Un orario scaduto non deve restare in pagina: farebbe cercare lezioni che non ci sono più.
|
||||
it('finito il primo periodo, il primo orario sparisce', () => {
|
||||
expect(idDi('2026-09-25T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
|
||||
expect(idDi('2027-03-01T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
|
||||
});
|
||||
|
||||
// 19 e 20 settembre non stanno in nessuna griglia perché quei due giorni non hanno class:
|
||||
// il 19 c'è la presentazione della nuova apertura, il 20 si sta chiusi (Adriano, 17/08).
|
||||
it('nei due giorni fuori griglia resta il solo orario del 21, annunciato', () => {
|
||||
expect(idDi('2026-09-19T10:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
|
||||
expect(idDi('2026-09-20T23:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
|
||||
});
|
||||
|
||||
it('confini al secondo, in ora italiana', () => {
|
||||
expect(idDi('2026-09-18T23:59:59+02:00'))
|
||||
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
|
||||
expect(idDi('2026-09-19T00:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
|
||||
expect(idDi('2026-09-21T00:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
|
||||
});
|
||||
|
||||
it('l\'ordine dell\'elenco di partenza non cambia il risultato', () => {
|
||||
const rovescio = [...calendari].reverse();
|
||||
expect(calendariDaMostrare(d('2026-09-05T10:00:00+02:00'), rovescio).map((m) => m.cal.id))
|
||||
.toEqual(['settembre-1-18', 'settembre-21-in-poi']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('giornate fuori griglia', () => {
|
||||
it('la presentazione e la chiusura si vedono prima di arrivarci', () => {
|
||||
const oggi = avvisiAttivi(d('2026-08-17T12:00:00+02:00')).map((a) => a.id);
|
||||
expect(oggi).toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
|
||||
});
|
||||
|
||||
// Un avviso passato è peggio di nessun avviso: dice che il centro è chiuso un giorno che è
|
||||
// già trascorso, e chi legge non sa più se valga per quest'anno o per il prossimo.
|
||||
it('ogni avviso sparisce da solo quando è passato', () => {
|
||||
expect(avvisiAttivi(d('2026-09-19T23:59:59+02:00')).map((a) => a.id))
|
||||
.toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
|
||||
expect(avvisiAttivi(d('2026-09-20T00:00:00+02:00')).map((a) => a.id))
|
||||
.toEqual(['chiusura-20-settembre']);
|
||||
expect(avvisiAttivi(d('2026-09-21T00:00:00+02:00'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('ogni avviso ha una scadenza leggibile e un testo', () => {
|
||||
for (const a of avvisi) {
|
||||
expect(Number.isNaN(Date.parse(a.to)), `${a.id}: scadenza illeggibile`).toBe(false);
|
||||
if (a.from !== null) expect(Date.parse(a.to)).toBeGreaterThanOrEqual(Date.parse(a.from));
|
||||
expect(a.quando.length).toBeGreaterThan(0);
|
||||
expect(a.testo.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerenza dei calendari trascritti', () => {
|
||||
for (const c of calendari) {
|
||||
describe(c.id, () => {
|
||||
it('la finestra è leggibile e ha un verso', () => {
|
||||
expect(Number.isNaN(Date.parse(c.from))).toBe(false);
|
||||
if (c.to !== null) {
|
||||
expect(Number.isNaN(Date.parse(c.to))).toBe(false);
|
||||
expect(Date.parse(c.to)).toBeGreaterThan(Date.parse(c.from));
|
||||
}
|
||||
});
|
||||
|
||||
it('le fasce orarie sono uniche e in ordine crescente', () => {
|
||||
const ore = c.righe.map((r) => r.ora);
|
||||
expect(new Set(ore).size, 'fascia ripetuta').toBe(ore.length);
|
||||
const minuti = ore.map((o) => {
|
||||
const [h, m] = o.split('.');
|
||||
return Number(h) * 60 + Number(m);
|
||||
});
|
||||
expect(minuti.every((v, i) => i === 0 || v > minuti[i - 1]), `fasce fuori ordine: ${ore.join(' ')}`).toBe(true);
|
||||
});
|
||||
|
||||
it('ogni cella sta su un giorno esistente e porta una disciplina nota', () => {
|
||||
for (const r of c.righe) {
|
||||
for (const [giorno, disciplina] of Object.entries(r.celle)) {
|
||||
expect(GIORNI, `${r.ora}: giorno ${giorno} inesistente`).toContain(giorno as Giorno);
|
||||
expect(DISCIPLINE, `${r.ora} ${giorno}: disciplina ${disciplina} sconosciuta`).toContain(disciplina!);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Una lezione in un giorno di chiusura è la contraddizione che nessuno nota leggendo la
|
||||
// griglia: la colonna è barrata e la cella ci finisce dentro lo stesso.
|
||||
it('nessuna lezione cade in un giorno di chiusura', () => {
|
||||
for (const r of c.righe) {
|
||||
for (const g of c.chiusi) {
|
||||
expect(r.celle[g], `${r.ora}: lezione di ${g}, che è chiuso`).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('le note dei giorni si riferiscono a giorni aperti', () => {
|
||||
for (const g of Object.keys(c.noteGiorno ?? {}) as Giorno[]) {
|
||||
expect(GIORNI).toContain(g);
|
||||
expect(c.chiusi, `nota su ${g}, che è dichiarato chiuso`).not.toContain(g);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { avvisi, calendari } from '../src/data/calendari';
|
||||
import { generaCalendariPdf, nomeFilePdf } from '../src/lib/calendario-pdf';
|
||||
|
||||
describe('PDF del calendario', () => {
|
||||
for (const cal of calendari) {
|
||||
it(`${cal.id}: produce un PDF valido`, async () => {
|
||||
const pdf = await generaCalendariPdf([cal]);
|
||||
// Un PDF comincia sempre con %PDF- e finisce con %%EOF: se il documento non venisse
|
||||
// chiuso (`doc.end()` mancante, o un errore a metà) il file arriverebbe monco al
|
||||
// browser, che mostrerebbe una pagina bianca invece di un errore.
|
||||
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
|
||||
expect(pdf.subarray(-8).toString()).toContain('%%EOF');
|
||||
expect(pdf.length).toBeGreaterThan(2000);
|
||||
});
|
||||
}
|
||||
|
||||
// La griglia deve stare in una pagina sola: l'incrocio giorno/ora è proprio ciò che si va a
|
||||
// cercare, e spezzarlo a metà rende il documento inutile. Il conteggio si legge dall'oggetto
|
||||
// /Type /Pages, che dichiara quante pagine contiene.
|
||||
it('sta in una pagina sola, anche con le giornate fuori griglia in fondo', async () => {
|
||||
for (const cal of calendari) {
|
||||
const pdf = await generaCalendariPdf([cal], avvisi);
|
||||
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
|
||||
expect(conteggio, `${cal.id}: numero di pagine non dichiarato`).not.toBeNull();
|
||||
expect(Number(conteggio![1]), `${cal.id}: il calendario occupa più di una pagina`).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('il nome del file distingue i due periodi', () => {
|
||||
const nomi = calendari.map((c) => nomeFilePdf([c]));
|
||||
expect(new Set(nomi).size).toBe(nomi.length);
|
||||
for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/);
|
||||
// Più orari insieme: un nome che nominasse solo il primo farebbe credere che dentro ci
|
||||
// sia quello e basta.
|
||||
expect(nomeFilePdf(calendari)).toBe('insanitylab-calendario-class.pdf');
|
||||
});
|
||||
|
||||
// Un orario per pagina: due griglie sullo stesso foglio si confonderebbero fra loro, e
|
||||
// spezzarne una a metà toglie proprio ciò che si va a cercare, l'incrocio giorno/ora.
|
||||
it('con più orari fa una pagina per ciascuno', async () => {
|
||||
const pdf = await generaCalendariPdf(calendari, avvisi);
|
||||
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
|
||||
expect(conteggio).not.toBeNull();
|
||||
expect(Number(conteggio![1])).toBe(calendari.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateContact } from '../src/lib/contact';
|
||||
|
||||
describe('validateContact', () => {
|
||||
const good = { firstName: 'Mario', lastName: 'Rossi', phone: '3331234567', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' };
|
||||
|
||||
it('accetta payload valido', () => {
|
||||
const r = validateContact(good);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.value.lastName).toBe('Rossi');
|
||||
expect(r.value.email).toBe('mario@example.com');
|
||||
}
|
||||
});
|
||||
|
||||
it('rifiuta email non valida', () => {
|
||||
expect(validateContact({ ...good, email: 'non-email' }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rifiuta campi obbligatori mancanti o vuoti', () => {
|
||||
expect(validateContact({ ...good, firstName: ' ' }).ok).toBe(false);
|
||||
expect(validateContact({ ...good, lastName: ' ' }).ok).toBe(false);
|
||||
expect(validateContact({ ...good, phone: '' }).ok).toBe(false);
|
||||
expect(validateContact({ ...good, message: '' }).ok).toBe(false);
|
||||
expect(validateContact(null).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rifiuta honeypot compilato', () => {
|
||||
expect(validateContact({ ...good, hp_field: 'spam.com' }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rifiuta messaggi oltre 5000 caratteri', () => {
|
||||
expect(validateContact({ ...good, message: 'x'.repeat(5001) }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { contentSeed } from '../src/data/content-seed';
|
||||
import { applyContentUpdate } from '../src/lib/content-update';
|
||||
|
||||
let db: Database.Database;
|
||||
const textTag = contentSeed.find((e) => e.type === 'text')!.tag;
|
||||
const htmlTag = contentSeed.find((e) => e.type === 'html')?.tag;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
|
||||
describe('applyContentUpdate', () => {
|
||||
it('aggiorna value e styles validi', () => {
|
||||
const r = applyContentUpdate(db, textTag, { value: 'Nuovo testo', styles: { size: 'l' } }, 'admin');
|
||||
expect(r).toEqual({ ok: true });
|
||||
const row = db.prepare('SELECT value, styles, updated_by FROM content_blocks WHERE tag = ?').get(textTag) as any;
|
||||
expect(row.value).toBe('Nuovo testo');
|
||||
expect(JSON.parse(row.styles)).toEqual({ size: 'l' });
|
||||
expect(row.updated_by).toBe('admin');
|
||||
});
|
||||
|
||||
it('404 su tag inesistente, 400 su styles fuori whitelist', () => {
|
||||
expect(applyContentUpdate(db, 'no.tag', { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 404 });
|
||||
expect(applyContentUpdate(db, textTag, { styles: { size: 'xxxl' } }, 'a')).toMatchObject({ ok: false, status: 400 });
|
||||
});
|
||||
|
||||
it('sanifica i tag html e rifiuta value su tag image', () => {
|
||||
if (htmlTag) {
|
||||
applyContentUpdate(db, htmlTag, { value: 'ciao <script>alert(1)</script><strong>ok</strong>' }, 'a');
|
||||
const row = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(htmlTag) as any;
|
||||
expect(row.value).not.toContain('<script>');
|
||||
expect(row.value).toContain('<strong>ok</strong>');
|
||||
}
|
||||
const imgTag = contentSeed.find((e) => e.type === 'image')!.tag;
|
||||
expect(applyContentUpdate(db, imgTag, { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/content/[tag]', () => {
|
||||
it('GET /api/admin/content/[tag] ritorna tag, guid, type, value, styleOptions', async () => {
|
||||
const { GET } = await import('../src/pages/api/admin/content/[tag]');
|
||||
const res = await GET({ params: { tag: 'home.hero.cta' } } as any);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.tag).toBe('home.hero.cta');
|
||||
expect(typeof body.guid).toBe('string');
|
||||
expect(body.type).toBeDefined();
|
||||
expect(Array.isArray(body.styleOptions)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { createUser } from '../src/lib/auth';
|
||||
|
||||
describe('schema content_blocks e ruoli', () => {
|
||||
it('crea la tabella content_blocks', () => {
|
||||
const db = createDb(':memory:');
|
||||
const cols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
|
||||
expect(cols.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining(['tag', 'type', 'value', 'styles', 'updated_at', 'updated_by'])
|
||||
);
|
||||
});
|
||||
|
||||
it('rifiuta type non valido', () => {
|
||||
const db = createDb(':memory:');
|
||||
expect(() =>
|
||||
db.prepare(`INSERT INTO content_blocks (tag, type, value) VALUES ('x.y', 'video', '')`).run()
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('users ha colonna role con default admin', () => {
|
||||
const db = createDb(':memory:');
|
||||
db.prepare(`INSERT INTO users (username, password_hash) VALUES ('a', 'h')`).run();
|
||||
const row = db.prepare(`SELECT role FROM users WHERE username = 'a'`).get() as { role: string };
|
||||
expect(row.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('migra i tag programmi → servizi preservando i valori modificati dal pannello', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'il-db-test-'));
|
||||
const path = join(dir, 'test.db');
|
||||
try {
|
||||
// Simula un DB di produzione pre-rinomina: tag vecchi con valori modificati dal pannello.
|
||||
const db1 = createDb(path);
|
||||
const old = db1.transaction(() => {
|
||||
db1.prepare("DELETE FROM content_blocks WHERE tag IN ('home.services.title', 'service.rehab.title', 'services.hero.title', 'services.meta.title', 'footer.services.1.label', 'footer.col.services-title')").run();
|
||||
const ins = db1.prepare("INSERT INTO content_blocks (tag, type, value, updated_by) VALUES (?, 'text', ?, 'editor')");
|
||||
ins.run('home.programs.title', 'Titolo modificato');
|
||||
ins.run('program.rehab.title', 'Rehab modificato');
|
||||
ins.run('programs.hero.title', 'Hero modificato');
|
||||
ins.run('footer.programs.1.label', 'Label modificata');
|
||||
ins.run('footer.col.programs-title', 'Colonna modificata');
|
||||
// Riga mai toccata dal pannello: valore identico al vecchio seed → deve prendere il nuovo default.
|
||||
db1.prepare("INSERT INTO content_blocks (tag, type, value) VALUES ('programs.meta.title', 'text', 'Programmi')").run();
|
||||
db1.prepare("UPDATE content_blocks SET value = 'Programmi' WHERE tag = 'global.nav.2.label'").run();
|
||||
});
|
||||
old();
|
||||
db1.close();
|
||||
|
||||
// Riapertura: la migrazione rinomina i tag preservando valore e updated_by.
|
||||
const db2 = createDb(path);
|
||||
const val = (tag: string) =>
|
||||
db2.prepare('SELECT value, updated_by FROM content_blocks WHERE tag = ?').get(tag) as { value: string; updated_by: string | null } | undefined;
|
||||
expect(val('home.services.title')).toMatchObject({ value: 'Titolo modificato', updated_by: 'editor' });
|
||||
expect(val('service.rehab.title')).toMatchObject({ value: 'Rehab modificato', updated_by: 'editor' });
|
||||
expect(val('services.hero.title')).toMatchObject({ value: 'Hero modificato', updated_by: 'editor' });
|
||||
expect(val('footer.services.1.label')).toMatchObject({ value: 'Label modificata', updated_by: 'editor' });
|
||||
expect(val('footer.col.services-title')).toMatchObject({ value: 'Colonna modificata', updated_by: 'editor' });
|
||||
|
||||
// Le righe rimaste al vecchio valore di default prendono il nuovo ("Servizi").
|
||||
// Dopo il riordino del menu (commit 5ff4771) la voce "Servizi" è la 2ª: la migrazione
|
||||
// aggiorna global.nav.2.label da 'Programmi' al seed corrente 'Servizi'.
|
||||
expect(val('services.meta.title')).toMatchObject({ value: 'Servizi' });
|
||||
expect(val('global.nav.2.label')).toMatchObject({ value: 'Servizi' });
|
||||
// La riga rinominata ma modificata dal pannello resta invariata (già verificata sopra):
|
||||
// home.services.title = 'Titolo modificato'.
|
||||
|
||||
// Nessun tag vecchio residuo.
|
||||
const residui = db2.prepare(
|
||||
"SELECT count(*) AS c FROM content_blocks WHERE tag LIKE 'program%' OR tag LIKE 'home.programs%' OR tag LIKE 'footer.programs%' OR tag = 'footer.col.programs-title'"
|
||||
).get() as { c: number };
|
||||
expect(residui.c).toBe(0);
|
||||
|
||||
// Idempotente: una terza apertura non altera i valori.
|
||||
db2.close();
|
||||
const db3 = createDb(path);
|
||||
const again = db3.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('home.services.title') as { value: string };
|
||||
expect(again.value).toBe('Titolo modificato');
|
||||
db3.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allinea i bottoni promo col vecchio testo-freccia al seed senza freccia', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'il-db-promo-'));
|
||||
const path = join(dir, 'test.db');
|
||||
try {
|
||||
// DB pre-refactor: valori con freccia letterale e maiuscolo (poi spostati nel CSS).
|
||||
const db1 = createDb(path);
|
||||
db1.prepare("UPDATE content_blocks SET value = 'LE NOSTRE PROPOSTE →' WHERE tag = 'promo.hero.cta'").run();
|
||||
db1.prepare("UPDATE content_blocks SET value = 'ACQUISTA →' WHERE tag = 'promo.buy-label'").run();
|
||||
// Riga modificata dal pannello: NON deve essere toccata.
|
||||
db1.prepare("UPDATE content_blocks SET value = 'Compra ora', updated_by = 'editor' WHERE tag = 'promo.buy-label'").run();
|
||||
db1.close();
|
||||
|
||||
// Riapertura: la migrazione allinea solo le righe rimaste al vecchio default con freccia.
|
||||
const db2 = createDb(path);
|
||||
const val = (tag: string) => (db2.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
|
||||
expect(val('promo.hero.cta')).toBe('Le nostre proposte');
|
||||
// promo.buy-label era stato editato dal pannello → resta invariato.
|
||||
expect(val('promo.buy-label')).toBe('Compra ora');
|
||||
db2.close();
|
||||
|
||||
// Idempotente: terza apertura non altera.
|
||||
const db3 = createDb(path);
|
||||
expect((db3.prepare("SELECT value FROM content_blocks WHERE tag = 'promo.hero.cta'").get() as { value: string }).value).toBe('Le nostre proposte');
|
||||
db3.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('riporta il titolo del servizio a "Class" (inverte Performance Class)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'il-db-pc-'));
|
||||
const path = join(dir, 'test.db');
|
||||
try {
|
||||
// DB con il vecchio valore normalizzato "Performance Class".
|
||||
const db1 = createDb(path);
|
||||
db1.prepare("UPDATE content_blocks SET value = 'Performance Class', updated_by = NULL WHERE tag = 'service.performance-class.title'").run();
|
||||
db1.close();
|
||||
|
||||
const db2 = createDb(path);
|
||||
const val = (tag: string) => (db2.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
|
||||
expect(val('service.performance-class.title')).toBe('Class');
|
||||
db2.close();
|
||||
|
||||
// Non deve toccare un valore modificato dal pannello.
|
||||
const db3 = createDb(path);
|
||||
db3.prepare("UPDATE content_blocks SET value = 'Titolo mio', updated_by = 'admin' WHERE tag = 'service.performance-class.title'").run();
|
||||
db3.close();
|
||||
const db4 = createDb(path);
|
||||
expect((db4.prepare("SELECT value FROM content_blocks WHERE tag = 'service.performance-class.title'").get() as { value: string }).value).toBe('Titolo mio');
|
||||
db4.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rimuove i tag orfani training.performance-class.* (riquadro rimosso)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'il-db-orf-'));
|
||||
const path = join(dir, 'test.db');
|
||||
try {
|
||||
// DB con i tag orfani del vecchio riquadro performance-class.
|
||||
const db1 = createDb(path);
|
||||
const ins = db1.prepare("INSERT OR REPLACE INTO content_blocks (tag, type, value, guid) VALUES (?, 'text', ?, ?)");
|
||||
ins.run('training.performance-class.title', 'Holistic Class', 'g1');
|
||||
ins.run('training.performance-class.caption', 'Classi fino a 8 persone', 'g2');
|
||||
db1.close();
|
||||
|
||||
const db2 = createDb(path);
|
||||
const count = (db2.prepare("SELECT count(*) c FROM content_blocks WHERE tag LIKE 'training.performance-class.%'").get() as { c: number }).c;
|
||||
expect(count).toBe(0);
|
||||
// I riquadri validi restano.
|
||||
expect((db2.prepare("SELECT count(*) c FROM content_blocks WHERE tag = 'training.holistic-class.title'").get() as { c: number }).c).toBe(1);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('migra un DB esistente senza colonna role', () => {
|
||||
// simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile,
|
||||
// quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia)
|
||||
const db = createDb(':memory:');
|
||||
expect(() => createDb(':memory:')).not.toThrow();
|
||||
expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrazioni ruoli/guid/autore', () => {
|
||||
it('ogni content_block ha un guid non vuoto e univoco', () => {
|
||||
const db = createDb(':memory:');
|
||||
const rows = db.prepare('SELECT guid FROM content_blocks').all() as { guid: string | null }[];
|
||||
expect(rows.length).toBeGreaterThan(100);
|
||||
expect(rows.every((r) => typeof r.guid === 'string' && r.guid!.length >= 10)).toBe(true);
|
||||
const guids = new Set(rows.map((r) => r.guid));
|
||||
expect(guids.size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it('lo statement di migrazione unifica editor in superuser', () => {
|
||||
// La migrazione gira dentro createDb, ma :memory: non persiste tra due boot: qui
|
||||
// verifichiamo l'invariante SQL applicata dalla migrazione (editor → superuser).
|
||||
// L'end-to-end su DB pre-esistente è coperto dallo smoke di produzione (Task 15).
|
||||
const db = createDb(':memory:');
|
||||
db.prepare("INSERT INTO users (username, password_hash, role) VALUES ('vecchio', 'x', 'editor')").run();
|
||||
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
|
||||
const row = db.prepare("SELECT role FROM users WHERE username = 'vecchio'").get() as { role: string };
|
||||
expect(row.role).toBe('superuser');
|
||||
});
|
||||
|
||||
it('posts ha la colonna author_id', () => {
|
||||
const db = createDb(':memory:');
|
||||
const cols = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
|
||||
expect(cols.some((c) => c.name === 'author_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { contentSeed } from '../src/data/content-seed';
|
||||
import { makeContentStore, syncSeed, resolveContent } from '../src/lib/content';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
|
||||
describe('seed', () => {
|
||||
it('tag univoci, validi e valori non vuoti', () => {
|
||||
const tags = contentSeed.map((e) => e.tag);
|
||||
expect(new Set(tags).size).toBe(tags.length);
|
||||
for (const e of contentSeed) {
|
||||
expect(e.tag).toMatch(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/);
|
||||
if (e.type !== 'image') expect(e.value.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('createDb sincronizza il seed senza sovrascrivere modifiche', () => {
|
||||
const n = (db.prepare('SELECT COUNT(*) AS n FROM content_blocks').get() as { n: number }).n;
|
||||
expect(n).toBe(contentSeed.length);
|
||||
db.prepare(`UPDATE content_blocks SET value = 'MODIFICATO' WHERE tag = ?`).run(contentSeed[0].tag);
|
||||
// ri-sync (come a un nuovo avvio) non deve toccare la modifica
|
||||
syncSeed(db);
|
||||
const v = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(contentSeed[0].tag) as { value: string };
|
||||
expect(v.value).toBe('MODIFICATO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content store', () => {
|
||||
it('legge dal DB con classi preset e cache invalidabile', () => {
|
||||
const store = makeContentStore(() => db);
|
||||
const tag = contentSeed[0].tag;
|
||||
db.prepare(`UPDATE content_blocks SET value = 'Nuovo', styles = '{"size":"l"}' WHERE tag = ?`).run(tag);
|
||||
store.invalidate();
|
||||
const r = store.resolve(tag);
|
||||
expect(r.value).toBe('Nuovo');
|
||||
expect(r.classes).toBe('cs-size-l');
|
||||
// senza invalidate la cache resta
|
||||
db.prepare(`UPDATE content_blocks SET value = 'Altro' WHERE tag = ?`).run(tag);
|
||||
expect(store.resolve(tag).value).toBe('Nuovo');
|
||||
store.invalidate();
|
||||
expect(store.resolve(tag).value).toBe('Altro');
|
||||
});
|
||||
|
||||
it('fallback: tag assente nel DB usa il seed, sconosciuto stringa vuota', () => {
|
||||
const store = makeContentStore(() => db);
|
||||
db.prepare('DELETE FROM content_blocks WHERE tag = ?').run(contentSeed[0].tag);
|
||||
store.invalidate();
|
||||
expect(store.resolve(contentSeed[0].tag).value).toBe(contentSeed[0].value);
|
||||
expect(store.resolve('tag.inesistente').value).toBe('');
|
||||
});
|
||||
|
||||
it('styles corrotto nel DB viene ignorato', () => {
|
||||
const store = makeContentStore(() => db);
|
||||
db.prepare(`UPDATE content_blocks SET styles = 'non-json' WHERE tag = ?`).run(contentSeed[0].tag);
|
||||
store.invalidate();
|
||||
expect(store.resolve(contentSeed[0].tag).classes).toBe('');
|
||||
});
|
||||
|
||||
it('resolveContent restituisce il guid dal DB', () => {
|
||||
const r = resolveContent('home.hero.cta');
|
||||
expect(typeof r.guid).toBe('string');
|
||||
expect(r.guid.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// Il sito nomina l'area riservata in UN posto solo (l'header). Dopo la separazione quel
|
||||
// link esce dal dominio, quindi non lo protegge piu' nessuna rotta interna: se punta a un
|
||||
// host che non esiste, il menu del sito porta a un errore di DNS e nessun test se ne
|
||||
// accorge. Qui si verifica che sia l'host in uso, non quello previsto per il futuro.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const header = readFileSync(join(process.cwd(), 'src/components/Header.astro'), 'utf8');
|
||||
|
||||
describe("il link all'area riservata", () => {
|
||||
it('punta a un host che esiste davvero oggi', () => {
|
||||
expect(header).toContain('https://piattaforme.tielogic.xyz');
|
||||
// apps.insanitylab.it e' l'indirizzo definitivo, ma il record A su Aruba non c'e'
|
||||
// ancora: finche' non c'e', qui sarebbe un link morto dentro al menu del sito.
|
||||
expect(header).not.toContain('https://apps.insanitylab.it');
|
||||
});
|
||||
|
||||
it('non prova piu a costruire un menu delle piattaforme dentro al sito', () => {
|
||||
// Le tre piattaforme non vivono piu' qui: un menu che le elenca sarebbe un elenco di
|
||||
// rotte inesistenti, servite dal sito come 404.
|
||||
expect(header).not.toContain("data/piattaforme");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// L'indirizzo della pagina evento e' stampato dentro il QR code delle locandine affisse
|
||||
// in giro per Bologna: una volta in strada non si corregge piu'. Questi test tengono
|
||||
// insieme le tre cose che devono restare d'accordo — la rotta servita dal sito, l'URL
|
||||
// cucito nel QR e il file che la pagina offre in download.
|
||||
const ROTTA = 'src/pages/promo-eventi/eventi.astro';
|
||||
const URL_STAMPATO = 'https://insanitylab.it/promo-eventi/eventi';
|
||||
|
||||
describe('locandina In-Sanity Hour', () => {
|
||||
it('la rotta che il QR indica esiste', () => {
|
||||
expect(existsSync(ROTTA)).toBe(true);
|
||||
});
|
||||
|
||||
it('il generatore della locandina punta a quella rotta', () => {
|
||||
const script = readFileSync('scripts/locandina-evento.py', 'utf-8');
|
||||
expect(script).toContain(`URL_EVENTO = '${URL_STAMPATO}'`);
|
||||
// La rotta del file deve corrispondere alla coda dell'URL, altrimenti il QR
|
||||
// porterebbe altrove: /promo-eventi/eventi <- src/pages/promo-eventi/eventi.astro
|
||||
const daFile = '/' + ROTTA.replace('src/pages/', '').replace('.astro', '');
|
||||
expect(URL_STAMPATO.endsWith(daFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('la locandina che la pagina offre in download esiste', () => {
|
||||
const pagina = readFileSync(ROTTA, 'utf-8');
|
||||
const link = pagina.match(/href="(\/locandina-[^"]+\.pdf)"/);
|
||||
expect(link).not.toBeNull();
|
||||
expect(existsSync(`public${link![1]}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { services, getService } from '../src/data/services';
|
||||
import { trainings, trainingDaMostrare, VISTE_TRAINING } from '../src/data/trainings';
|
||||
import { contentSeed } from '../src/data/content-seed';
|
||||
import { site } from '../src/data/site';
|
||||
import { PAGINE_CON_CALENDARIO, CARD_CON_CALENDARIO } from '../src/data/calendari';
|
||||
import { rinominaSmallGroup, calcola, applica, TESTI } from '../scripts/testi-17-agosto.mjs';
|
||||
|
||||
// Le modifiche chieste dal cliente il 17/08/2026 (MODIFICHE_PER_SITO.pdf).
|
||||
|
||||
describe('Small Group è diventato Personalized Training Group', () => {
|
||||
it('non resta traccia del vecchio nome nei dati né nel seed', () => {
|
||||
const testi = [
|
||||
...services.flatMap((s) => [s.title, s.subtitle, s.excerpt, s.longDescription, ...s.features, ...s.faq.flatMap((f) => [f.q, f.a])]),
|
||||
...trainings.flatMap((t) => [t.title, t.caption, t.description]),
|
||||
...site.footerTraining.map((v) => v.label),
|
||||
...contentSeed.map((e) => e.value),
|
||||
];
|
||||
expect(testi.filter((v) => /small group/i.test(v))).toEqual([]);
|
||||
});
|
||||
|
||||
// Lo slug resta `small-group`: è l'indirizzo della scheda, e cambiarlo romperebbe i link
|
||||
// già girati su WhatsApp senza che nessuno se ne accorga.
|
||||
it('lo slug della scheda non cambia', () => {
|
||||
expect(getService('small-group')?.title).toBe('Personalized Training Group');
|
||||
});
|
||||
|
||||
it('gli articoli restano corretti, e passarci due volte non cambia niente', () => {
|
||||
const casi: [string, string][] = [
|
||||
['Lo Small Group di InsanityLab è un allenamento', 'Il Personalized Training Group di InsanityLab è un allenamento'],
|
||||
['nuovi format come gli small group e una', 'nuovi format come i Personalized Training Group e una'],
|
||||
['One to One / Small Group / Rehab', 'One to One / Personalized Training Group / Rehab'],
|
||||
['Small Groups', 'Personalized Training Group'],
|
||||
['in formula small group (massimo 4 persone)', 'in formula Personalized Training Group (massimo 4 persone)'],
|
||||
];
|
||||
for (const [prima, dopo] of casi) {
|
||||
expect(rinominaSmallGroup(prima)).toBe(dopo);
|
||||
expect(rinominaSmallGroup(dopo)).toBe(dopo);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('lo script porta i testi nuovi in un database già scritto', () => {
|
||||
it('aggiorna le righe vecchie e al secondo giro non ha più niente da fare', () => {
|
||||
const db = createDb(':memory:');
|
||||
const scrivi = db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
|
||||
scrivi.run('service.one-to-one.subtitle', 'text', 'One to One / Small Group / Rehab');
|
||||
scrivi.run('service.profilazione.subtitle', 'text', 'Profilazione e valutazione fisica');
|
||||
scrivi.run('footer.services.4.label', 'text', 'Profilazione');
|
||||
|
||||
const cambi = applica(db);
|
||||
const dopo = (tag: string) => (db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(tag) as { value: string }).value;
|
||||
expect(dopo('service.one-to-one.subtitle')).toBe('One to One / Personalized Training Group / Rehab');
|
||||
expect(dopo('service.profilazione.subtitle')).toBe('Base e avanzato');
|
||||
expect(dopo('footer.services.4.label')).toBe('Check-up');
|
||||
expect(cambi.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
expect(calcola(db)).toEqual([]);
|
||||
});
|
||||
|
||||
// Un `--dry-run` che scrivesse renderebbe inutile guardare prima di toccare la produzione.
|
||||
it('con --dry-run non tocca niente', () => {
|
||||
const db = createDb(':memory:');
|
||||
db.prepare('INSERT OR REPLACE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)')
|
||||
.run('promo.card.2.title', 'text', 'Small Group');
|
||||
expect(applica(db, { dryRun: true }).length).toBe(1);
|
||||
expect((db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('promo.card.2.title') as { value: string }).value)
|
||||
.toBe('Small Group');
|
||||
});
|
||||
|
||||
it('i testi riscritti a mano corrispondono a quelli del seed', () => {
|
||||
const seed = new Map(contentSeed.map((e) => [e.tag, e.value]));
|
||||
for (const [tag, valore] of Object.entries(TESTI)) expect(seed.get(tag)).toBe(valore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viste di /training', () => {
|
||||
it('senza vista si vedono tutti i training', () => {
|
||||
expect(trainingDaMostrare()?.map((t) => t.id)).toEqual(trainings.map((t) => t.id));
|
||||
});
|
||||
|
||||
it('One to One porta a One to One, Personalized Training Group e Rehab', () => {
|
||||
expect(trainingDaMostrare('one-to-one')?.map((t) => t.id)).toEqual(['one-to-one', 'small-groups', 'rehab']);
|
||||
});
|
||||
|
||||
it('Class porta a Holistic e Reformer', () => {
|
||||
expect(trainingDaMostrare('class')?.map((t) => t.id)).toEqual(['holistic-class', 'reformer-class']);
|
||||
});
|
||||
|
||||
// Un filtro sbagliato che mostra tutto farebbe passare per buono un indirizzo rotto.
|
||||
it('una vista inventata è 404, non l\'elenco intero', () => {
|
||||
expect(trainingDaMostrare('inesistente')).toBeNull();
|
||||
});
|
||||
|
||||
it('ogni vista nomina solo training che esistono', () => {
|
||||
for (const v of Object.values(VISTE_TRAINING)) {
|
||||
for (const id of v.blocchi) expect(trainings.some((t) => t.id === id)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('instradamento delle card di /services', () => {
|
||||
const href = (slug: string) => { const s = getService(slug)!; return s.href ?? `/services/${s.slug}`; };
|
||||
|
||||
it('One to One e Class portano alle viste di training', () => {
|
||||
expect(href('one-to-one')).toBe('/training/one-to-one');
|
||||
expect(href('performance-class')).toBe('/training/class');
|
||||
});
|
||||
|
||||
it('Check-up porta alla propria scheda', () => {
|
||||
expect(href('profilazione')).toBe('/services/profilazione');
|
||||
});
|
||||
|
||||
// La Performance Class non si eroga più: la card resta, la scheda che la spiegava no.
|
||||
it('la Performance Class non ha più una scheda ma resta in griglia', () => {
|
||||
expect(getService('performance-class')?.page).toBe(false);
|
||||
expect(getService('performance-class')?.grid).not.toBe(false);
|
||||
});
|
||||
|
||||
it('nessun link del sito manda a una scheda che non esiste', () => {
|
||||
const schede = new Set(services.filter((s) => s.page !== false).map((s) => `/services/${s.slug}`));
|
||||
const interni = [...site.footerServices, ...site.footerTraining, ...site.navigation]
|
||||
.map((v) => v.href)
|
||||
.filter((h) => h.startsWith('/services/'));
|
||||
for (const h of interni) expect(schede.has(h)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check-up', () => {
|
||||
const checkup = getService('profilazione')!;
|
||||
|
||||
it('il trafiletto della card racconta Base e Avanzato', () => {
|
||||
expect(checkup.title).toBe('Check-up');
|
||||
expect(checkup.subtitle).toBe('Base e avanzato');
|
||||
expect(checkup.excerpt).toContain('Check-up Base');
|
||||
expect(checkup.excerpt).toContain('Check-up Avanzato');
|
||||
});
|
||||
|
||||
it('la scheda porta la sezione avanzata con i suoi cinque punti', () => {
|
||||
expect(checkup.extra?.title).toBe('Check-up Avanzato');
|
||||
expect(checkup.extra?.body).toContain('Wellness Tower');
|
||||
expect(checkup.extra?.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
// Tolta il 19/08 su richiesta del cliente: restava in fondo alla scheda e diceva cose che
|
||||
// la sezione Avanzato racconta meglio.
|
||||
it('la domanda «Che cosa comprende?» non c\'è più', () => {
|
||||
expect(checkup.faq).toHaveLength(1);
|
||||
expect(checkup.faq.map((f) => f.q)).not.toContain('Che cosa comprende?');
|
||||
});
|
||||
|
||||
it('i testi della sezione avanzata sono modificabili dal pannello', () => {
|
||||
const tags = new Set(contentSeed.map((e) => e.tag));
|
||||
expect(tags.has('service.profilazione.extra.title')).toBe(true);
|
||||
expect(tags.has('service.profilazione.extra.body')).toBe(true);
|
||||
for (let i = 1; i <= 5; i++) expect(tags.has(`service.profilazione.extra.item.${i}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendario delle class', () => {
|
||||
// Il calendario si apre da due punti diversi: le schede delle class che si fanno, e la
|
||||
// card «Class» della griglia servizi, che ha slug `performance-class`.
|
||||
it('le schede sono Holistic e Reformer, la Performance Class è fuori', () => {
|
||||
expect(PAGINE_CON_CALENDARIO).toEqual(['holistic-class', 'reformer-class']);
|
||||
});
|
||||
|
||||
it('in griglia il bottone sta sulla card Class', () => {
|
||||
expect(CARD_CON_CALENDARIO).toEqual(['performance-class']);
|
||||
});
|
||||
|
||||
it('ogni slug nominato esiste davvero', () => {
|
||||
for (const slug of [...PAGINE_CON_CALENDARIO, ...CARD_CON_CALENDARIO]) expect(getService(slug)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promo ed eventi', () => {
|
||||
it('i testi della pagina di scelta e degli eventi sono nel seed', () => {
|
||||
const tags = new Set(contentSeed.map((e) => e.tag));
|
||||
for (const tag of ['promo-eventi.hero.title', 'promo-eventi.promo.label', 'promo-eventi.eventi.label',
|
||||
'eventi.hero.title', 'eventi.inaugurazione.title', 'eventi.inaugurazione.body']) {
|
||||
expect(tags.has(tag)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// I tag `eventi.*` restano nel seed — in produzione le righe esistono già — ma dal
|
||||
// commit della landing dell'In-Sanity Hour non alimentano piu' nessuna pagina: la data
|
||||
// vive nel suo tag, non nel titolo, da quando i due sono stati separati.
|
||||
it('la data dell\'inaugurazione sta nel tag della data', () => {
|
||||
expect(contentSeed.find((e) => e.tag === 'eventi.inaugurazione.date')!.value).toContain('19 settembre 2026');
|
||||
});
|
||||
|
||||
// La voce di menu continua a puntare qui: è l'indirizzo già girato ai clienti.
|
||||
it('il menu punta alla pagina di scelta', () => {
|
||||
expect(site.navigation.some((v) => v.href === '/promo-eventi')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import {
|
||||
createPost, trashPost, restorePost, deletePost, getPostById, listAllPosts, listByAuthor,
|
||||
listTrashed, listPublished, listRecent, listArchiveMonths, getPublishedBySlug,
|
||||
countPublishedByCategory,
|
||||
} from '../src/lib/posts';
|
||||
|
||||
let db: Database.Database;
|
||||
let id: number;
|
||||
|
||||
const articolo = (slug: string, authorId: number | null = 1) => ({
|
||||
title: `Titolo ${slug}`, slug, category: 'training', excerpt: 'x',
|
||||
body_html: '<p>x</p>', draft: false, author_id: authorId,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb(':memory:');
|
||||
id = createPost(db, articolo('uno'));
|
||||
createPost(db, articolo('due', 2));
|
||||
});
|
||||
|
||||
describe('cestino', () => {
|
||||
it('cestinare non cancella: l\'articolo resta leggibile per id', () => {
|
||||
trashPost(db, id);
|
||||
const p = getPostById(db, id);
|
||||
expect(p).toBeDefined();
|
||||
expect(p!.deleted_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lo toglie da tutti gli elenchi del pannello', () => {
|
||||
trashPost(db, id);
|
||||
expect(listAllPosts(db).map((p) => p.slug)).toEqual(['due']);
|
||||
expect(listByAuthor(db, 1)).toEqual([]);
|
||||
expect(listTrashed(db).map((p) => p.slug)).toEqual(['uno']);
|
||||
});
|
||||
|
||||
it('lo toglie dal sito pubblico: elenco, recenti, archivio e pagina singola', () => {
|
||||
trashPost(db, id);
|
||||
expect(listPublished(db).items.map((p) => p.slug)).toEqual(['due']);
|
||||
expect(listPublished(db).total).toBe(1);
|
||||
expect(listPublished(db, { category: 'training' }).total).toBe(1);
|
||||
expect(listRecent(db).map((p) => p.slug)).toEqual(['due']);
|
||||
expect(listArchiveMonths(db).reduce((n, m) => n + m.count, 0)).toBe(1);
|
||||
expect(getPublishedBySlug(db, 'uno')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('il cestino di un autore contiene solo i suoi articoli', () => {
|
||||
trashPost(db, id);
|
||||
expect(listTrashed(db, 1).map((p) => p.slug)).toEqual(['uno']);
|
||||
expect(listTrashed(db, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it('il ripristino lo rimette dov\'era', () => {
|
||||
trashPost(db, id);
|
||||
restorePost(db, id);
|
||||
expect(getPostById(db, id)!.deleted_at).toBeNull();
|
||||
expect(listTrashed(db)).toEqual([]);
|
||||
expect(listAllPosts(db).map((p) => p.slug)).toContain('uno');
|
||||
expect(getPublishedBySlug(db, 'uno')).toBeDefined();
|
||||
});
|
||||
|
||||
it('i conteggi per categoria escludono cestino e bozze', () => {
|
||||
expect(countPublishedByCategory(db)).toEqual({ training: 2 });
|
||||
trashPost(db, id);
|
||||
expect(countPublishedByCategory(db)).toEqual({ training: 1 });
|
||||
restorePost(db, id);
|
||||
expect(countPublishedByCategory(db)).toEqual({ training: 2 });
|
||||
});
|
||||
|
||||
it('la cancellazione definitiva rimuove davvero la riga', () => {
|
||||
trashPost(db, id);
|
||||
deletePost(db, id);
|
||||
expect(getPostById(db, id)).toBeUndefined();
|
||||
expect(listTrashed(db)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { canModifyPost } from '../src/lib/posts';
|
||||
|
||||
describe('canModifyPost', () => {
|
||||
const own = { author_id: 7 };
|
||||
const other = { author_id: 8 };
|
||||
const orphan = { author_id: null };
|
||||
|
||||
it('admin e superuser modificano qualsiasi post', () => {
|
||||
for (const role of ['admin', 'superuser']) {
|
||||
expect(canModifyPost(role, 7, other)).toBe(true);
|
||||
expect(canModifyPost(role, 7, orphan)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('user modifica solo i propri', () => {
|
||||
expect(canModifyPost('user', 7, own)).toBe(true);
|
||||
expect(canModifyPost('user', 7, other)).toBe(false);
|
||||
expect(canModifyPost('user', 7, orphan)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import {
|
||||
createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
|
||||
listPublished, listAllPosts, listRecent, listArchiveMonths, listByAuthor, type PostInput,
|
||||
} from '../src/lib/posts';
|
||||
import { createUser, getUsernameById, listUsers } from '../src/lib/auth';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
|
||||
const base: PostInput = {
|
||||
title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi',
|
||||
excerpt: 'estratto', body_html: '<p>corpo</p>', cover: null, draft: false,
|
||||
};
|
||||
|
||||
describe('posts repository', () => {
|
||||
it('crea e rilegge un post pubblicato', () => {
|
||||
const id = createPost(db, base);
|
||||
const post = getPostById(db, id)!;
|
||||
expect(post.title).toBe('Titolo');
|
||||
expect(post.draft).toBe(0);
|
||||
expect(post.published_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('bozza non ha published_at e non appare nelle liste pubbliche', () => {
|
||||
const id = createPost(db, { ...base, slug: 'bozza', draft: true });
|
||||
expect(getPostById(db, id)!.published_at).toBeNull();
|
||||
expect(getPublishedBySlug(db, 'bozza')).toBeUndefined();
|
||||
expect(listPublished(db).total).toBe(0);
|
||||
expect(listAllPosts(db)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('slug duplicato lancia', () => {
|
||||
createPost(db, base);
|
||||
expect(() => createPost(db, base)).toThrow();
|
||||
});
|
||||
|
||||
it('update imposta published_at alla prima pubblicazione e lo conserva', () => {
|
||||
const id = createPost(db, { ...base, draft: true });
|
||||
updatePost(db, id, { ...base, draft: false });
|
||||
const first = getPostById(db, id)!.published_at;
|
||||
expect(first).not.toBeNull();
|
||||
updatePost(db, id, { ...base, title: 'Nuovo', draft: false });
|
||||
expect(getPostById(db, id)!.published_at).toBe(first);
|
||||
expect(getPostById(db, id)!.title).toBe('Nuovo');
|
||||
});
|
||||
|
||||
it('listPublished filtra per categoria e pagina', () => {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
createPost(db, { ...base, slug: `post-${i}`, category: i % 2 ? 'Contenuti educativi' : 'Eventi & Presidi' });
|
||||
}
|
||||
const all = listPublished(db, { page: 1, perPage: 9 });
|
||||
expect(all.total).toBe(12);
|
||||
expect(all.items).toHaveLength(9);
|
||||
const edu = listPublished(db, { category: 'Contenuti educativi' });
|
||||
expect(edu.total).toBe(6);
|
||||
});
|
||||
|
||||
it('delete rimuove', () => {
|
||||
const id = createPost(db, base);
|
||||
deletePost(db, id);
|
||||
expect(getPostById(db, id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('listRecent e archivio mesi', () => {
|
||||
createPost(db, base);
|
||||
expect(listRecent(db, 4)).toHaveLength(1);
|
||||
const months = listArchiveMonths(db);
|
||||
expect(months).toHaveLength(1);
|
||||
expect(months[0].count).toBe(1);
|
||||
expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autore articoli', () => {
|
||||
it('createPost salva author_id e listByAuthor filtra', () => {
|
||||
const aliceId = createUser(db, 'alice', 'segreta123', 'user');
|
||||
const bobId = createUser(db, 'bob', 'segreta123', 'user');
|
||||
createPost(db, { ...base, slug: 'a1', author_id: aliceId });
|
||||
createPost(db, { ...base, slug: 'a2', author_id: aliceId });
|
||||
createPost(db, { ...base, slug: 'b1', author_id: bobId });
|
||||
expect(listByAuthor(db, aliceId).length).toBe(2);
|
||||
expect(listByAuthor(db, bobId).length).toBe(1);
|
||||
});
|
||||
|
||||
it('post senza author_id ha author_id null', () => {
|
||||
const id = createPost(db, { ...base, slug: 'orfano' });
|
||||
expect(getPostById(db, id)!.author_id).toBeNull();
|
||||
});
|
||||
|
||||
it('getUsernameById e listUsers', () => {
|
||||
const id = createUser(db, 'carla', 'segreta123', 'superuser');
|
||||
expect(getUsernameById(db, id)).toBe('carla');
|
||||
expect(getUsernameById(db, 99999)).toBeNull();
|
||||
expect(listUsers(db).some((u) => u.username === 'carla' && u.role === 'superuser')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { edizioni, edizioneAttiva, tagEdizione, type Edizione } from '../src/data/promo-edizioni';
|
||||
import { seedByTag } from '../src/data/content-seed';
|
||||
|
||||
const d = (iso: string) => new Date(iso);
|
||||
|
||||
describe('edizioneAttiva', () => {
|
||||
it('sceglie l\'edizione la cui finestra contiene l\'istante', () => {
|
||||
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'))?.id).toBe('agosto-2026');
|
||||
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'))?.id).toBe('bundle-apertura');
|
||||
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'))?.id).toBe('settembre-2026');
|
||||
});
|
||||
|
||||
it('fuori da ogni finestra non c\'è edizione attiva', () => {
|
||||
expect(edizioneAttiva(d('2026-07-15T12:00:00+02:00'))).toBeNull();
|
||||
expect(edizioneAttiva(d('2026-09-25T12:00:00+02:00'))).toBeNull();
|
||||
});
|
||||
|
||||
// Il passaggio è il momento in cui il sito cambia da solo: va provato al secondo, e in ora
|
||||
// italiana. Il server gira in UTC, quindi un confronto fatto sull'orologio locale della
|
||||
// macchina anticiperebbe (o ritarderebbe) lo scambio di due ore.
|
||||
it('scambia le edizioni al secondo giusto, in ora italiana', () => {
|
||||
expect(edizioneAttiva(d('2026-08-19T23:59:59+02:00'))?.id).toBe('agosto-2026');
|
||||
expect(edizioneAttiva(d('2026-08-20T00:00:00+02:00'))?.id).toBe('bundle-apertura');
|
||||
expect(edizioneAttiva(d('2026-09-04T23:59:59+02:00'))?.id).toBe('bundle-apertura');
|
||||
expect(edizioneAttiva(d('2026-09-05T00:00:00+02:00'))?.id).toBe('settembre-2026');
|
||||
expect(edizioneAttiva(d('2026-09-19T23:59:59+02:00'))?.id).toBe('settembre-2026');
|
||||
expect(edizioneAttiva(d('2026-09-20T00:00:00+02:00'))).toBeNull();
|
||||
});
|
||||
|
||||
it('l\'ordine dell\'elenco non cambia il risultato', () => {
|
||||
const rovescio = [...edizioni].reverse();
|
||||
expect(edizioneAttiva(d('2026-08-15T12:00:00+02:00'), rovescio)?.id).toBe('agosto-2026');
|
||||
expect(edizioneAttiva(d('2026-08-25T12:00:00+02:00'), rovescio)?.id).toBe('bundle-apertura');
|
||||
expect(edizioneAttiva(d('2026-09-10T12:00:00+02:00'), rovescio)?.id).toBe('settembre-2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerenza delle edizioni configurate', () => {
|
||||
const ordinate = [...edizioni].sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
|
||||
|
||||
it('ogni finestra è valida e ha un verso', () => {
|
||||
for (const e of edizioni) {
|
||||
expect(Number.isNaN(Date.parse(e.from)), `${e.id}: from illeggibile`).toBe(false);
|
||||
expect(Number.isNaN(Date.parse(e.to)), `${e.id}: to illeggibile`).toBe(false);
|
||||
expect(Date.parse(e.to), `${e.id}: finisce prima di cominciare`).toBeGreaterThan(Date.parse(e.from));
|
||||
}
|
||||
});
|
||||
|
||||
// Due promozioni insieme sul sito è il difetto peggiore: prezzi diversi per la stessa cosa,
|
||||
// e nessuno sa quale vale. Nessun buco, invece, evita il giorno in cui la pagina non offre
|
||||
// niente senza che nessuno se ne accorga: se un buco è voluto va scritto qui, non subìto.
|
||||
it('le finestre non si sovrappongono e non lasciano buchi', () => {
|
||||
for (let i = 1; i < ordinate.length; i++) {
|
||||
const prima = ordinate[i - 1];
|
||||
const dopo = ordinate[i];
|
||||
const stacco = Date.parse(dopo.from) - Date.parse(prima.to);
|
||||
expect(stacco, `${prima.id} e ${dopo.id} si sovrappongono`).toBeGreaterThan(0);
|
||||
expect(stacco, `fra ${prima.id} e ${dopo.id} c'è un buco`).toBeLessThanOrEqual(1000);
|
||||
}
|
||||
});
|
||||
|
||||
// Il countdown è la promessa fatta al visitatore. Se dice una data e lo scambio ne usa
|
||||
// un'altra, il sito si contraddice da solo: o cambia mentre il contatore corre ancora, o
|
||||
// resta lì a zero. Sono lo stesso numero e devono restare lo stesso numero.
|
||||
it('il countdown scade quando finisce l\'edizione', () => {
|
||||
for (const e of edizioni) {
|
||||
const deadline = seedByTag.get(tagEdizione(e, 'countdown.deadline'))?.value;
|
||||
expect(deadline, `${e.id}: manca il tag della deadline`).toBeTruthy();
|
||||
expect(Date.parse(deadline!), `${e.id}: countdown e fine edizione non coincidono`)
|
||||
.toBe(Date.parse(e.to));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('i testi di ogni edizione esistono', () => {
|
||||
// Un tag che manca non rompe la pagina: `resolve` restituisce stringa vuota e logga un warn
|
||||
// che nessuno legge. In vetrina diventa una card senza prezzo, o un titolo sparito. Meglio
|
||||
// scoprirlo qui che dal cliente.
|
||||
const richiesti = (e: Edizione): string[] => {
|
||||
const base = ['hero.badge', 'hero.title1', 'hero.accent', 'hero.title2', 'hero.desc', 'hero.cta',
|
||||
'buy-label', 'validity-note', 'faq.title',
|
||||
'countdown.label', 'countdown.deadline',
|
||||
'countdown.unit.days', 'countdown.unit.hours', 'countdown.unit.minutes', 'countdown.unit.seconds'];
|
||||
if (e.launch) base.push('launch.badge', 'launch.title', 'launch.accent', 'launch.desc', 'why5.text');
|
||||
for (const c of e.cards) {
|
||||
base.push(`card.${c.id}.eyebrow`, `card.${c.id}.title`, `card.${c.id}.price`);
|
||||
if (c.priceOld) base.push(`card.${c.id}.price-old`);
|
||||
if (c.featured) base.push(`card.${c.id}.badge`);
|
||||
if (c.unit) base.push(`card.${c.id}.unit`);
|
||||
if (c.question) base.push(`card.${c.id}.question`);
|
||||
if (c.desc) base.push(`card.${c.id}.desc`);
|
||||
if (c.note) base.push(`card.${c.id}.note-label`, `card.${c.id}.note`);
|
||||
for (let i = 1; i <= c.features; i++) base.push(`card.${c.id}.feature.${i}`);
|
||||
}
|
||||
for (const n of e.faq) base.push(`faq.${n}.q`, `faq.${n}.a`);
|
||||
return base.map((s) => tagEdizione(e, s));
|
||||
};
|
||||
|
||||
for (const e of edizioni) {
|
||||
it(`${e.id}: tutti i tag sono nel seed`, () => {
|
||||
const mancanti = richiesti(e).filter((tag) => !seedByTag.has(tag));
|
||||
expect(mancanti, `tag mancanti: ${mancanti.join(', ')}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it('lo stato "nessuna promozione" ha i suoi testi', () => {
|
||||
for (const tag of ['promo.chiusa.title', 'promo.chiusa.desc', 'promo.chiusa.cta']) {
|
||||
expect(seedByTag.has(tag), `manca ${tag}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { rateLimit, _resetBuckets } from '../src/lib/rate-limit';
|
||||
|
||||
afterEach(() => { _resetBuckets(); vi.useRealTimers(); });
|
||||
|
||||
describe('rateLimit', () => {
|
||||
it('consente fino a max richieste', () => {
|
||||
expect(rateLimit('a', 2, 1000)).toBe(true);
|
||||
expect(rateLimit('a', 2, 1000)).toBe(true);
|
||||
expect(rateLimit('a', 2, 1000)).toBe(false);
|
||||
});
|
||||
it('chiavi indipendenti', () => {
|
||||
expect(rateLimit('a', 1, 1000)).toBe(true);
|
||||
expect(rateLimit('b', 1, 1000)).toBe(true);
|
||||
});
|
||||
it('la finestra scade', () => {
|
||||
vi.useFakeTimers();
|
||||
expect(rateLimit('a', 1, 1000)).toBe(true);
|
||||
expect(rateLimit('a', 1, 1000)).toBe(false);
|
||||
vi.advanceTimersByTime(1100);
|
||||
expect(rateLimit('a', 1, 1000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { createUser, login, getSessionUser, canAccessAdminPath, landingFor } from '../src/lib/auth';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
|
||||
describe('ruoli', () => {
|
||||
it('createUser accetta i tre ruoli e getSessionUser li ritorna', () => {
|
||||
createUser(db, 'u', 'segreta123', 'user');
|
||||
createUser(db, 's', 'segreta123', 'superuser');
|
||||
expect(getSessionUser(db, login(db, 'u', 'segreta123')!)).toMatchObject({ role: 'user' });
|
||||
expect(getSessionUser(db, login(db, 's', 'segreta123')!)).toMatchObject({ role: 'superuser' });
|
||||
});
|
||||
|
||||
it('createUser senza ruolo crea admin', () => {
|
||||
createUser(db, 'adriano', 'segreta123');
|
||||
expect(getSessionUser(db, login(db, 'adriano', 'segreta123')!)).toMatchObject({ role: 'admin' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccessAdminPath', () => {
|
||||
it('admin accede a tutto', () => {
|
||||
for (const p of ['/admin', '/admin/users', '/admin/content', '/api/admin/posts', '/api/admin/users/1'])
|
||||
expect(canAccessAdminPath('admin', p)).toBe(true);
|
||||
});
|
||||
|
||||
it('superuser: contenuti + blog sì, utenti no', () => {
|
||||
expect(canAccessAdminPath('superuser', '/admin/content')).toBe(true);
|
||||
expect(canAccessAdminPath('superuser', '/admin/content/')).toBe(true);
|
||||
expect(canAccessAdminPath('superuser', '/api/admin/content/home.hero.title')).toBe(true);
|
||||
expect(canAccessAdminPath('superuser', '/admin')).toBe(true);
|
||||
expect(canAccessAdminPath('superuser', '/admin/new')).toBe(true);
|
||||
expect(canAccessAdminPath('superuser', '/api/admin/posts')).toBe(true);
|
||||
expect(canAccessAdminPath('superuser', '/admin/users')).toBe(false);
|
||||
expect(canAccessAdminPath('superuser', '/api/admin/users/1')).toBe(false);
|
||||
});
|
||||
|
||||
it('user: blog sì, contenuti e utenti no', () => {
|
||||
expect(canAccessAdminPath('user', '/admin')).toBe(true);
|
||||
expect(canAccessAdminPath('user', '/admin/new')).toBe(true);
|
||||
expect(canAccessAdminPath('user', '/api/admin/posts')).toBe(true);
|
||||
expect(canAccessAdminPath('user', '/api/admin/upload')).toBe(true);
|
||||
expect(canAccessAdminPath('user', '/admin/logout')).toBe(true);
|
||||
expect(canAccessAdminPath('user', '/admin/content')).toBe(false);
|
||||
expect(canAccessAdminPath('user', '/api/admin/content/x')).toBe(false);
|
||||
expect(canAccessAdminPath('user', '/admin/users')).toBe(false);
|
||||
});
|
||||
|
||||
it('la matrice non confonde prefissi simili', () => {
|
||||
expect(canAccessAdminPath('user', '/admin/contentious')).toBe(true); // NON è /admin/content
|
||||
expect(canAccessAdminPath('superuser', '/admin/users-x')).toBe(true); // NON è /admin/users
|
||||
});
|
||||
});
|
||||
|
||||
describe('landingFor', () => {
|
||||
it('instrada ogni ruolo alla sua home', () => {
|
||||
expect(landingFor('superuser')).toBe('/admin/content');
|
||||
expect(landingFor('user')).toBe('/admin');
|
||||
expect(landingFor('admin')).toBe('/admin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Cosa resta vero in questo sito dopo la separazione del 03/09/2026, quando Campus,
|
||||
// Stress Index e Longevity sono passati a apps/platforms con i loro utenti.
|
||||
// Eredita le due asserzioni ancora valide di tests/longevity/ruoli.test.ts (emigrato) e
|
||||
// aggiunge la guardia sul ciclo di redirect, che è nata proprio da questa separazione.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ROLES, isRole, landingFor, canAccessAdminPath } from '../src/lib/auth';
|
||||
|
||||
describe('ROLES — restano solo i ruoli redazionali', () => {
|
||||
it('i tre ruoli del sito, e nessuno di quelli emigrati', () => {
|
||||
expect([...ROLES].sort()).toEqual(['admin', 'superuser', 'user']);
|
||||
for (const emigrato of ['piattaforme', 'cliente', 'trainer']) expect(isRole(emigrato)).toBe(false);
|
||||
});
|
||||
|
||||
it('isRole accetta esattamente i ruoli di ROLES', () => {
|
||||
for (const r of ROLES) expect(isRole(r)).toBe(true);
|
||||
for (const finto of ['campus', 'Admin', '', 'user ']) expect(isRole(finto)).toBe(false);
|
||||
});
|
||||
|
||||
it('la tendina di admin/users.astro pesca da ROLES, non da una lista propria', () => {
|
||||
const src = readFileSync(join(process.cwd(), 'src/pages/admin/users.astro'), 'utf8');
|
||||
expect(src).toContain('ROLES');
|
||||
});
|
||||
|
||||
it('le descrizioni dei ruoli in users/new.astro non nominano ruoli che non esistono più', () => {
|
||||
const src = readFileSync(join(process.cwd(), 'src/pages/admin/users/new.astro'), 'utf8');
|
||||
for (const emigrato of ['piattaforme', 'cliente', 'trainer']) expect(src).not.toContain(`${emigrato}:`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('⚠️ il ciclo di redirect che la separazione ha reso possibile', () => {
|
||||
it('un ruolo emigrato rimasto in `users` NON può accedere a /admin, ma è lì che atterrerebbe', () => {
|
||||
// Questa coppia è il ciclo: landingFor lo manda su /admin, canAccessAdminPath glielo
|
||||
// nega, il middleware lo rimanda su landingFor. È la ragione della guardia nel login.
|
||||
expect(landingFor('piattaforme')).toBe('/admin');
|
||||
expect(canAccessAdminPath('piattaforme', '/admin')).toBe(false);
|
||||
expect(canAccessAdminPath('cliente', '/admin')).toBe(false);
|
||||
expect(canAccessAdminPath('trainer', '/admin')).toBe(false);
|
||||
});
|
||||
|
||||
it('il login del pannello rifiuta questi account invece di aprire una sessione', () => {
|
||||
const src = readFileSync(join(process.cwd(), 'src/pages/admin/login.astro'), 'utf8');
|
||||
expect(src).toContain('isRole');
|
||||
expect(src).toContain('logout(getDb(), token)');
|
||||
expect(src).toMatch(/apps\.insanitylab\.it/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeHtml } from '../src/lib/sanitize';
|
||||
|
||||
describe('sanitizeHtml', () => {
|
||||
it('mantiene la formattazione consentita', () => {
|
||||
const html = '<h2>Titolo</h2><p>Testo <strong>forte</strong> ed <em>enfasi</em></p><ul><li>voce</li></ul><blockquote>citazione</blockquote>';
|
||||
expect(sanitizeHtml(html)).toBe(html);
|
||||
});
|
||||
it('rimuove script e handler', () => {
|
||||
expect(sanitizeHtml('<p onclick="x()">ciao</p><script>alert(1)</script>')).toBe('<p>ciao</p>');
|
||||
});
|
||||
it('consente immagini locali e https, blocca javascript:', () => {
|
||||
expect(sanitizeHtml('<img src="/uploads/a.jpg">')).toContain('/uploads/a.jpg');
|
||||
expect(sanitizeHtml('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { slugify } from '../src/lib/slug';
|
||||
|
||||
describe('slugify', () => {
|
||||
it('minuscole e trattini', () => {
|
||||
expect(slugify('Il Metodo InsanityLab')).toBe('il-metodo-insanitylab');
|
||||
});
|
||||
it('rimuove accenti', () => {
|
||||
expect(slugify('Attività fisica è già qui')).toBe('attivita-fisica-e-gia-qui');
|
||||
});
|
||||
it('comprime simboli e spazi multipli', () => {
|
||||
expect(slugify('Blog & Edugo -- news!!')).toBe('blog-edugo-news');
|
||||
});
|
||||
it('niente trattini ai bordi', () => {
|
||||
expect(slugify(' ciao ')).toBe('ciao');
|
||||
});
|
||||
it('stringa vuota resta vuota', () => {
|
||||
expect(slugify('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateStyles, stylesToClasses, STYLE_GROUPS } from '../src/lib/style-presets';
|
||||
|
||||
describe('style presets', () => {
|
||||
it('accetta chiavi e valori in whitelist', () => {
|
||||
const r = validateStyles({ size: 'l', color: 'accent', align: 'center' });
|
||||
expect(r).toEqual({ ok: true, styles: { size: 'l', color: 'accent', align: 'center' } });
|
||||
});
|
||||
|
||||
it('rifiuta gruppo sconosciuto e valore fuori lista', () => {
|
||||
expect(validateStyles({ font: 'comic' }).ok).toBe(false);
|
||||
expect(validateStyles({ size: 'xxxl' }).ok).toBe(false);
|
||||
expect(validateStyles('non-oggetto').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('mappa stili in classi cs-*', () => {
|
||||
expect(stylesToClasses({ size: 's', weight: 'bold' })).toBe('cs-size-s cs-weight-bold');
|
||||
expect(stylesToClasses({})).toBe('');
|
||||
});
|
||||
|
||||
it('espone i gruppi attesi', () => {
|
||||
expect(Object.keys(STYLE_GROUPS)).toEqual(['size', 'color', 'weight', 'style', 'align']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import {
|
||||
createUser, countAdmins, getUserById, updateUserRole,
|
||||
updateUserPassword, deleteUser, randomPassword, isRole, hashPassword, verifyPassword,
|
||||
} from '../src/lib/auth';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
|
||||
describe('gestione utenti', () => {
|
||||
it('countAdmins conta i soli admin', () => {
|
||||
createUser(db, 'a1', 'segreta123', 'admin');
|
||||
createUser(db, 's1', 'segreta123', 'superuser');
|
||||
expect(countAdmins(db)).toBe(1);
|
||||
});
|
||||
|
||||
it('updateUserRole e getUserById', () => {
|
||||
const id = createUser(db, 'u1', 'segreta123', 'user');
|
||||
updateUserRole(db, id, 'superuser');
|
||||
expect(getUserById(db, id)!.role).toBe('superuser');
|
||||
});
|
||||
|
||||
it('updateUserPassword cambia l’hash', () => {
|
||||
const id = createUser(db, 'u2', 'vecchia123', 'user');
|
||||
updateUserPassword(db, id, hashPassword('nuova12345'));
|
||||
const hash = (db.prepare('SELECT password_hash AS h FROM users WHERE id = ?').get(id) as { h: string }).h;
|
||||
expect(verifyPassword('nuova12345', hash)).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteUser rimuove', () => {
|
||||
const id = createUser(db, 'u3', 'segreta123', 'user');
|
||||
deleteUser(db, id);
|
||||
expect(getUserById(db, id)).toBeNull();
|
||||
});
|
||||
|
||||
it('randomPassword genera almeno 12 caratteri', () => {
|
||||
expect(randomPassword().length).toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('isRole valida i ruoli', () => {
|
||||
expect(isRole('admin')).toBe(true);
|
||||
expect(isRole('superuser')).toBe(true);
|
||||
expect(isRole('user')).toBe(true);
|
||||
expect(isRole('editor')).toBe(false);
|
||||
expect(isRole(3)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user