diff --git a/.dockerignore b/.dockerignore index 8b8a1a4..41696bd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -44,3 +44,4 @@ docker-compose.override.yml .idea .vscode .phpunit.result.cache +.phpunit.cache diff --git a/.gitignore b/.gitignore index 0823e78..af876cf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ /public/storage /public/build/ /.phpunit.result.cache +/.phpunit.cache/ /.idea/ /.vscode/ *.swp diff --git a/HANDOFF.md b/HANDOFF.md index 0a82df8..d0f1db2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto. -Ultimo aggiornamento: **29 luglio 2026**. +Ultimo aggiornamento: **30 luglio 2026**. --- @@ -22,18 +22,36 @@ redeploy, login, tutte le pagine, code e scheduler. | Sviluppo locale | ✅ stesso compose, profilo `dev` | | Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale | | Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) | -| Test automatici | ❌ **assenti** — vedi [Cosa resta aperto](#cosa-resta-aperto) | +| Test automatici | ⚠️ **impalcatura pronta, 57 test** — copertura ancora parziale | -**Punto in cui ci si è fermati (29/07/2026).** Su `develop` è pubblicato tutto: +**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto: backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre -di avanzamento reali e console di log (ultimo commit `f909791`, deploy -verificato confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è -impostato **solo** su develop; la produzione non è stata toccata e resta sul suo -deploy precedente, senza quella variabile. +di avanzamento reali e console di log (commit `f909791`, deploy verificato +confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo** +su develop; la produzione non è stata toccata e resta sul suo deploy +precedente, senza quella variabile. -Il ripristino dall'interfaccia resta però impraticabile per pacchetti grandi -finché non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta -aperto*. +Il 30/07 sono state fatte due cose. + +**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando, +senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione +della logica di business — vedi *Cosa resta aperto*. Non fanno parte +dell'immagine di produzione e non girano su un deploy: sono uno strumento +locale. + +**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con +un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con +gli stati di focus, che prima non c'erano), e **una sola console** per tutte le +operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di +log sparsi. L'azzeramento è diventato un componente a sé per poter stare in +fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel +browser — apertura automatica della console, scorrimento, lampeggio delle zone — +non è stato provato**, perché richiede un browser: è la prima cosa da guardare +aprendo la pagina. + +Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché +non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto* +e l'unica che richiede una decisione, non del lavoro. --- @@ -556,6 +574,228 @@ Tre scelte non ovvie: hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità. +### La pagina "Backup e import": cinque zone e una sola console + +La pagina era cresciuta per accumulo: sette riquadri senza un ordine dichiarato, +la zona pericolosa a metà strada, **cinque** aree di log separate e pulsanti che +ogni sezione ridefiniva per conto proprio. È stata rimessa in ordine attorno a +tre regole. + +**Cinque zone numerate, in ordine di quanto sono definitive.** `1 Backup`, +`2 Ripristino`, `3 Import PDF`, `4 XML`, `5 Zona pericolosa`. Ognuna ha +un'ancora (`id="zona-…"`) e l'indice appiccicato in cima ci salta sopra. Un test +verifica che le cinque ancore esistano *e che siano in quell'ordine nel +sorgente*: l'ordine è la sostanza della sistemazione, non un dettaglio estetico. + +**Il "focus" della pagina.** Premuto un pulsante — o scelta una voce +dell'indice — la pagina si sposta sulla zona di riferimento e la fa lampeggiare +(`window.tmFocusZona`). Il focus da tastiera si sposta **solo** quando la +richiesta è di navigazione: spostarlo anche a ogni azione strapperebbe il +cursore da sotto le dita di chi ha appena premuto. Il primo comando di ogni zona +è marcato `data-zona-focus` ed è quello che lo riceve. + +**Una sola console, agganciata in fondo alla finestra.** Prima la stessa +operazione si raccontava in tre punti diversi: un ripristino cominciava nel +riquadro "Browser", proseguiva in quello "Server" e aveva un terzo "Dettaglio" +richiudibile nel pannello di avanzamento, in cima. Per capire dove si era +fermato bisognava guardarli tutti. Adesso c'è una cronologia sola, in ordine di +tempo, con la sorgente accanto a ogni riga, e la finestra è *fissa*: quasi tutti +i modi in cui un caricamento muore sono muti, e l'unico indizio è dove si è +interrotto il flusso di righe — un log che esce dallo schermo appena si scorre +non serve proprio nel momento in cui servirebbe. + +Dettagli non ovvi della console: + +- **Le righe del server viaggiano già scremate.** Il conto di quante ne sono + state spedite lo tiene il componente Livewire (`logSpediti`), non il browser: + la coda intera dentro ogni risposta significherebbe sessanta righe poco meno + di due volte al secondo, e per l'import PDF — la cui coda non ha tetto — + l'intera cronologia a ogni poll. + +- **Per ogni operazione c'è un solo componente che la racconta** + (`SegueOperazioneBackup::tipiSeguiti()`). Backup e zona pericolosa guardano lo + stesso stato condiviso: senza un proprietario dichiarato spedirebbero entrambi + le stesse righe, e la console le mostrerebbe due volte. + +- **Il download automatico si ascolta in un posto solo.** `scarica-backup` è un + evento su `window`: due componenti che lo ascoltassero entrambi assegnerebbero + `window.location.href` due volte, e il pacchetto verrebbe scaricato due volte. + L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo + stesso motivo `scaricaOra()` esce subito se non è il proprietario a chiamarlo: + il segno "download già inviato" sta nello stato condiviso, quindi chi segue + l'operazione di riflesso lo consumerebbe per primo — l'archivio sarebbe pronto + e non arriverebbe a nessuno, e per l'azzeramento è pure il passo che sblocca + la cancellazione. + +- **Lo scorrimento automatico si sospende se si torna indietro a leggere.** + Trascinare in fondo mentre si sta guardando una riga di dieci secondi prima è + il modo più rapido di rendere inutile un log che scorre. Compare invece + "Torna in fondo". + +- **Il pannello è a posizione fissa, quindi la pagina ha uno spaziatore in + fondo.** Senza, coprirebbe l'ultima zona — che è quella pericolosa. + +- **Il JavaScript della console sta in un ` + +
Sezione Import
Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.
{{ $message }}
@enderror - +Backup completo dell’applicazione, import PDF territori, conversione legacy SQL, import ed export XML.
++ Backup completo dell’applicazione, ripristino, import dei PDF dei territori, + scambio dati in XML e azzeramento. +
Puoi importare una cartella di PDF oppure, meglio per archivi grandi, un file ZIP contenente i PDF. Il nome file puo variare: basta che contenga il numero di un territorio gia presente nell'app. I PDF verranno associati ai territori esistenti e verra generata anche la thumbnail.
+ let ultimaTacca = -1; + xhr.upload.addEventListener('progress', (e) => { + if (!e.lengthComputable) { + return; + } + this.zipPercento = Math.round((e.loaded / e.total) * 100); + const tacca = Math.floor(this.zipPercento / 10); + if (tacca > ultimaTacca) { + ultimaTacca = tacca; + this.log(`Caricamento ZIP ${this.zipPercento}%`); + } + }); - + if (xhr.status >= 200 && xhr.status < 300) { + this.zipPercento = 100; + this.log(`Archivio ricevuto. La pagina si ricarica sulla console dell'import.`, 'ok'); + window.location = payload.redirect_url || window.location.href; + return; + } -Consigliato per grandi volumi: carichi un solo file e il server estrae automaticamente tutti i PDF.
+ this.zipPercento = 0; + const messaggio = payload.message + || (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : `Errore durante il caricamento dello ZIP (HTTP ${xhr.status}).`); + this.log(`ERRORE: ${messaggio}`, 'errore'); + }); -Link copiato.
diff --git a/tests/Feature/HomeMediaDurataTest.php b/tests/Feature/HomeMediaDurataTest.php new file mode 100644 index 0000000..cabf115 --- /dev/null +++ b/tests/Feature/HomeMediaDurataTest.php @@ -0,0 +1,149 @@ +withoutVite(); + + Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web'])); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); + + $this->utente = User::create([ + 'name' => 'Amministratore', + 'email' => 'admin@example.test', + 'password' => 'password-di-prova', + ]); + $this->utente->assignRole('amministratore'); + $this->actingAs($this->utente); + + $this->proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]); + $this->anno = AnnoTeocratico::corrente(); + } + + public function test_senza_assegnazioni_chiuse_la_media_e_zero(): void + { + $this->assertSame(0, $this->media()); + } + + public function test_un_assegnazione_ancora_aperta_non_entra_nella_media(): void + { + $this->assegnazione($this->anno->start_date->copy()->addDays(10), null); + + $this->assertSame(0, $this->media()); + } + + public function test_la_media_e_in_mesi_da_trenta_giorni_e_mezzo(): void + { + // 10 e 20 giorni: media 15, cioè 15/30,44 = 0,5 mesi. + $this->assegnazioneDiGiorni(10); + $this->assegnazioneDiGiorni(20); + + $this->assertSame(0.5, $this->media()); + } + + public function test_un_rientro_nello_stesso_giorno_non_abbassa_la_media(): void + { + $this->assegnazioneDiGiorni(30); + $this->assegnazioneDiGiorni(0); + + // Senza il filtro la media sarebbe 15 giorni invece di 30. + $this->assertSame(1.0, $this->media()); + } + + /** + * La conversione del vecchio database ha lasciato qualche data impossibile: + * il filtro esisteva per quelle, e deve continuare a esistere. + */ + public function test_una_data_antecedente_al_1900_viene_ignorata(): void + { + $this->assegnazioneDiGiorni(30); + + $this->assegnazione('1899-01-01', '1899-03-01'); + + $this->assertSame(1.0, $this->media()); + } + + public function test_le_assegnazioni_di_un_altro_anno_non_entrano_nella_media(): void + { + $this->assegnazioneDiGiorni(30); + + $altroAnno = AnnoTeocratico::create([ + 'label' => '2000-2001', + 'start_date' => '2000-09-01', + 'end_date' => '2001-08-31', + ]); + $this->assegnazione('2000-09-01', '2001-03-01', $altroAnno); + + $this->assertSame(1.0, $this->media()); + } + + // ─── helper ───────────────────────────────────────────────── + + private function media(): float|int + { + $valore = Livewire::test(Home::class)->viewData('mediaDurataPercorrenzaMesi'); + + return $valore; + } + + private function assegnazioneDiGiorni(int $giorni): void + { + $inizio = $this->anno->start_date->copy()->addDays(5); + + $this->assegnazione($inizio, $inizio->copy()->addDays($giorni)); + } + + private function assegnazione(mixed $da, mixed $a, ?AnnoTeocratico $anno = null): void + { + $territorio = Territorio::create([ + 'numero' => (string) $this->prossimoTerritorio++, + 'attivo' => true, + ]); + + Assegnazione::create([ + 'territorio_id' => $territorio->id, + 'proclamatore_id' => $this->proclamatore->id, + 'anno_teocratico_id' => ($anno ?? $this->anno)->id, + 'assigned_at' => $da, + 'returned_at' => $a, + 'created_by' => $this->utente->id, + ]); + } +} diff --git a/tests/Feature/PaginaBackupImportTest.php b/tests/Feature/PaginaBackupImportTest.php new file mode 100644 index 0000000..aa5bc27 --- /dev/null +++ b/tests/Feature/PaginaBackupImportTest.php @@ -0,0 +1,195 @@ + non viene + * compilato, il tag resta letterale nel PHP generato e la pagina muore con un + * errore di sintassi. Qui quella classe di errori si vede subito. + * + * Quello che questi test NON possono coprire: Alpine e il comportamento della + * console (apertura automatica, scorrimento, evidenziazione delle zone) girano + * nel browser, e qui non c'è un browser. Vanno provati a mano. + */ +class PaginaBackupImportTest extends TestCase +{ + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + + // L'immagine di test non contiene public/build: gli asset sono + // compilati in uno stage a parte, che serve al runtime e non qui. Senza + // questo, ogni pagina morirebbe su "Vite manifest not found" — un + // fallimento che non parla del markup, cioe' l'unica cosa che questi + // test guardano. Che la build degli asset funzioni lo dice `npm run + // build`, non PHPUnit. + $this->withoutVite(); + } + + private function admin(): User + { + Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web'])); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $utente = User::create([ + 'name' => 'Amministratore', + 'email' => 'admin@example.test', + 'password' => 'password-di-prova', + ]); + $utente->assignRole('amministratore'); + + return $utente; + } + + public function test_la_pagina_si_apre_per_l_amministratore(): void + { + $this->actingAs($this->admin()) + ->get(route('xml.exchange')) + ->assertOk(); + } + + public function test_chi_non_e_amministratore_non_ci_entra(): void + { + $utente = User::create([ + 'name' => 'Assistente', + 'email' => 'assistente@example.test', + 'password' => 'password-di-prova', + ]); + + $this->actingAs($utente) + ->get(route('xml.exchange')) + ->assertForbidden(); + } + + /** + * Le cinque zone esistono e sono raggiungibili per ancora: l'indice in cima + * alla pagina ci salta sopra, e un id sbagliato lo renderebbe un pulsante + * che non fa niente. + */ + public function test_ci_sono_tutte_e_cinque_le_zone_con_la_loro_ancora(): void + { + $risposta = $this->actingAs($this->admin())->get(route('xml.exchange')); + + foreach (['zona-backup', 'zona-ripristino', 'zona-pdf', 'zona-xml', 'zona-azzeramento'] as $zona) { + $risposta->assertSee('id="' . $zona . '"', false); + $risposta->assertSee('tmFocusZona(\'' . $zona . '\'', false); + } + } + + /** + * La zona pericolosa è l'ultima. È il motivo per cui l'azzeramento è stato + * estratto in un componente suo: reso da BackupManager finiva a metà pagina, + * fra il ripristino e l'import, che è l'ultimo posto in cui si vuole trovare + * un pulsante che cancella tutto. + */ + public function test_la_zona_pericolosa_e_l_ultima_della_pagina(): void + { + $html = $this->actingAs($this->admin())->get(route('xml.exchange'))->getContent(); + + $posizioni = []; + foreach (['zona-backup', 'zona-ripristino', 'zona-pdf', 'zona-xml', 'zona-azzeramento'] as $zona) { + $posizioni[$zona] = strpos($html, 'id="' . $zona . '"'); + $this->assertNotFalse($posizioni[$zona], "Zona {$zona} assente dalla pagina."); + } + + $this->assertSame( + ['zona-backup', 'zona-ripristino', 'zona-pdf', 'zona-xml', 'zona-azzeramento'], + array_keys($posizioni), + 'Ordine atteso delle chiavi.' + ); + + $precedente = -1; + foreach ($posizioni as $zona => $posizione) { + $this->assertGreaterThan($precedente, $posizione, "La zona {$zona} è fuori ordine."); + $precedente = $posizione; + } + } + + public function test_la_console_unica_e_nella_pagina(): void + { + $risposta = $this->actingAs($this->admin())->get(route('xml.exchange')); + + $risposta->assertSee('tmConsoleOperazioni', false); + $risposta->assertSee('class="tm-console"', false); + } + + /** + * Ordine di caricamento, che è il motivo per cui quel JavaScript sta in uno + *