# PromoGest v2, fase 2: dati e API

> **Fase chiusa, in produzione.** Registro storico: cosa è stato deciso e perché.


> **Per chi esegue:** usare la skill `superpowers:subagent-driven-development`. Gli step usano
> caselle `- [ ]` per il tracciamento.

**Obiettivo:** il database su Turso con i vincoli che rendono impossibile l'incidente di giugno,
l'autenticazione, gli endpoint, e la migrazione dei dati dalla versione PHP. Al termine l'app
funziona senza interfaccia: si può fare login, leggere i dati e importare un Excel con `curl`.

**Architettura:** SvelteKit con pagine statiche prerenderizzate, quindi `hooks.server.ts` protegge
solo `/api/*` e risponde sempre in JSON. Drizzle su libSQL, con l'import che sostituisce in blocco
gli eventi di un'azienda dentro un singolo `db.batch()` atomico.

**Stack:** quello della fase 1, più `drizzle-orm`, `@libsql/client`, `drizzle-kit`, `tsx`, `jose`,
`bcryptjs`.

---

## Come è scritto questo piano, e perché diverso dal precedente

Nella fase 1 il piano conteneva il codice di implementazione completo. Gli implementatori l'hanno
trascritto fedelmente, bug compresi: su cinque task la revisione ha trovato cinque difetti che
stavano nel piano, non nel lavoro di chi lo eseguiva. Un `eIsoDate` che accettava il 31 febbraio, una
regex con un ramo morto, un file Excel aperto due volte.

Qui il piano contiene **i test**, che sono la specifica eseguibile, più le firme esatte e le trappole
verificate sperimentalmente. L'implementazione la scrive chi esegue. Un test sbagliato diventa rosso
e si vede subito; un'implementazione sbagliata copiata dal piano attraversa tutta la catena in
silenzio.

Dove il piano mostra del codice non di test, è perché la forma è controintuitiva e sbagliarla costa
un giro di revisione: lì va copiato alla lettera.

## Vincoli globali

Valgono per ogni task e non si ripetono.

- **Le date civili sono stringhe `YYYY-MM-DD`.** Confronti lessicografici. Mai un `Date` per
  confrontare o formattare. Vedi `src/lib/date.ts`.
- **Tutti gli endpoint usano gli helper `risposta` ed `errore` di `$lib/server/risposte.ts`**, senza
  eccezioni. Nessuno costruisce risposte a mano. Due forme diverse nella stessa API costringono il
  client a gestirle entrambe, e quel tipo di incoerenza non si sistema più una volta che qualcosa la
  consuma. È successo: gli helper sono stati introdotti nel task 3 mentre i due endpoint del task 2
  erano già scritti con la forma vecchia, e sono stati riallineati subito.
- **Mai `error()` dentro un `+server.ts`.** Negozia sull'header `Accept` e può rendere **HTML con
  status 401**, che un client che si aspetta JSON non sa leggere. Verificato:
  `@sveltejs/kit/src/runtime/server/endpoint.js:87` → `respond.js:736` → `utils.js:81-92`.
  Si risponde sempre con `json(...)` e lo status esplicito.
- **Mai un redirect in `hooks.server.ts`.** Le pagine sono file statici sulla CDN e non passano mai
  dall'hook. Peggio: l'hook gira anche **durante il build**, e un `throw redirect(302, '/login')`
  copiato da Alleanza fa fallire la build con `Error: 404 /login (linked from /)`, un messaggio che
  punta a `prerender.js` e non fa pensare all'autenticazione.
- **`db.batch()`, mai `db.transaction()`.** Stessa atomicità, un round-trip invece di N, e nessun
  timeout server di 5 secondi. In locale su file SQLite la transaction funziona benissimo: fallisce
  solo in produzione, coi volumi veri.
- **Nessun `catch` che degrada un errore di lettura in lista vuota.** Una query fallita è un 500, mai
  un 200 con `[]`. È il difetto di `readJson` nella versione PHP, che trasformava un file illeggibile
  in un calendario vuoto senza dire niente.
- **Commento in italiano in testa a ogni file sorgente.**
- **Test prima dell'implementazione.** `npm test` gira su tre fusi orari: è il default apposta.
- **`npx svelte-check --output human` deve dare 0 errori e 0 avvisi.** Nella fase 1 un errore di tipi
  è passato perché `npm test` da solo non lo intercetta.
- **`npm run build` deve completare, in ogni task che tocca codice lato server.** Non è ridondante:
  la guardia su `DATABASE_URL` scritta al livello del modulo ha rotto il build mentre `npm test` e
  `svelte-check` erano entrambi verdi con 114 test. Tutto ciò che legge l'ambiente al livello del
  modulo esplode durante il prerendering, e ce ne si accorge solo qui. Il rimedio è la pigrizia: si
  legge l'ambiente quando serve, non quando si importa il modulo.
- **Commit unico per task, messaggio in italiano all'imperativo.**

## Trappole verificate

Ognuna è costata un esperimento. Chi esegue le legga prima di scrivere, non dopo.

1. **`drizzle-kit push` non aggiorna i `CHECK`.** Li crea la prima volta, poi a ogni modifica dice
   "No changes detected" e lascia il vincolo vecchio. Siccome i CHECK sono la difesa principale di
   questa fase, si usa `generate` + `migrate` e mai `push`.
2. **In GLOB i jolly sono `*` e `?`, non `_`.** `GLOB '____-__-__'` non matcha nessuna data e fa
   fallire ogni insert. Per le date serve `GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'`.
3. **Niente `await` davanti agli statement dentro l'array di `db.batch()`.** Partirebbero subito,
   uno per uno, fuori dalla transazione.
4. **`db.insert(x).values([])` solleva**, non è un'operazione nulla. Con zero righe non si costruisce
   un batch di solo DELETE: si rifiuta a monte, perché quel DELETE cancellerebbe tutto per colpa di
   un indice di colonna sbagliato.
5. **Non calcolare la dimensione dei lotti come 32766 diviso il numero di colonne.** Drizzle non
   emette segnaposto per i campi `undefined`, quindi il conteggio dipende da quali opzionali sono
   valorizzati in quel lotto: un import passa e il successivo esplode. Lotti fissi da 500.
6. **Non spezzare l'import in più `db.batch()` per prudenza.** Si perde esattamente la proprietà per
   cui si usa il batch. Seicento statement in un batch girano in decine di millisecondi e restano un
   round-trip solo.
7. **Il segreto JWT va letto dentro la funzione, non al top level.** `const S = env.JWT_SECRET` in
   cima al modulo rompe il build dove manca `.env`. Nei test non basta `beforeAll`, serve
   `setupFiles`: gli import statici sono issati.
8. **`jose` accetta un segreto di 5 byte per HS256 senza protestare**, e senza
   `{ algorithms: ['HS256'] }` in verifica non fissa l'algoritmo. Il `JWT_SECRET=cambiami` del
   `.env.example` non deve arrivare in produzione.
9. **`cookies.set` vuole `path: '/'` esplicito.** Un path relativo viene risolto contro la route: da
   `/api/sessione` un `path: '.'` nasce come `Path=/api/` e il logout con `path: '/'` non lo cancella.
   E serve `secure: !dev` esplicito, perché su `127.0.0.1` il default diventa `true` e il browser
   scarta il cookie in chiaro: la login sembra riuscire e la sessione non si crea mai.
10. **Se l'hook risponde senza chiamare `resolve()`, i cookie impostati in quel giro si perdono.**
    `add_cookies_to_headers` viene invocato solo lungo il cammino che passa da `resolve`. Niente
    rinnovo della sessione sul ramo che poi risponde 401.
11. **Il CSRF di SvelteKit blocca in produzione le POST multipart senza header `Origin`**, con un 403
    in testo semplice. Un browser same-origin lo manda sempre; un `curl` di prova no, e sembra un bug
    dell'endpoint.
12. **`0` è un indice di colonna legittimo.** `header_row` vale 0 su tutte e tre le aziende, e
    `insegna` vale 0 su Parmacotto. Validare con `typeof x === 'number'`, mai con un controllo di
    verità.
13. **`bcryptjs` accetta il prefisso `$2y$`.** Verificato contro l'hash reale di Valentina con la
    password nota: `compareSync('valentina', '$2y$12$FdAZ…')` restituisce `true`. Non serve farle
    reimpostare la password. Da `bcryptjs` 3 i tipi sono nel pacchetto: niente `@types/bcryptjs`.
14. **Una data va sempre validata contro il calendario, mai contro la sua forma.** Questa svista è
    già passata due volte in questo progetto: in fase 1 `eIsoDate` controllava solo il formato con
    una regex e accettava `2026-02-31`; in fase 2 il `CHECK` sullo schema usava un GLOB di cifre e
    accettava `2026-13-42`. Contare le cifre non serve a niente. In TypeScript si ricostruisce la
    data in UTC e si verifica che i componenti tornino identici; in SQLite si usa
    `date(col) IS NOT NULL AND date(col) = col`, dove l'`IS NOT NULL` non è ridondante perché un
    CHECK che valuta a NULL è considerato soddisfatto. Vale ovunque compaia una validazione di data,
    fase 3 compresa.
15. **Un confronto fra insegne va sempre fatto sulla forma normalizzata.** `costruisciMappaAlias`
    risolve passando da `normalizzaInsegna`, cioè trim e maiuscolo. Qualunque guardia che confronti
    la stringa grezza lascia entrare `Esselunga` accanto a `ESSELUNGA`, e poi le due collassano
    sulla stessa chiave con l'ultimo iterato che vince, in un ordine che l'utente non controlla.
    Vale anche per i vincoli sul database: un indice unico sulla colonna grezza non protegge da
    niente, va messo sull'espressione normalizzata.
16. **Un test che verifica un vincolo deve isolarlo.** Sempre in fase 2, il test sulla forma delle
    date passava una data malformata solo su un lato: falliva per il vincolo sull'ordine, non per
    quello sulla forma, e togliendo del tutto il vincolo sulla forma la suite restava verde. Quando
    si prova un vincolo, si cattura il messaggio dell'errore e si asserisce **quale** vincolo è
    scattato, e poi lo si toglie di proposito per vedere il test diventare rosso.

---

### Task 1: Fondamenta del database

**File:**
- Creare: `src/lib/server/db/schema.ts`, `src/lib/server/db/index.ts`, `drizzle.config.ts`
- Creare: `src/lib/server/db/schema.test.ts`
- Modificare: `package.json`

**Interfacce prodotte:**
```ts
// schema.ts
export const users, companies, events, aliases, aliasVariants, ignoredInsegne
// index.ts
export const db;      // istanza Drizzle
export const client;  // client libSQL grezzo, per i batch e gli script
```

- [ ] **Step 1: Installare le dipendenze**

```bash
npm i drizzle-orm @libsql/client
npm i -D drizzle-kit tsx
```

- [ ] **Step 2: Scrivere lo schema**

`src/lib/server/db/schema.ts`. Nomi delle colonne in snake_case identici ai JSON di partenza, così
la migrazione è una copia; nomi TypeScript in camelCase identici a quelli già usati da
`src/lib/server/excel.ts`.

Tabelle e colonne:

```
users            id (text pk), username (unique), password_hash, token_version (int, default 0), created_at
companies        id (text pk), name, color, mapping (json), last_import, ultimo_import_scarti (real)
events           id (text pk), company_id (fk companies), insegna, insegna_raw,
                 sellout_start, sellout_end, products (json), imported_at,
                 draft (int, default 0), import_id
aliases          id (text pk), canonical_name
alias_variants   id (int pk autoincrement), alias_id (fk aliases), variant   UNIQUE(variant)
ignored_insegne  name (text pk)
```

Vincoli su `events`, tutti obbligatori:

```ts
check('ck_events_range', sql`${t.selloutEnd} >= ${t.selloutStart}`)
check('ck_events_iso', sql`${t.selloutStart} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'
                        AND ${t.selloutEnd} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'`)
check('ck_events_finestra', sql`${t.selloutStart} BETWEEN '2020-01-01' AND '2099-12-31'
                             AND ${t.selloutEnd} BETWEEN '2020-01-01' AND '2099-12-31'`)
check('ck_events_products', sql`json_valid(${t.prodotti}) AND json_type(${t.prodotti}) = 'array'`)
check('ck_events_draft', sql`${t.draft} IN (0,1)`)
```

più `check('ck_companies_mapping', ...)` con lo stesso `json_valid` su `companies.mapping`, con
`json_type = 'object'`.

Indici: `(company_id, sellout_start)` per il calendario, `(import_id)` per la pulizia delle bozze,
e l'unico su `alias_variants(variant)`.

**Perché `ck_events_products` non è facoltativo:** `$type<Prodotto[]>()` è solo tipizzazione e a
runtime non valida niente. Una sola riga con testo non-JSON in quella colonna fa fallire **l'intera
SELECT** con un `SyntaxError` senza contesto, non quella riga: verificato sperimentalmente. Chiunque
scriva in SQL grezzo, a partire dallo script di migrazione, può avvelenare la tabella.

**Perché nessun `CHECK` sulla durata massima:** con soglia 120 giorni oggi verrebbero rifiutati 11
eventi su 3.503, e almeno due sono accordi annuali veri. La durata implausibile **marca**, non
scarta, e vive nel guard del task 5.

**Perché la finestra 2020-2099 e non 2015-2035 come il parser:** sono tre livelli concentrici con
scopi diversi. `parseDate` filtra la plausibilità di *un numero letto da Excel*; il CHECK è la rete
anti-catastrofe su *un dato in tabella*; il guard segnala ciò che è sospetto ma legittimo. I tre
refusi d'anno presenti nei dati veri passano il CHECK e devono passare, altrimenti la migrazione si
ferma su tre righe.

- [ ] **Step 3: Scrivere il client**

`src/lib/server/db/index.ts`, con in cima il commento che dice "batch, mai transaction" e perché.

La guardia sull'ambiente è obbligatoria:

```ts
if (!dev && !env.DATABASE_URL) throw new Error('DATABASE_URL mancante');
```

Alleanza non ce l'ha ed è una lacuna, non un modello: senza, su Vercel con la variabile dimenticata
l'app non esplode, apre un SQLite vuoto nel filesystem effimero, e Valentina vede un calendario vuoto
senza un errore nei log.

Esporta sia `db` (Drizzle) sia `client` (libSQL grezzo), perché i batch e gli script di migrazione
hanno bisogno del secondo.

- [ ] **Step 4: Configurare drizzle-kit e gli script**

`drizzle.config.ts` con `dialect: 'turso'`, schema che punta a `schema.ts`, output in
`drizzle/migrations`.

In `package.json`: `db:generate` (drizzle-kit generate), `db:migrate`, `db:studio`. **Nessuno script
`db:push`**, per non lasciare in giro la scorciatoia che non aggiorna i CHECK.

- [ ] **Step 5: Scrivere il test dei vincoli (deve fallire)**

`src/lib/server/db/schema.test.ts`. Apre un database in memoria, applica la migrazione generata, e
verifica che i vincoli mordano davvero. Questo test è la ragione d'essere del task: senza, lo schema
è una dichiarazione di intenti.

```ts
import { describe, it, expect, beforeAll } from 'vitest';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { readFileSync, readdirSync } from 'node:fs';
import * as schema from './schema';

let db: ReturnType<typeof drizzle>;

beforeAll(async () => {
	const client = createClient({ url: 'file::memory:' });
	// applica la migrazione generata da drizzle-kit, così il test verifica
	// l'SQL vero e non una tabella costruita a mano nel test
	const dir = 'drizzle/migrations';
	for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) {
		for (const stmt of readFileSync(`${dir}/${f}`, 'utf8').split('--> statement-breakpoint')) {
			if (stmt.trim()) await client.execute(stmt);
		}
	}
	db = drizzle(client, { schema });
	await db.insert(schema.companies).values({
		id: 'caseifici_gt', name: 'Caseifici GT', color: '#2196F3',
		mapping: { insegna: 3, product: 7, selloutStart: 10, selloutEnd: 11, extraInfo: 13, extraInfoLabel: 'Meccanica', headerRow: 0 }
	});
});

const evento = (o: Record<string, unknown> = {}) => ({
	id: `evt_${Math.random().toString(16).slice(2)}`,
	companyId: 'caseifici_gt',
	insegna: 'ESSELUNGA SPA',
	insegnaRaw: 'ESSELUNGA SPA',
	selloutStart: '2026-04-23',
	selloutEnd: '2026-05-06',
	prodotti: [{ nome: 'BURRO 200g', extraInfo: 'A' }],
	importedAt: '2026-08-12T10:00:00+02:00',
	...o
});

describe('vincoli su events', () => {
	it('accetta un evento valido e lo rilegge come array tipizzato', async () => {
		await db.insert(schema.events).values(evento({ id: 'evt_ok' }));
		const righe = await db.select().from(schema.events);
		const e = righe.find((r) => r.id === 'evt_ok')!;
		expect(Array.isArray(e.prodotti)).toBe(true);
		expect(e.prodotti[0].nome).toBe('BURRO 200g');
	});

	it('rifiuta la data di fine precedente a quella di inizio', async () => {
		// È lo scenario del 10 giugno 2026: 2.833 eventi su 2.929 avevano
		// la fine prima dell'inizio e l'import rispose "riuscito".
		await expect(
			db.insert(schema.events).values(evento({ selloutStart: '2026-05-06', selloutEnd: '2026-04-23' }))
		).rejects.toThrow();
	});

	it('rifiuta una data fuori dalla finestra plausibile', async () => {
		await expect(
			db.insert(schema.events).values(evento({ selloutStart: '1952-01-07', selloutEnd: '1952-02-01' }))
		).rejects.toThrow();
		await expect(
			db.insert(schema.events).values(evento({ selloutStart: '2242-03-27', selloutEnd: '2242-04-01' }))
		).rejects.toThrow();
	});

	it('rifiuta una data che non ha la forma YYYY-MM-DD', async () => {
		await expect(
			db.insert(schema.events).values(evento({ selloutStart: '23/04/2026', selloutEnd: '2026-05-06' }))
		).rejects.toThrow();
	});

	it('rifiuta products che non è JSON valido', async () => {
		// scritto in SQL grezzo perché è il cammino che lo script di migrazione
		// e una query di riparazione a mano userebbero davvero
		await expect(
			db.run(sql`INSERT INTO events (id, company_id, insegna, insegna_raw, sellout_start, sellout_end, products, imported_at, draft)
			           VALUES ('evt_marcio','caseifici_gt','X','X','2026-04-23','2026-05-06','non json','2026-08-12',0)`)
		).rejects.toThrow();
	});

	it('rifiuta products che è JSON ma non un array', async () => {
		await expect(
			db.run(sql`INSERT INTO events (id, company_id, insegna, insegna_raw, sellout_start, sellout_end, products, imported_at, draft)
			           VALUES ('evt_oggetto','caseifici_gt','X','X','2026-04-23','2026-05-06','{"a":1}','2026-08-12',0)`)
		).rejects.toThrow();
	});

	it('rifiuta draft diverso da 0 o 1', async () => {
		await expect(db.insert(schema.events).values(evento({ draft: 2 }))).rejects.toThrow();
	});
});

describe('vincoli su alias_variants', () => {
	it('impedisce di mappare la stessa variante su due insegne diverse', async () => {
		// Senza questo vincolo la risoluzione ne pescherebbe una in base
		// all'ordine dell'array, in silenzio, e gli eventi finirebbero
		// sull'insegna sbagliata.
		await db.insert(schema.aliases).values([
			{ id: 'a1', canonicalName: 'ESSELUNGA SPA' },
			{ id: 'a2', canonicalName: 'BENNET SPA' }
		]);
		await db.insert(schema.aliasVariants).values({ aliasId: 'a1', variant: 'ESSELUNGA' });
		await expect(
			db.insert(schema.aliasVariants).values({ aliasId: 'a2', variant: 'ESSELUNGA' })
		).rejects.toThrow();
	});
});
```

- [ ] **Step 6: Generare la migrazione e far passare i test**

```bash
npm run db:generate
npm test -- src/lib/server/db/schema.test.ts
```

Se un `rejects.toThrow()` non scatta, il vincolo non è finito nell'SQL: aprire
`drizzle/migrations/0000_*.sql` e verificarlo a occhio prima di proseguire. È il modo in cui ci si
accorge che `push` avrebbe mentito.

- [ ] **Step 7: Verifica completa e commit**

```bash
npm test
npx svelte-check --output human
```

```bash
git add src/lib/server/db drizzle.config.ts drizzle package.json package-lock.json
git commit -m "Aggiungi schema del database con i vincoli che bloccano l'import sbagliato

I CHECK su ordine delle date, finestra plausibile e forma di products sono la
difesa che mancava il 10 giugno 2026, quando un mapping colonne sbagliato ha
prodotto 2.833 eventi spazzatura su 2.929 e l'import rispose riuscito.
Si usa generate e non push, perché push crea i CHECK ma non li aggiorna mai."
```

---

### Task 2: Autenticazione

**File:**
- Creare: `src/lib/server/auth.ts`, `src/hooks.server.ts`, `src/app.d.ts`
- Creare: `src/routes/api/sessione/+server.ts`, `src/routes/api/password/+server.ts`
- Creare: `src/test-utils/setup.ts`
- Creare: `src/lib/server/auth.test.ts`, `src/routes/api/sessione/server.test.ts`
- Modificare: `vitest.config.ts` (aggiungere `setupFiles`)

**Interfacce prodotte:**
```ts
export interface UtenteSessione { id: string; username: string; tokenVersion: number }
export async function hashPassword(chiaro: string): Promise<string>
export async function verificaPassword(chiaro: string, hash: string): Promise<boolean>
export async function creaToken(u: UtenteSessione): Promise<string>
export async function leggiToken(token: string): Promise<UtenteSessione | null>
export const NOME_COOKIE = 'sessione'
export const DURATA_SESSIONE_S = 60 * 60 * 24 * 90
```

- [ ] **Step 1: Installare e preparare i test**

```bash
npm i jose bcryptjs
```

Niente `@types/bcryptjs`: da bcryptjs 3 i tipi sono dentro il pacchetto.

`src/test-utils/setup.ts` popola l'ambiente prima che i moduli vengano importati. Serve
`setupFiles`, non `beforeAll`: gli import statici sono issati e girerebbero prima.

```ts
// Popola $env/dynamic/private prima che i moduli lo leggano.
// Deve stare in setupFiles: con beforeAll gli import statici sarebbero già avvenuti.
import { env } from '$env/dynamic/private';
env.JWT_SECRET = 'segreto-di-prova-lungo-abbastanza-per-hs256';
env.DATABASE_URL = 'file::memory:';
```

e in `vitest.config.ts`, dentro `test`: `setupFiles: ['src/test-utils/setup.ts']`.

- [ ] **Step 2: Scrivere i test dell'autenticazione (devono fallire)**

`src/lib/server/auth.test.ts`:

```ts
import { describe, it, expect } from 'vitest';
import { creaToken, leggiToken, verificaPassword, hashPassword } from './auth';

const utente = { id: 'u1', username: 'valentina', tokenVersion: 0 };

describe('password', () => {
	it('verifica l hash bcrypt $2y$ prodotto dalla versione PHP', async () => {
		// Hash reale di Valentina, con la password reale. Se questo test fallisce,
		// la migrazione le fa perdere l'accesso e va gestito prima, non dopo.
		const hashPhp = '$2y$12$FdAZdCVyRGjC1k6fgnIC5O7J3oe.H9bYDNbyyUd4vC6XlP448jNpu';
		expect(await verificaPassword('valentina', hashPhp)).toBe(true);
		expect(await verificaPassword('sbagliata', hashPhp)).toBe(false);
	});

	it('produce hash verificabili', async () => {
		const h = await hashPassword('nuova-password');
		expect(await verificaPassword('nuova-password', h)).toBe(true);
		expect(await verificaPassword('altra', h)).toBe(false);
	});
});

describe('token di sessione', () => {
	it('firma e rilegge l utente', async () => {
		const t = await creaToken(utente);
		expect(await leggiToken(t)).toMatchObject(utente);
	});

	it('rifiuta un token manomesso', async () => {
		const t = await creaToken(utente);
		expect(await leggiToken(t.slice(0, -3) + 'xxx')).toBeNull();
	});

	it('rifiuta stringhe che non sono token', async () => {
		expect(await leggiToken('')).toBeNull();
		expect(await leggiToken('pippo')).toBeNull();
	});

	it('fissa l algoritmo in verifica', async () => {
		// jose senza { algorithms: ['HS256'] } non vincola l'algoritmo.
		// Un token con alg diverso non deve essere accettato.
		const { SignJWT } = await import('jose');
		const finto = await new SignJWT({ sub: 'u1' })
			.setProtectedHeader({ alg: 'HS384' })
			.sign(new TextEncoder().encode('segreto-di-prova-lungo-abbastanza-per-hs256'));
		expect(await leggiToken(finto)).toBeNull();
	});
});
```

- [ ] **Step 3: Implementare `auth.ts`**

Il segreto si legge **dentro** le funzioni, mai al top level: al top level rompe il build dove manca
`.env`, con uno stack trace che punta a `prerender.js`. La verifica passa
`{ algorithms: ['HS256'] }`. Il payload porta `sub`, `username` e `tokenVersion`.

- [ ] **Step 4: Scrivere l'hook**

`src/hooks.server.ts`. Tre regole, tutte e tre verificate sperimentalmente:

```ts
export const handle: Handle = async ({ event, resolve }) => {
	// 1. durante il build l'hook gira lo stesso: uscire subito, o il prerender si rompe
	if (building) return resolve(event);
	// 2. le pagine sono statiche sulla CDN e non passano di qui: si protegge solo /api
	if (!event.url.pathname.startsWith('/api/')) return resolve(event);
	// ... leggi il cookie, popola event.locals.utente
	// 3. mai un redirect: 401 in JSON, sempre
};
```

Il filtro sul pathname è la prima riga per un motivo: il build valuta anche l'hook, e qualunque cosa
faccia scattare un redirect lì lo fa fallire.

Le route pubbliche sono `POST /api/sessione` (login) e nient'altro.

`src/app.d.ts` dichiara `Locals.utente?: UtenteSessione`.

- [ ] **Step 5: Scrivere i test degli endpoint di sessione (devono fallire)**

`src/routes/api/sessione/server.test.ts`. Gli endpoint si importano direttamente e si chiamano con
oggetti finti: non serve un server.

Casi da coprire, uno per `it`:
- `GET` senza cookie: 401, corpo JSON, **nessun header `set-cookie`**
- `POST` con credenziali giuste: 200, cookie impostato con `Path=/`, `HttpOnly`
- `POST` con password sbagliata: 401, e il messaggio non distingue utente inesistente da password
  errata
- `GET` con cookie valido: 200 e l'utente nel corpo
- `GET` con token la cui `tokenVersion` è inferiore a quella nel database: 401
- `DELETE`: 200 e il cookie cancellato con lo stesso `path` con cui era stato creato

Verificare esplicitamente il `Path=/` nell'header, non solo la presenza del cookie: un path relativo
nasce come `Path=/api/` e il logout non lo cancella.

- [ ] **Step 6: Implementare gli endpoint, far passare i test, commit**

`POST /api/password` cambia la password e **incrementa `token_version`**, che invalida ogni token
esistente. Nella versione PHP il cambio password non buttava fuori le sessioni già aperte.

```bash
npm test && npx svelte-check --output human
git commit -m "Aggiungi autenticazione con JWT e revoca tramite token_version"
```

---

### Task 3: Lettura dei dati

**File:**
- Creare: `src/lib/server/risposte.ts` (helper `risposta`/`errore` e `CodiceErrore`)
- Creare: `src/lib/server/dati.ts`, `src/routes/api/dati/+server.ts`
- Creare: `src/lib/server/dati.test.ts`

Un solo endpoint di lettura che restituisce tutto lo stato: eventi, aziende, alias, insegne escluse.
Il client fa `store = risposta` e mai un merge, quindi l'ambito deve essere identico a ogni chiamata,
altrimenti gli eventi cancellati da un import restano per sempre nella copia locale.

**Ambito: tutti gli eventi, non solo i futuri.** Misurato sui dati veri: 895 eventi sono 66 KB
compressi. Filtrare complicherebbe l'invalidazione della copia locale per risparmiare niente.

**Interfacce prodotte:**
```ts
// risposte.ts
export type CodiceErrore =
	| 'non_autenticato' | 'credenziali_errate' | 'non_trovato'
	| 'richiesta_non_valida' | 'conflitto' | 'errore_interno';
export function risposta<T>(dati: T, status?: number): Response;
export function errore(codice: CodiceErrore, messaggio: string, status: number): Response;

// dati.ts
export interface Snapshot {
	eventi: EventoSalvato[];
	aziende: Azienda[];
	alias: Alias[];
	insegneEscluse: string[];
	generatoIl: string;      // timestamp ISO completo, non una data civile
}
export async function leggiSnapshot(): Promise<Snapshot>;
```

- [ ] **Step 1: Scrivere i test (devono fallire)**

`src/lib/server/dati.test.ts`. Usa lo stesso modo del test dello schema per aprire un database in
memoria e applicarci le migrazioni.

```ts
describe('leggiSnapshot', () => {
	it('restituisce le quattro sezioni e il timestamp', async () => {
		// popolato con 3 eventi, 2 aziende, 1 alias con 2 varianti, 2 insegne escluse
		const s = await leggiSnapshot();
		expect(s.eventi).toHaveLength(3);
		expect(s.aziende).toHaveLength(2);
		expect(s.alias[0].varianti).toHaveLength(2);
		expect(s.insegneEscluse).toEqual(['ALFI', 'CONAD NORD OVEST']);
		expect(s.generatoIl).toMatch(/^\d{4}-\d{2}-\d{2}T/);
	});

	it('restituisce i prodotti come array tipizzato, non come stringa', async () => {
		const s = await leggiSnapshot();
		expect(Array.isArray(s.eventi[0].prodotti)).toBe(true);
		expect(typeof s.eventi[0].prodotti[0].nome).toBe('string');
	});

	it('esclude le bozze di import dagli eventi', async () => {
		// inserito un evento con draft = 1: non deve comparire
		const s = await leggiSnapshot();
		expect(s.eventi.every((e) => e.id !== 'evt_bozza')).toBe(true);
	});

	it('non degrada un errore di lettura in lista vuota', async () => {
		// Il difetto della versione PHP: readJson restituiva [] su file illeggibile,
		// e siccome ogni scrittura era read-modify-write, una lettura fallita
		// distruggeva i dati. Qui una query fallita deve propagare, non tacere.
		await client.execute('DROP TABLE events');
		await expect(leggiSnapshot()).rejects.toThrow();
	});
});
```

`src/routes/api/dati/server.test.ts`:

```ts
describe('GET /api/dati', () => {
	it('risponde 401 JSON senza sessione', async () => {
		const r = await GET({ locals: {} } as any);
		expect(r.status).toBe(401);
		expect(r.headers.get('content-type')).toContain('application/json');
		expect(await r.json()).toMatchObject({ codice: 'non_autenticato' });
	});

	it('risponde 200 con lo snapshot se autenticato', async () => {
		const r = await GET({ locals: { utente: { id: 'u1', username: 'valentina', tokenVersion: 0 } } } as any);
		expect(r.status).toBe(200);
		const corpo = await r.json();
		expect(corpo).toHaveProperty('eventi');
		expect(corpo).toHaveProperty('generatoIl');
	});

	it('propaga un errore del database come 500, mai come snapshot vuoto', async () => {
		await client.execute('DROP TABLE events');
		const r = await GET({ locals: { utente: { id: 'u1', username: 'valentina', tokenVersion: 0 } } } as any);
		expect(r.status).toBe(500);
	});
});
```

- [ ] **Step 2: Implementazione.** - [ ] **Step 3: Verifica e commit.**

---

### Task 4: Mutazioni di configurazione

**File:**
- Creare: `src/routes/api/aziende/[id]/+server.ts`, `src/routes/api/alias/+server.ts`,
  `src/routes/api/alias/[id]/+server.ts`, `src/routes/api/insegne-escluse/+server.ts`,
  `src/routes/api/eventi/+server.ts` (DELETE)
- Creare i rispettivi `*.test.ts`

Tutte restituiscono lo stesso `Snapshot` del task 3, così il client non deve ricomporre niente.

Casi che i test devono coprire:
- `mapping` con `headerRow: 0` accettato. Validare con `typeof x === 'number'`: `0` è un indice
  legittimo e vale 0 su tutte e tre le aziende.
- variante di alias già presente su un'altra insegna: 409, non un errore generico.
- **alias la cui canonica è fra le insegne escluse: 422 con un messaggio esplicito.** Sui dati veri
  37 insegne attive collassano sulla chiave di un'insegna esclusa. Accettare quell'accorpamento
  farebbe sparire in silenzio tutti i loro eventi al prossimo import, perché la risoluzione applica
  prima l'alias e poi l'esclusione.
- `DELETE /api/eventi` con `company_id`: cancella solo quella azienda e azzera il suo `last_import`.

- [ ] **Step 1: Scrivere i test (devono fallire)**

Tutti aprono un database in memoria come fa `src/lib/server/db/schema.test.ts` e chiamano gli
endpoint direttamente, passando `locals` finto. Un utente autenticato si simula con
`{ locals: { utente: { id: 'u1', username: 'valentina', tokenVersion: 0 } } }`.

```ts
describe('PUT /api/aziende/[id]', () => {
	it('aggiorna il mapping e restituisce lo snapshot', async () => {
		const r = await PUT(richiesta({ mapping: { insegna: 3, product: 7, selloutStart: 10, selloutEnd: 11, extraInfo: 13, extraInfoLabel: 'Meccanica', headerRow: 0 } }, { id: 'caseifici_gt' }));
		expect(r.status).toBe(200);
		const s = await r.json();
		expect(s.aziende.find((a) => a.id === 'caseifici_gt').mapping.product).toBe(7);
	});

	it('accetta 0 come indice di colonna', async () => {
		// headerRow vale 0 su tutte e tre le aziende, e insegna vale 0 su Parmacotto.
		// Un controllo di verità invece di typeof === 'number' li rifiuterebbe entrambi.
		const r = await PUT(richiesta({ mapping: { insegna: 0, product: 1, selloutStart: 3, selloutEnd: 4, extraInfo: 6, extraInfoLabel: 'Volantino', headerRow: 0 } }, { id: 'parmacotto' }));
		expect(r.status).toBe(200);
		const s = await r.json();
		expect(s.aziende.find((a) => a.id === 'parmacotto').mapping.insegna).toBe(0);
	});

	it('rifiuta un indice che non è un numero', async () => {
		const r = await PUT(richiesta({ mapping: { insegna: '3', product: 7, selloutStart: 10, selloutEnd: 11, extraInfo: 13, extraInfoLabel: 'x', headerRow: 0 } }, { id: 'caseifici_gt' }));
		expect(r.status).toBe(400);
	});

	it('rifiuta un id di azienda inesistente', async () => {
		expect((await PUT(richiesta({ mapping: {} }, { id: 'inventata' }))).status).toBe(404);
	});
});

describe('alias', () => {
	it('crea un alias con le sue varianti', async () => {
		const r = await POST(richiesta({ nomeCanonico: 'ESSELUNGA SPA', varianti: ['ESSELUNGA'] }));
		expect(r.status).toBe(201);
	});

	it('rifiuta una variante già mappata su un altra insegna', async () => {
		// Senza il vincolo, la risoluzione ne pescherebbe una in base all'ordine,
		// in silenzio, e gli eventi finirebbero sull'insegna sbagliata.
		await POST(richiesta({ nomeCanonico: 'ESSELUNGA SPA', varianti: ['ESSELUNGA'] }));
		const r = await POST(richiesta({ nomeCanonico: 'BENNET SPA', varianti: ['ESSELUNGA'] }));
		expect(r.status).toBe(409);
	});

	it('rifiuta un alias la cui canonica è fra le insegne escluse', async () => {
		// Sui dati veri 37 insegne attive collassano sulla chiave di un'insegna esclusa.
		// Accettare l'accorpamento farebbe sparire in silenzio tutti i loro eventi al
		// prossimo import, perché la risoluzione applica prima l'alias e poi l'esclusione.
		await db.insert(schema.ignoredInsegne).values({ name: 'ALFI' });
		const r = await POST(richiesta({ nomeCanonico: 'ALFI', varianti: ['ALFI SRL'] }));
		expect(r.status).toBe(422);
		expect((await r.json()).messaggio).toMatch(/esclus/i);
	});

	it('accetta un alias senza varianti', async () => {
		// Serve a dichiarare "questo nome è già giusto" e a silenziare le segnalazioni.
		expect((await POST(richiesta({ nomeCanonico: 'COOP', varianti: [] }))).status).toBe(201);
	});

	it('scarta le varianti vuote', async () => {
		// Una variante '' combacerebbe con qualunque cella vuota.
		const r = await POST(richiesta({ nomeCanonico: 'COOP', varianti: ['', '  ', 'COOP ITALIA'] }));
		const s = await r.json();
		expect(s.alias.find((a) => a.nomeCanonico === 'COOP').varianti).toEqual(['COOP ITALIA']);
	});
});

describe('PUT /api/insegne-escluse', () => {
	it('sostituisce l elenco e restituisce lo snapshot', async () => {
		const r = await PUT(richiesta({ insegneEscluse: ['ALFI', 'DAO'] }));
		expect((await r.json()).insegneEscluse).toEqual(['ALFI', 'DAO']);
	});

	it('non tocca gli eventi già importati', async () => {
		// L'esclusione vale per gli import futuri: azzerare gli eventi al cambio di
		// una casella sarebbe distruttivo e irreversibile.
		const prima = (await leggiSnapshot()).eventi.length;
		await PUT(richiesta({ insegneEscluse: ['ESSELUNGA SPA'] }));
		expect((await leggiSnapshot()).eventi).toHaveLength(prima);
	});
});

describe('DELETE /api/eventi', () => {
	it('cancella solo l azienda indicata e azzera il suo last_import', async () => {
		const s = await (await DELETE(richiesta({ companyId: 'caseifici_gt' }))).json();
		expect(s.eventi.every((e) => e.companyId !== 'caseifici_gt')).toBe(true);
		expect(s.eventi.some((e) => e.companyId === 'parmacotto')).toBe(true);
		expect(s.aziende.find((a) => a.id === 'caseifici_gt').lastImport).toBeNull();
	});

	it('rifiuta senza companyId invece di cancellare tutto', async () => {
		expect((await DELETE(richiesta({}))).status).toBe(400);
	});
});
```

- [ ] **Step 2: Implementazione.** - [ ] **Step 3: Verifica e commit.**

---

### Task 5: Import, passo 1, anteprima

**File:**
- Creare: `src/lib/server/segnali.ts`, `src/lib/server/segnali.test.ts`
- Creare: `src/routes/api/import/+server.ts`, `src/routes/api/import/[bozzaId]/+server.ts` (DELETE)
- Modificare: `src/lib/server/excel.ts` (aggiungere `intestazioni` e `fogli` a `EsitoLettura`)

Esiste una bozza scritta durante il workflow di verifica in
`.superpowers/sdd/bozze-fase2/segnali.ts` con i suoi test: **leggerla come materiale, non
copiarla**. Non è passata da nessuna revisione.

**Il rischio che questo task chiude.** La fase 1 scarta le righe con la fine prima dell'inizio e
filtra gli anni fuori da 2015-2035, ma non basta: se il mapping punta a una colonna di quantità con
valori fra 42005 e 49307, quei numeri sono seriali Excel di date fra il 2015 e il 2035. Verificato:
tre eventi accettati, zero scarti, nessun segnale. Il cammino dell'incidente di giugno esiste ancora
in forma attenuata.

**Segnali da calcolare**, ognuno con la sua soglia giustificata dai dati veri:
- durata implausibile: oltre 120 giorni. Marca, non scarta: oggi ne marcherebbe 11 su 3.503 e almeno
  due sono accordi annuali legittimi.
- scostamento del tasso di scarto rispetto all'ultimo import riuscito della stessa azienda. Una
  soglia fissa non funziona: i tassi legittimi vanno dallo 0,2% di Caseifici al 18,8% di Parmacotto.
  Per questo `companies.ultimo_import_scarti` esiste nello schema.
- insegne mai viste in percentuale sul totale: se un file introduce l'80% di insegne nuove, o il
  fornitore ha cambiato tutti i clienti o il mapping legge la colonna sbagliata.
- intestazioni delle colonne mappate: confrontarle con quelle attese. Se `sellout_start` punta a una
  colonna intitolata "Qta.Prev." è finita, e il controllo costa una stringa.

L'esito ha tre livelli: `ok`, `conferma` (l'import procede solo con un `conferma: true` esplicito),
`rifiuto` (non procede). Rifiutare un import legittimo costa quanto accettarne uno sbagliato:
Valentina resta bloccata e non ha nessuno a cui chiedere. Il rifiuto è per i casi in cui la
sostituzione distruggerebbe dati esistenti senza rimpiazzarli.

L'endpoint scrive gli eventi come bozza (`draft = 1`, `import_id = bozzaId`), pulisce le bozze più
vecchie di 24 ore, e risponde con l'anteprima. Zero eventi validi su un'azienda che ne ha già: 422 e
**nessuna bozza scritta**.

Gli accorpamenti proposti si calcolano **sull'unione delle insegne di tutte le aziende lette dal
database**, non sul file appena caricato. Misurato: per singolo file le proposte sono 0, sull'unione
sono 9. Dentro un file le insegne sono già coerenti, è fra fornitori diversi che lo stesso cliente
cambia forma.

I test usano workbook sintetici costruiti in memoria con `XLSX.utils.aoa_to_sheet`, mai file su
disco. Deve esserci il test che riproduce l'incidente: mapping puntato su una colonna di quantità,
e l'esito deve essere `conferma` o `rifiuto`, mai `ok`.

- [ ] **Step 1: Test dei segnali.** - [ ] **Step 2: Implementazione dei segnali.**
- [ ] **Step 3: Test dell'endpoint.** - [ ] **Step 4: Implementazione.** - [ ] **Step 5: Commit.**

---

### Task 6: Import, passo 2, conferma

**File:**
- Creare: `src/routes/api/import/[bozzaId]/conferma/+server.ts` e il suo test

Un solo `db.batch()` che fa, in ordine: applica gli accorpamenti accettati scrivendoli negli alias e
rinominando gli eventi **di tutte le aziende**, cancella gli eventi vivi dell'azienda, promuove le
bozze a vivi, aggiorna `last_import` e `ultimo_import_scarti`.

I segnali si **ricalcolano lato server** alla conferma: il client non è la fonte di verità su se un
import è sano. Se l'esito è `conferma` e il corpo non porta `conferma: true`, si risponde 409 e non
si scrive niente.

- [ ] **Step 1: Scrivere i test (devono fallire)**

```ts
describe('POST /api/import/[bozzaId]/conferma', () => {
	it('sostituisce gli eventi vivi con le bozze', async () => {
		// 312 vivi di caseifici_gt + 96 bozze con lo stesso import_id
		const r = await POST(richiesta({}, { bozzaId: 'bz1' }));
		expect(r.status).toBe(200);
		const vivi = await db.select().from(events).where(eq(events.draft, 0));
		expect(vivi.filter((e) => e.companyId === 'caseifici_gt')).toHaveLength(96);
		expect(await db.select().from(events).where(eq(events.draft, 1))).toHaveLength(0);
	});

	it('non tocca le altre aziende', async () => {
		const prima = (await db.select().from(events)).filter((e) => e.companyId === 'parmacotto').length;
		await POST(richiesta({}, { bozzaId: 'bz1' }));
		const dopo = (await db.select().from(events)).filter((e) => e.companyId === 'parmacotto').length;
		expect(dopo).toBe(prima);
	});

	it('aggiorna last_import e ultimo_import_scarti', async () => {
		await POST(richiesta({}, { bozzaId: 'bz1' }));
		const a = (await db.select().from(companies)).find((c) => c.id === 'caseifici_gt')!;
		expect(a.lastImport).not.toBeNull();
		expect(a.ultimoImportScarti).toBeCloseTo(0.05, 2);
	});

	it('se il batch fallisce a metà, i 312 eventi vivi sono ancora tutti lì', async () => {
		// È IL test del task. Il rischio che chiude è il DELETE che va a segno e gli
		// INSERT no, cioè il calendario vuoto. L'atomicità di db.batch() è stata
		// verificata in laboratorio contro un sqld reale: qui si verifica sul
		// cammino vero dell'applicazione.
		// Per far fallire il batch a metà: una bozza che viola un CHECK, per esempio
		// products messo a '{}' con una UPDATE in SQL grezzo prima della conferma.
		await client.execute(`UPDATE events SET products = '{}' WHERE import_id = 'bz1' LIMIT 1`);
		const r = await POST(richiesta({}, { bozzaId: 'bz1' }));
		expect(r.status).toBe(500);
		const vivi = (await db.select().from(events)).filter((e) => e.draft === 0 && e.companyId === 'caseifici_gt');
		expect(vivi).toHaveLength(312);
	});

	it('rifiuta con 409 se i segnali chiedono conferma e il corpo non la porta', async () => {
		// I segnali si ricalcolano lato server: il client non è la fonte di verità
		// su se un import è sano.
		const r = await POST(richiesta({}, { bozzaId: 'bz-sospetta' }));
		expect(r.status).toBe(409);
		expect(await db.select().from(events).where(eq(events.draft, 1))).not.toHaveLength(0);
	});

	it('procede se il corpo porta conferma esplicita', async () => {
		expect((await POST(richiesta({ conferma: true }, { bozzaId: 'bz-sospetta' }))).status).toBe(200);
	});

	it('applica gli accorpamenti accettati anche agli eventi delle altre aziende', async () => {
		// L'accorpamento è fra fornitori diversi: dentro un file le insegne sono già
		// coerenti. Se rinominasse solo l'azienda in import, il gruppo resterebbe spezzato.
		await POST(richiesta({ accorpamentiAccettati: [{ canonica: 'ESSELUNGA SPA', varianti: ['ESSELUNGA'] }] }, { bozzaId: 'bz1' }));
		const tutti = await db.select().from(events);
		expect(tutti.some((e) => e.insegna === 'ESSELUNGA')).toBe(false);
		expect(tutti.some((e) => e.companyId === 'parmacotto' && e.insegna === 'ESSELUNGA SPA')).toBe(true);
	});

	it('scrive negli alias gli accorpamenti accettati', async () => {
		// Così al prossimo import quel gruppo non viene più riproposto:
		// la lista si estingue da sola invece di richiedere ogni volta la stessa risposta.
		await POST(richiesta({ accorpamentiAccettati: [{ canonica: 'ESSELUNGA SPA', varianti: ['ESSELUNGA'] }] }, { bozzaId: 'bz1' }));
		const varianti = await db.select().from(aliasVariants);
		expect(varianti.some((v) => v.variant === 'ESSELUNGA')).toBe(true);
	});

	it('risponde 404 su una bozza inesistente o già consumata', async () => {
		expect((await POST(richiesta({}, { bozzaId: 'mai-esistita' }))).status).toBe(404);
		await POST(richiesta({}, { bozzaId: 'bz1' }));
		expect((await POST(richiesta({}, { bozzaId: 'bz1' }))).status).toBe(404);
	});
});
```

- [ ] **Step 2: Implementazione.** - [ ] **Step 3: Verifica e commit.**

---

### Task 7: Migrazione dei dati

**File:**
- Creare: `scripts/migra-da-php.ts`, `scripts/migra-da-php.test.ts`

Legge i quattro JSON della versione PHP e popola il database. **Dry-run di default**, scrive solo con
`--apply`: un `--apply` lanciato senza `--env-file` scriverebbe in silenzio sul file locale credendo
di aver migrato la produzione.

`JSON.parse` in try/catch su ogni `products`, normalizzando il vuoto a `'[]'` e mai a `''`, perché il
CHECK rifiuta la stringa vuota e sarebbe un errore oscuro a metà migrazione.

- [ ] **Step 1: Scrivere i test (devono fallire)**

I JSON di partenza stanno in `/Users/paolo/Server/Siti Web/_private/valentina/promogest/data/`, sono
i dati veri di produzione rigenerati l'11 agosto 2026 e sono sani. Come le fixture Excel non sono
versionati: i test che li usano si saltano da soli quando mancano.

```ts
describe('migrazione dai JSON della versione PHP', () => {
	it('porta tutto e nei numeri giusti', async () => {
		await migra({ apply: true, db });
		expect(await conta(events)).toBe(895);
		expect(await conta(companies)).toBe(3);
		expect(await conta(users)).toBe(1);
		expect(await conta(ignoredInsegne)).toBe(244);
		expect(await conta(aliases)).toBe(0);
	});

	it('non scrive niente senza --apply', async () => {
		await migra({ apply: false, db });
		expect(await conta(events)).toBe(0);
	});

	it('gli eventi migrati sono identici a quelli di partenza, uno per uno', async () => {
		// Non basta contarli: un errore di mappatura dei campi darebbe lo stesso
		// numero con dentro cose sbagliate.
		await migra({ apply: true, db });
		const origine = JSON.parse(readFileSync(`${DATI}/events.json`, 'utf8')).events;
		const migrati = await db.select().from(events);
		const perId = new Map(migrati.map((e) => [e.id, e]));
		for (const o of origine) {
			const m = perId.get(o.id)!;
			expect(m.insegna).toBe(o.insegna);
			expect(m.insegnaRaw).toBe(o.insegna_raw);
			expect(m.selloutStart).toBe(o.sellout_start);
			expect(m.selloutEnd).toBe(o.sellout_end);
			expect(m.companyId).toBe(o.company_id);
			expect(m.prodotti).toEqual(o.products.map((p) => ({ nome: p.name, extraInfo: p.extra_info ?? '' })));
		}
	});

	it('è ripetibile: due esecuzioni danno lo stesso stato', async () => {
		await migra({ apply: true, db });
		const dopoUna = await db.select().from(events);
		await migra({ apply: true, db });
		expect(await db.select().from(events)).toEqual(dopoUna);
	});

	it('porta i mapping delle colonne senza toccarli', async () => {
		// Sono sette indici per tre aziende, tarati a mano guardando dentro gli Excel:
		// è la conoscenza più costosa da ricostruire di tutto il dataset.
		await migra({ apply: true, db });
		const c = (await db.select().from(companies)).find((x) => x.id === 'caseifici_gt')!;
		expect(c.mapping).toEqual({ insegna: 3, product: 7, selloutStart: 10, selloutEnd: 11, extraInfo: 13, extraInfoLabel: 'Meccanica', headerRow: 0 });
	});

	it('la password di Valentina continua a funzionare dopo la migrazione', async () => {
		// L'hash è in formato $2y$ prodotto da PHP. Verificato che bcryptjs lo accetti,
		// ma se qualcosa lo alterasse durante la copia lei resterebbe fuori dall'app
		// senza un modo per rientrare.
		await migra({ apply: true, db });
		const u = (await db.select().from(users))[0];
		expect(await verificaPassword('valentina', u.passwordHash)).toBe(true);
	});

	it('normalizza a [] i products vuoti, mai a stringa vuota', async () => {
		// Il CHECK rifiuta la stringa vuota, e sarebbe un errore oscuro a metà migrazione.
		// Con un JSON di prova che contiene un evento senza products.
		await migra({ apply: true, db, sorgente: SORGENTE_DI_PROVA });
		expect((await db.select().from(events))[0].prodotti).toEqual([]);
	});

	it('si ferma su un evento che violerebbe un vincolo, invece di saltarlo in silenzio', async () => {
		// Un evento con la fine prima dell'inizio deve fare fallire la migrazione con
		// un messaggio che dice quale record e perché, non essere scartato senza dirlo.
		await expect(migra({ apply: true, db, sorgente: SORGENTE_CON_DATE_ROVESCIATE })).rejects.toThrow(/evt_/);
	});
});
```

- [ ] **Step 2: Implementazione.** - [ ] **Step 3: Verifica e commit.**

---

### Task 8: Igiene dei test ereditata dalla fase 1

Indipendente dagli altri, si può fare in parallelo.

**File:** `src/lib/server/excel.ts`, `src/lib/server/excel.test.ts`

Due cose, entrambe rilievi aperti della revisione finale della fase 1:

1. **I test di `leggiWorkbook` non devono più dipendere dai tre `.xlsx` non versionati.** Oggi tutta
   la copertura della funzione più importante è appesa a file che non sono nella repo: chi clona
   ottiene una suite verde con quella copertura assente e nessun avviso. Si riscrivono con workbook
   sintetici costruiti in memoria, tenendo **tre** test marcati come saltabili sui file veri, uno
   per azienda, ognuno sulla pipeline completa con la mappatura reale di quell'azienda.

   Tre e non due, e ognuno su `leggiWorkbook` e non su `scegliFoglio`: in questo progetto l'errore
   più frequente è la mappatura sbagliata, non il bug nel parser, ed è quello che il 10 giugno 2026
   ha prodotto 2.833 eventi spazzatura su 2.929. Un'ancora per azienda costa zero, perché si salta
   da sola quando il file manca, e ogni azienda che resta senza ancora è una mappatura che nessuno
   sta guardando.
2. **`fondiConflitti` non deve più mutare gli oggetti che riceve.** Oggi
   `vincitore.prodotti.push(...perdente.prodotti)` scrive dentro l'array del chiamante. Non morde
   perché `raggruppa` costruisce oggetti freschi, ma morderà nel task 6, quando il risultato viene
   rielaborato per applicare gli accorpamenti rifiutati. Una copia difensiva in testa alla funzione,
   più un test che chiama `fondiConflitti(eventi)` e asserisce che `eventi` sia invariato dopo.

- [ ] **Step 1: Test.** - [ ] **Step 2: Implementazione.** - [ ] **Step 3: Verifica e commit.**

---

## Ordine di esecuzione

T1, poi T2 e T8 in parallelo, poi T3, T4, T5, T6, T7.

T7 può anticipare subito dopo T1 se serve un database popolato per sviluppare gli altri.

## Cosa resta incerto e va misurato durante l'esecuzione

1. **I limiti di Turso Cloud su dimensione del payload e numero di statement per batch** non sono
   documentati. Tutte le prove sono state fatte contro un `sqld` locale. Un import da 3.000 eventi
   produce circa 1,6 MB di argomenti serializzati. Il primo import reale contro il Turso vero, con il
   file più grosso di Valentina, è il collaudo. Se rifiuta: lotti più piccoli, oppure più batch
   rinunciando all'atomicità e dicendolo.
2. **`PRAGMA foreign_keys` su Turso via HTTP** non è verificato. Su SQLite nudo è disattivato di
   default e ogni richiesta HTTP può essere una connessione diversa. Finché non è misurato, la
   chiave esterna `events.company_id` va considerata **non applicata**, e la validazione
   dell'azienda si fa nell'endpoint.

## Nota sulla spec

Il paragrafo 11.1 di `docs/design.md` descrive i dati di produzione come corrotti: 2.833 eventi
spazzatura su 2.929 e 682 insegne escluse. Erano i numeri prima dell'intervento dell'11 agosto.
Adesso in produzione ci sono 895 eventi sani e 244 insegne escluse. Il paragrafo va aggiornato
insieme a questo piano, altrimenti chi lo legge fra sei mesi crede che il problema sia ancora aperto.
