From c4107b84a5d38e177cc7f84c3f8dd7b015b6c1b3 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 25 Jul 2026 15:40:07 +0200 Subject: [PATCH] Sezione Campus: layout dedicato, route protette e documentazione Co-Authored-By: Claude Fable 5 --- Dockerfile | 2 + README.md | 29 +++- campus-content/nav.json | 34 ++--- .../specs/2026-07-25-campus-design.md | 9 +- scripts/create-user.mjs | 4 +- scripts/sync-campus.mjs | 24 +-- src/layouts/Admin.astro | 1 + src/layouts/Campus.astro | 137 ++++++++++++++++++ src/lib/campus.ts | 94 ++++++++++++ src/pages/admin/login.astro | 6 +- src/pages/campus/[...slug].astro | 113 +++++++++++++++ src/pages/campus/assets/[...path].ts | 35 +++++ src/pages/campus/index.astro | 43 ++++++ tests/campus-lib.test.ts | 71 +++++++++ tests/campus-parse.test.ts | 15 +- 15 files changed, 565 insertions(+), 52 deletions(-) create mode 100644 src/layouts/Campus.astro create mode 100644 src/lib/campus.ts create mode 100644 src/pages/campus/[...slug].astro create mode 100644 src/pages/campus/assets/[...path].ts create mode 100644 src/pages/campus/index.astro create mode 100644 tests/campus-lib.test.ts diff --git a/Dockerfile b/Dockerfile index a5963b9..b77908e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=build /app/dist ./dist COPY scripts ./scripts +# Contenuti della sezione Campus (generati con `npm run sync-campus` e committati) +COPY campus-content ./campus-content ENV HOST=0.0.0.0 \ PORT=4321 \ diff --git a/README.md b/README.md index 39a46a9..8cdb65b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node - **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina. - **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito. - **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo. -- **Gestione utenti** con tre ruoli (`admin`, `superuser`, `user`). +- **Gestione utenti** con quattro ruoli (`admin`, `superuser`, `user`, `campus`). +- **Campus**: sezione riservata con le guide di studio del corso Biohacking Campus, accessibile solo dopo il login e con navigazione autonoma. - **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP. ## Installazione (sviluppo) @@ -53,7 +54,8 @@ Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato aut | `npm run preview` | Anteprima locale della build | | `npm run start` | Server di produzione (`node --env-file=.env`) | | `npm run test` | Test con Vitest | -| `npm run create-user` | Crea/aggiorna utente: `-- [admin\|superuser\|user]` | +| `npm run create-user` | Crea/aggiorna utente: `-- [admin\|superuser\|user\|campus]` | +| `npm run sync-campus` | Rigenera i contenuti del Campus: `-- ` | ## Gestione contenuti @@ -69,31 +71,41 @@ Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui conten ## Ruoli e accessi -Tre ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`: +Quattro ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`: | Ruolo | Può fare | |-------|----------| -| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) | +| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), Campus | | `superuser` | Pannello contenuti + blog | | `user` | Solo i **propri** articoli del blog | +| `campus` | Solo la sezione riservata `/campus`; nessun accesso al pannello | -Login pubblico su `/login` (`?next` sanitizzato contro open redirect). La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin. +Login pubblico su `/login` (`?next` sanitizzato contro open redirect). Dopo l'autenticazione ogni ruolo viene indirizzato alla propria pagina iniziale: il pannello per admin e user, i contenuti per superuser, il Campus per il ruolo omonimo. La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin. + +## Campus (sezione riservata) + +La sezione `/campus` raccoglie le guide di studio del corso Biohacking Campus. È protetta dal middleware come il pannello: un visitatore anonimo viene mandato al login, un utente con ruolo non abilitato torna alla propria pagina iniziale. Le pagine non montano l'header e il footer del sito — la navigazione avviene attraverso la barra laterale delle aree, con un unico richiamo per tornare alla home — ma usano i font, i colori e il CSS globale di InsanityLab. + +I contenuti non vengono scritti a mano: lo script `npm run sync-campus` legge le guide sorgente (`*.studio.md`) dal progetto Biohacking Campus, le converte in frammenti HTML e produce la cartella `campus-content/` (indice `nav.json`, pagine e immagini delle slide), che va committata perché il deploy la copia nell'immagine Docker. Lo script è idempotente: rigenerarlo dopo l'aggiunta di nuove lezioni aggiorna indice e pagine e rimuove quelle non più presenti. Le nuove guide vanno prima dichiarate nella struttura dei capitoli in `scripts/campus/structure.mjs`. + +Le immagini delle slide sono servite dalla route `/campus/assets/...`, anch'essa protetta: non sono raggiungibili senza una sessione valida. ## Struttura del progetto ``` insanitylab-website/ ├── src/ -│ ├── pages/ # Route Astro (vetrina, /admin, /api) +│ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus) │ ├── components/ # Componenti (home/, content/, Header, Footer, …) -│ ├── layouts/ # Layout Base +│ ├── layouts/ # Layout Base, Admin, Campus │ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed) │ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env │ ├── assets/img/ # Immagini ottimizzate da Astro │ └── styles/ # global.css ├── data/ # SQLite (volume in produzione) ├── uploads/ # Upload immagini (volume in produzione) -├── scripts/ # create-user, smoke test +├── campus-content/ # Contenuti del Campus generati da sync-campus +├── scripts/ # create-user, sync-campus, smoke test ├── astro.config.mjs # Config Astro (site, allowedDomains, redirects) ├── compose.yaml # Stack Docker + label Traefik ├── Dockerfile # Build multi-stage @@ -115,6 +127,7 @@ Il file `.env` (non versionato) richiede: | `CONTACT_TO` | Destinatario dei messaggi del form | | `DB_PATH` | Percorso del database SQLite | | `UPLOADS_DIR` | Cartella degli upload | +| `CAMPUS_DIR` | Cartella dei contenuti del Campus (default `./campus-content`) | La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`. diff --git a/campus-content/nav.json b/campus-content/nav.json index bcf42e2..f4ed405 100644 --- a/campus-content/nav.json +++ b/campus-content/nav.json @@ -915,9 +915,9 @@ "title": "Longevità e Mantenere un Cervello Sano" }, "next": { - "slug": "accelerator-1-2025-11-11", - "area": "live-e-ospiti", - "title": "Accelerator 1" + "slug": "slide-hrv", + "area": "slide", + "title": "HRV: Variabilità della frequenza cardiaca" } } ] @@ -954,9 +954,9 @@ "chapter": 13, "area": "slide", "prev": { - "slug": "lezione-live-4-2026-06-30", - "area": "live-e-ospiti", - "title": "Lezione live 4" + "slug": "lezione-25-focus-microbiota-plus", + "area": "corso-base", + "title": "FOCUS Microbiota Plus" }, "next": { "slug": "slide-dna", @@ -1127,9 +1127,9 @@ "title": "Focus Ormoni (Dott.ssa Smeazzetto)" }, "next": { - "slug": "speaker-dna-focus-moduli", + "slug": "accelerator-1-2025-11-11", "area": "live-e-ospiti", - "title": "DNA: Focus modulo per modulo" + "title": "Accelerator 1" } } ] @@ -1166,9 +1166,9 @@ "chapter": 11, "area": "live-e-ospiti", "prev": { - "slug": "lezione-25-focus-microbiota-plus", - "area": "corso-base", - "title": "FOCUS Microbiota Plus" + "slug": "slide-focus-microbiota-plus", + "area": "slide", + "title": "Focus Microbiota PLUS" }, "next": { "slug": "accelerator-2-2025-12-20", @@ -1459,9 +1459,9 @@ "title": "Lezione live 3" }, "next": { - "slug": "slide-hrv", - "area": "slide", - "title": "HRV: Variabilità della frequenza cardiaca" + "slug": "speaker-dna-focus-moduli", + "area": "live-e-ospiti", + "title": "DNA: Focus modulo per modulo" } } ] @@ -1490,9 +1490,9 @@ "chapter": 14, "area": "live-e-ospiti", "prev": { - "slug": "slide-focus-microbiota-plus", - "area": "slide", - "title": "Focus Microbiota PLUS" + "slug": "lezione-live-4-2026-06-30", + "area": "live-e-ospiti", + "title": "Lezione live 4" }, "next": { "slug": "speaker-seipel-campi-elettromagnetici", diff --git a/docs/superpowers/specs/2026-07-25-campus-design.md b/docs/superpowers/specs/2026-07-25-campus-design.md index beaa35c..9798bb5 100644 --- a/docs/superpowers/specs/2026-07-25-campus-design.md +++ b/docs/superpowers/specs/2026-07-25-campus-design.md @@ -35,8 +35,9 @@ un ruolo dedicato. Le pagine devono adottare lo stile grafico del sito oggi per short-circuit). - Middleware: `/campus/*` diventa percorso protetto (oltre a `/admin`, `/api/admin`). Non autenticato → redirect a `/admin/login`. -- `landingFor('campus')` → `/campus` (dopo il login l'utente campus atterra - direttamente sulla sezione). +- `landingFor('campus')` → `/campus`. La pagina di login rimandava sempre a + `/admin`: ora usa `landingFor` sul ruolo dell'utente appena autenticato, + così ogni ruolo atterra su una pagina che può davvero aprire. - Il ruolo `campus` NON deve accedere ai percorsi comuni dei loggati del pannello (blog admin, upload): `canAccessAdminPath` ritorna `false` per `campus` su `/admin/*` e `/api/admin/*` non esplicitamente consentiti. @@ -54,7 +55,9 @@ un ruolo dedicato. Le pagine devono adottare lo stile grafico del sito riferimenti alle immagini slide verso `/campus/assets/...`. - Output in `campus-content/` (root repo, committato): - `nav.json` — aree, capitoli, lezioni ordinate (slug, titolo, sottotitolo, - docente, prev/next); + docente, prev/next). La catena prev/next segue l'ordine di navigazione + aree → capitoli → guide, non la numerazione dei capitoli: le aree li + raggruppano in un ordine diverso; - `pages/.html` — frammento HTML del corpo di ogni guida; - `assets/` — immagini slide copiate (~37 MB, commit una tantum). - Idempotente: rigenera tutto e rimuove i file orfani. diff --git a/scripts/create-user.mjs b/scripts/create-user.mjs index 4690813..0dca403 100644 --- a/scripts/create-user.mjs +++ b/scripts/create-user.mjs @@ -8,8 +8,8 @@ if (!username || !password) { console.error('Uso: npm run create-user -- [role]'); process.exit(1); } -if (!['admin', 'superuser', 'user'].includes(role)) { - console.error('Ruolo non valido. Ammessi: admin, superuser, user'); +if (!['admin', 'superuser', 'user', 'campus'].includes(role)) { + console.error('Ruolo non valido. Ammessi: admin, superuser, user, campus'); process.exit(1); } const path = process.env.DB_PATH ?? 'data/insanitylab.db'; diff --git a/scripts/sync-campus.mjs b/scripts/sync-campus.mjs index e668ecb..436704f 100644 --- a/scripts/sync-campus.mjs +++ b/scripts/sync-campus.mjs @@ -63,7 +63,6 @@ async function main() { // 1. parsing di tutte le guide, capitolo per capitolo const chapters = []; const written = new Set(); - const flat = []; // ordine globale di lettura, per prev/next for (const [num, title, sub, slugs] of CHAPTERS) { const lessons = []; for (const [i, slug] of slugs.entries()) { @@ -81,27 +80,28 @@ async function main() { chapter: num, }; lessons.push(entry); - flat.push(entry); } chapters.push({ num, title, sub, color: CH_COLORS[num - 1], lessons }); } - // 2. prev/next lungo l'ordine globale, e area di appartenenza - const areaOf = new Map(); - for (const [slug, , , , chapterNums] of AREAS) - for (const n of chapterNums) areaOf.set(n, slug); - flat.forEach((l, i) => { - l.area = areaOf.get(l.chapter); - l.prev = flat[i - 1] ? { slug: flat[i - 1].slug, area: areaOf.get(flat[i - 1].chapter), title: flat[i - 1].titleShort } : null; - l.next = flat[i + 1] ? { slug: flat[i + 1].slug, area: areaOf.get(flat[i + 1].chapter), title: flat[i + 1].titleShort } : null; - }); - const byNum = new Map(chapters.map((c) => [c.num, c])); const areas = AREAS.map(([slug, title, sub, color, chapterNums]) => ({ slug, title, sub, color, chapters: chapterNums.map((n) => byNum.get(n)).filter(Boolean), })); + // 2. prev/next nell'ordine in cui l'utente naviga: aree → capitoli → guide, + // che non coincide con l'ordine dei capitoli (le aree li raggruppano). + const flat = []; + for (const area of areas) + for (const ch of area.chapters) + for (const l of ch.lessons) { l.area = area.slug; flat.push(l); } + const linkTo = (l) => (l ? { slug: l.slug, area: l.area, title: l.titleShort } : null); + flat.forEach((l, i) => { + l.prev = linkTo(flat[i - 1]); + l.next = linkTo(flat[i + 1]); + }); + await writeFile(join(OUT_DIR, 'nav.json'), JSON.stringify({ areas, count: flat.length }, null, 2)); // 3. rimozione dei frammenti orfani diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro index ab98680..fec9104 100644 --- a/src/layouts/Admin.astro +++ b/src/layouts/Admin.astro @@ -37,6 +37,7 @@ const user = Astro.locals.user; Articoli {(user.role === 'superuser' || user.role === 'admin') && Contenuti} {user.role === 'admin' && Utenti} + {user.role === 'admin' && Campus} Nuovo Esci Vedi sito ↗ diff --git a/src/layouts/Campus.astro b/src/layouts/Campus.astro new file mode 100644 index 0000000..493a194 --- /dev/null +++ b/src/layouts/Campus.astro @@ -0,0 +1,137 @@ +--- +// Layout della sezione riservata Campus. Usa i token e i font del sito, ma +// non ne monta Header e Footer: la navigazione è tutta interna, con un solo +// richiamo alla home. +import '@fontsource/montserrat/400.css'; +import '@fontsource/montserrat/500.css'; +import '@fontsource/montserrat/600.css'; +import '@fontsource/open-sans/400.css'; +import '@fontsource/open-sans/600.css'; +import '../styles/global.css'; +import type { CampusNav } from '../lib/campus'; + +interface Props { + title: string; + nav: CampusNav; + currentArea?: string; + currentLesson?: string; +} +const { title, nav, currentArea, currentLesson } = Astro.props; +--- + + + + + + {title} — Campus InsanityLab + + + + + + + + + +
+ + + + diff --git a/src/lib/campus.ts b/src/lib/campus.ts new file mode 100644 index 0000000..01a3e4c --- /dev/null +++ b/src/lib/campus.ts @@ -0,0 +1,94 @@ +// Accesso ai contenuti della sezione Campus, generati da `npm run sync-campus` +// in campus-content/. I file sono letti a runtime (non a build time) e tenuti +// in cache in memoria: cambiano solo con un nuovo deploy. +import { readFileSync, existsSync } from 'node:fs'; +import { join, normalize, resolve } from 'node:path'; + +export interface CampusLink { slug: string; area: string; title: string } + +export interface CampusLesson { + slug: string; + kind: string; + label: string; + titleFull: string; + titleShort: string; + relatore: string; + concetti: string[]; + chapter: number; + area: string; + prev: CampusLink | null; + next: CampusLink | null; +} + +export interface CampusChapter { + num: number; + title: string; + sub: string; + color: string; + lessons: CampusLesson[]; +} + +export interface CampusArea { + slug: string; + title: string; + sub: string; + color: string; + chapters: CampusChapter[]; +} + +export interface CampusNav { areas: CampusArea[]; count: number } + +const CONTENT_DIR = resolve(process.env.CAMPUS_DIR ?? './campus-content'); + +let navCache: CampusNav | null = null; + +/** Struttura della sezione, o null se i contenuti non sono stati generati. */ +export function getNav(): CampusNav | null { + if (navCache) return navCache; + const file = join(CONTENT_DIR, 'nav.json'); + if (!existsSync(file)) { + console.warn(`Campus: contenuti assenti in ${CONTENT_DIR}. Eseguire "npm run sync-campus".`); + return null; + } + navCache = JSON.parse(readFileSync(file, 'utf8')) as CampusNav; + return navCache; +} + +export function findArea(nav: CampusNav, slug: string): CampusArea | null { + return nav.areas.find((a) => a.slug === slug) ?? null; +} + +export function findLesson(nav: CampusNav, slug: string): CampusLesson | null { + for (const area of nav.areas) + for (const ch of area.chapters) { + const l = ch.lessons.find((x) => x.slug === slug); + if (l) return l; + } + return null; +} + +export function chapterOf(nav: CampusNav, lesson: CampusLesson): CampusChapter | null { + for (const area of nav.areas) { + const ch = area.chapters.find((c) => c.num === lesson.chapter); + if (ch) return ch; + } + return null; +} + +/** Corpo HTML di una guida; null se il frammento non esiste. */ +export function readLessonHtml(slug: string): string | null { + const file = join(CONTENT_DIR, 'pages', `${slug}.html`); + if (!existsSync(file)) return null; + return readFileSync(file, 'utf8'); +} + +/** + * Percorso assoluto di un asset dentro campus-content/assets, oppure null se + * il path esce dalla cartella (traversal) o il file non esiste. + */ +export function resolveAsset(relPath: string): string | null { + const base = join(CONTENT_DIR, 'assets'); + const full = resolve(base, normalize(relPath)); + if (full !== base && !full.startsWith(base + '/')) return null; + return existsSync(full) ? full : null; +} diff --git a/src/pages/admin/login.astro b/src/pages/admin/login.astro index 2f39dd8..259ae65 100644 --- a/src/pages/admin/login.astro +++ b/src/pages/admin/login.astro @@ -1,7 +1,7 @@ --- import Admin from '../../layouts/Admin.astro'; import { getDb } from '../../lib/db'; -import { login, SESSION_COOKIE } from '../../lib/auth'; +import { login, getSessionUser, landingFor, SESSION_COOKIE } from '../../lib/auth'; import { rateLimit } from '../../lib/rate-limit'; export const prerender = false; @@ -17,7 +17,9 @@ if (Astro.request.method === 'POST') { httpOnly: true, sameSite: 'lax', path: '/', secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600, }); - return Astro.redirect('/admin'); + // Ogni ruolo atterra sulla sua sezione: campus fuori dal pannello, gli + // altri sulla pagina admin che possono effettivamente aprire. + return Astro.redirect(landingFor(getSessionUser(getDb(), token)?.role ?? 'user')); } error = 'Credenziali non valide.'; } diff --git a/src/pages/campus/[...slug].astro b/src/pages/campus/[...slug].astro new file mode 100644 index 0000000..2baa2f8 --- /dev/null +++ b/src/pages/campus/[...slug].astro @@ -0,0 +1,113 @@ +--- +// Due forme di URL: +// /campus/ → indice dei capitoli dell'area +// /campus// → corpo della guida +import Campus from '../../layouts/Campus.astro'; +import { getNav, findArea, findLesson, chapterOf, readLessonHtml } from '../../lib/campus'; +export const prerender = false; + +const nav = getNav(); +if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 }); + +const parts = (Astro.params.slug ?? '').split('/').filter(Boolean); +if (parts.length === 0 || parts.length > 2) return new Response('Pagina non trovata', { status: 404 }); + +const [areaSlug, lessonSlug] = parts; +const area = findArea(nav, areaSlug); +if (!area) return new Response('Pagina non trovata', { status: 404 }); + +const lesson = lessonSlug ? findLesson(nav, lessonSlug) : null; +if (lessonSlug && (!lesson || lesson.area !== areaSlug)) return new Response('Pagina non trovata', { status: 404 }); + +const body = lesson ? readLessonHtml(lesson.slug) : null; +if (lesson && body === null) return new Response('Guida non disponibile', { status: 404 }); + +const chapter = lesson ? chapterOf(nav, lesson) : null; +--- + + {lesson ? ( +
+ +

{lesson.label} · {lesson.relatore}

+

{lesson.titleFull}

+ {lesson.concetti.length > 0 && ( +
    + {lesson.concetti.map((c) =>
  • {c}
  • )} +
+ )} +
+ +
+ ) : ( + <> + +

Area di studio

+

{area.title}

+

{area.sub}

+ {area.chapters.map((ch) => ( +
+

{ch.num}. {ch.title}

+

{ch.sub}

+ +
+ ))} + + )} +
+ + diff --git a/src/pages/campus/assets/[...path].ts b/src/pages/campus/assets/[...path].ts new file mode 100644 index 0000000..0c8f23b --- /dev/null +++ b/src/pages/campus/assets/[...path].ts @@ -0,0 +1,35 @@ +// Immagini delle slide del Campus. Passano dal middleware come il resto di +// /campus, quindi non sono raggiungibili senza login con ruolo abilitato. +import type { APIRoute } from 'astro'; +import { createReadStream, statSync } from 'node:fs'; +import { extname } from 'node:path'; +import { Readable } from 'node:stream'; +import { resolveAsset } from '../../../lib/campus'; + +export const prerender = false; + +const TYPES: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml', +}; + +export const GET: APIRoute = ({ params }) => { + const rel = params.path; + if (!rel) return new Response('Not found', { status: 404 }); + + const type = TYPES[extname(rel).toLowerCase()]; + if (!type) return new Response('Tipo non consentito', { status: 400 }); + + const file = resolveAsset(rel); + if (!file) return new Response('Not found', { status: 404 }); + + const { size } = statSync(file); + const stream = Readable.toWeb(createReadStream(file)) as ReadableStream; + return new Response(stream, { + headers: { + 'Content-Type': type, + 'Content-Length': String(size), + 'Cache-Control': 'private, max-age=86400', + }, + }); +}; diff --git a/src/pages/campus/index.astro b/src/pages/campus/index.astro new file mode 100644 index 0000000..a62a434 --- /dev/null +++ b/src/pages/campus/index.astro @@ -0,0 +1,43 @@ +--- +import Campus from '../../layouts/Campus.astro'; +import { getNav } from '../../lib/campus'; +export const prerender = false; + +const nav = getNav(); +if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 }); +--- + +

Biohacking Campus 2025/2026

+

Guida allo studio

+

+ Le sintesi di tutte le lezioni del corso, organizzate in cinque aree: + {nav.count} guide con concetti chiave, sviluppo esteso e materiali originali. +

+ + +
+ + diff --git a/tests/campus-lib.test.ts b/tests/campus-lib.test.ts new file mode 100644 index 0000000..ad41f31 --- /dev/null +++ b/tests/campus-lib.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { getNav, findArea, findLesson, chapterOf, readLessonHtml, resolveAsset } from '../src/lib/campus'; + +// I test girano sui contenuti reali generati da `npm run sync-campus`. +const nav = getNav(); + +describe('getNav', () => { + it('carica aree e capitoli dai contenuti generati', () => { + expect(nav).not.toBeNull(); + expect(nav!.count).toBeGreaterThan(100); + expect(nav!.areas.map((a) => a.slug)).toEqual( + ['corso-base', 'slide', 'live-e-ospiti', 'consulenze-masterclass', 'qa']); + for (const a of nav!.areas) expect(a.chapters.length).toBeGreaterThan(0); + }); +}); + +describe('lookup', () => { + it('trova area e guida, e il capitolo della guida', () => { + const area = findArea(nav!, 'corso-base'); + expect(area?.title).toBe('Corso base'); + const l = findLesson(nav!, 'lezione-01-ottimizzazione-del-sonno'); + expect(l).toMatchObject({ area: 'corso-base', chapter: 1, label: 'L1' }); + expect(chapterOf(nav!, l!)?.title).toBe('Sonno e ritmi'); + }); + + it('ritorna null per slug inesistenti', () => { + expect(findArea(nav!, 'inventata')).toBeNull(); + expect(findLesson(nav!, 'lezione-99-inventata')).toBeNull(); + }); + + it('la catena prev/next collega tutte le guide in ordine', () => { + const all = nav!.areas.flatMap((a) => a.chapters.flatMap((c) => c.lessons)); + expect(all[0].prev).toBeNull(); + expect(all.at(-1)!.next).toBeNull(); + for (let i = 0; i < all.length - 1; i++) expect(all[i].next?.slug).toBe(all[i + 1].slug); + }); +}); + +describe('readLessonHtml', () => { + it('legge il frammento di una guida esistente', () => { + const html = readLessonHtml('lezione-01-ottimizzazione-del-sonno'); + expect(html).toContain(''); + }); + + it('ritorna null per una guida inesistente', () => { + expect(readLessonHtml('non-esiste')).toBeNull(); + }); + + it('le immagini puntano alla route protetta', () => { + const html = readLessonHtml('slide-hrv')!; + expect(html).toContain('src="/campus/assets/'); + expect(html).not.toContain('src="assets/'); + }); +}); + +describe('resolveAsset', () => { + it('risolve un asset esistente', () => { + expect(resolveAsset('slide/slide-hrv')).toMatch(/campus-content\/assets\/slide\/slide-hrv$/); + }); + + it('rifiuta i path traversal e i path assoluti', () => { + expect(resolveAsset('../pages/lezione-01-ottimizzazione-del-sonno.html')).toBeNull(); + expect(resolveAsset('../../package.json')).toBeNull(); + expect(resolveAsset('/etc/passwd')).toBeNull(); + }); + + it('ritorna null per un asset inesistente', () => { + expect(resolveAsset('slide/non-esiste.png')).toBeNull(); + }); +}); diff --git a/tests/campus-parse.test.ts b/tests/campus-parse.test.ts index fc5494f..ef7dd85 100644 --- a/tests/campus-parse.test.ts +++ b/tests/campus-parse.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect } from 'vitest'; -// @ts-expect-error — modulo JS senza tipi, usato solo dallo script di sync import { parseGuide, kindOf, rewriteAssets } from '../scripts/campus/parse.mjs'; -// @ts-expect-error — idem import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs'; const body = ` @@ -104,18 +102,19 @@ describe('rewriteAssets', () => { }); describe('struttura del corso', () => { + const chapterNums = CHAPTERS.map((c) => c[0] as number); + it('ogni capitolo ha un colore e ogni area capitoli esistenti', () => { - const nums = CHAPTERS.map((c: [number, string, string, string[]]) => c[0]); expect(CH_COLORS.length).toBe(CHAPTERS.length); - for (const [, , , , chapterNums] of AREAS as [string, string, string, string, number[]][]) - for (const n of chapterNums) expect(nums).toContain(n); + for (const area of AREAS) + for (const n of area[4] as number[]) expect(chapterNums).toContain(n); }); it('nessuno slug duplicato e ogni capitolo è in una sola area', () => { - const slugs = CHAPTERS.flatMap((c: [number, string, string, string[]]) => c[3]); + const slugs = CHAPTERS.flatMap((c) => c[3] as string[]); expect(new Set(slugs).size).toBe(slugs.length); - const assigned = (AREAS as [string, string, string, string, number[]][]).flatMap((a) => a[4]); + const assigned = AREAS.flatMap((a) => a[4] as number[]); expect(new Set(assigned).size).toBe(assigned.length); - expect(assigned.sort((a, b) => a - b)).toEqual(CHAPTERS.map((c: [number, string, string, string[]]) => c[0])); + expect(assigned.sort((a, b) => a - b)).toEqual(chapterNums); }); });