++ fix e controlli

This commit is contained in:
2026-07-30 17:05:39 +02:00
parent 712336d652
commit 440d63a6b9
54 changed files with 4921 additions and 1322 deletions

View File

@@ -0,0 +1,149 @@
<?php
namespace Tests\Feature;
use App\Livewire\Home;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* "Media percorrenza congregazione": la durata media di un'assegnazione chiusa,
* in mesi, sull'anno di servizio corrente.
*
* Il calcolo è stato spostato da SQL a PHP perché usava DATEDIFF() e YEAR(),
* funzioni MySQL che rendevano la dashboard l'unica pagina non verificabile
* dalla suite. Questi test fissano il comportamento che lo spostamento doveva
* lasciare intatto — compresi i due filtri, che sono la parte facile da perdere
* riscrivendo: senza, una data assurda ereditata dalla vecchia applicazione o
* un rientro nello stesso giorno spostano la media senza volerlo dire.
*/
class HomeMediaDurataTest extends TestCase
{
use RefreshDatabase;
private User $utente;
private Proclamatore $proclamatore;
private AnnoTeocratico $anno;
private int $prossimoTerritorio = 1;
protected function setUp(): void
{
parent::setUp();
$this->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,
]);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* La pagina "Backup e import" servita per intero: rotta, permessi, layout e i
* tre componenti Livewire annidati.
*
* Serve perché il grosso di questa pagina è markup, e il markup non fallisce
* come il codice: una vista che non compila non rompe niente finché qualcuno non
* la apre. È già successo — `@disabled(...)` dentro un tag <x-btn> 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
* <script> nella pagina e non in resources/js/app.js.
*
* Alpine arriva col pacchetto di Livewire, in fondo al body, e valuta
* x-data="tmConsoleOperazioni()" quando parte: se la funzione non fosse
* ancora definita, la console sarebbe un riquadro morto. Il bundle di app.js
* non andrebbe bene perché è un modulo ES, che il browser rimanda a dopo il
* parsing del documento — cioè dopo Alpine.
*
* Il rapporto fra i due è invisibile guardando i file: si vede solo qui.
*/
public function test_la_console_e_definita_prima_che_alpine_parta(): void
{
$html = $this->actingAs($this->admin())->get(route('xml.exchange'))->getContent();
$definizione = strpos($html, 'window.tmConsoleOperazioni =');
$livewire = strpos($html, 'livewire.js');
$this->assertNotFalse($definizione, 'La definizione della console non è nella pagina.');
$this->assertNotFalse($livewire, 'Il pacchetto di Livewire non è nella pagina.');
$this->assertLessThan(
$livewire,
$definizione,
'La console va definita prima del pacchetto che avvia Alpine, altrimenti x-data non la trova.'
);
}
/**
* I cinque riquadri di log separati sono spariti: se ne torna uno, la pagina
* ricomincia a raccontare la stessa operazione in due punti diversi.
*/
public function test_non_sono_rimasti_riquadri_di_log_separati(): void
{
$risposta = $this->actingAs($this->admin())->get(route('xml.exchange'));
$risposta->assertDontSee('Console upload');
$risposta->assertDontSee('Log import PDF');
}
/**
* Il difetto che questo test presidia: se un tag di componente non viene
* compilato, resta nella pagina come testo. Non solleva, non rompe il
* layout in modo evidente — semplicemente il pulsante non c'è.
*/
public function test_nessun_tag_di_componente_e_rimasto_da_compilare(): void
{
$html = $this->actingAs($this->admin())->get(route('xml.exchange'))->getContent();
$this->assertStringNotContainsString('<x-btn', $html);
$this->assertStringNotContainsString('</x-btn>', $html);
}
/**
* I pulsanti passano tutti dal sistema unico. Un `style=` inline su un
* pulsante è il segnale che qualcuno è tornato a definirne uno per conto suo,
* senza stato di focus.
*/
public function test_i_pulsanti_usano_le_classi_del_sistema_unico(): void
{
$html = $this->actingAs($this->admin())->get(route('xml.exchange'))->getContent();
foreach (['tm-btn tm-btn-primary', 'tm-btn tm-btn-danger', 'tm-btn tm-btn-secondary'] as $classe) {
$this->assertStringContainsString($classe, $html, "Manca un pulsante {$classe}.");
}
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* Ogni pagina dell'applicazione si apre davvero.
*
* Nasce dall'unificazione dello stile, che ha toccato tutte le viste: un errore
* di markup non fallisce come un errore di codice — non rompe niente finche'
* qualcuno non apre quella pagina, e con venti viste modificate in una volta
* "qualcuno" e' un utente, non chi ha fatto la modifica.
*
* Il controllo su `<x-` non e' teorico: un tag di componente che il compilatore
* non riconosce resta nella pagina come testo. Non solleva, non rompe il
* layout in modo evidente — semplicemente il pulsante non c'e'.
*/
class PagineApplicativeTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Gli asset compilati non stanno nell'immagine di test: vedi la nota in
// PaginaBackupImportTest.
$this->withoutVite();
}
/**
* Rotte senza parametri. Quelle con un modello nell'URL hanno un test a
* parte, perche' hanno bisogno che il modello esista.
*
* @return array<string, array{string}>
*/
public static function rotteSempliciProvider(): array
{
$rotte = [
'dashboard',
'profile',
'territori.index',
'territori.create',
'territori.cestino',
'proclamatori.index',
'proclamatori.create',
'proclamatori.cestino',
'campagne.index',
'campagne.create',
'registro.index',
'audit.index',
'settings.edit',
'users.index',
'zone.index',
'tipologie.index',
'xml.exchange',
'privacy',
'assegnazioni.assegna',
];
return array_combine(
$rotte,
array_map(fn (string $rotta) => [$rotta], $rotte)
);
}
#[DataProvider('rotteSempliciProvider')]
public function test_la_pagina_si_apre_senza_markup_rimasto_da_compilare(string $rotta): void
{
$risposta = $this->actingAs($this->admin())->get(route($rotta));
$risposta->assertOk();
$this->assertNessunTagDaCompilare($risposta->getContent(), $rotta);
}
public function test_le_pagine_legate_a_un_record_si_aprono(): void
{
$this->actingAs($this->admin());
$territorio = Territorio::create(['numero' => '1', 'attivo' => true]);
$proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$campagna = Campagna::create([
'descrizione' => 'Campagna di prova',
'start_date' => '2026-01-01',
'end_date' => '2026-12-31',
]);
$assegnazione = Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $proclamatore->id,
'anno_teocratico_id' => AnnoTeocratico::corrente()->id,
'assigned_at' => '2026-02-01',
'created_by' => auth()->id(),
]);
$pagine = [
'territori.show' => route('territori.show', $territorio),
'territori.edit' => route('territori.edit', $territorio),
'proclamatori.show' => route('proclamatori.show', $proclamatore),
'proclamatori.edit' => route('proclamatori.edit', $proclamatore),
'campagne.show' => route('campagne.show', $campagna),
'campagne.edit' => route('campagne.edit', $campagna),
'assegnazioni.rientra' => route('assegnazioni.rientra', $assegnazione),
];
foreach ($pagine as $nome => $url) {
$risposta = $this->get($url);
$risposta->assertOk();
$this->assertNessunTagDaCompilare($risposta->getContent(), $nome);
}
}
public function test_la_schermata_di_cambio_password_si_apre(): void
{
$risposta = $this->actingAs($this->admin())->get(route('password.change'));
$risposta->assertOk();
$this->assertNessunTagDaCompilare($risposta->getContent(), 'password.change');
}
public function test_la_pagina_di_accesso_si_apre(): void
{
$risposta = $this->get(route('login'));
$risposta->assertOk();
$this->assertNessunTagDaCompilare($risposta->getContent(), 'login');
}
private function assertNessunTagDaCompilare(string $html, string $dove): void
{
$this->assertStringNotContainsString('<x-btn', $html, "{$dove}: tag <x-btn> rimasto da compilare.");
$this->assertStringNotContainsString('</x-btn>', $html, "{$dove}: tag </x-btn> rimasto da compilare.");
$this->assertStringNotContainsString('btn-action', $html, "{$dove}: e' rimasta una classe .btn-action, che non esiste piu' nel foglio di stile.");
}
private function admin(): User
{
Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web']));
app(PermissionRegistrar::class)->forgetCachedPermissions();
// La testata legge le impostazioni: senza riga, il nome della
// congregazione ricade sul default e va bene lo stesso, ma le pagine
// che leggono le soglie vogliono trovarla.
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
$utente = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-di-prova',
]);
$utente->assignRole('amministratore');
return $utente;
}
}

View File

@@ -0,0 +1,333 @@
<?php
namespace Tests\Feature;
use App\Livewire\Registro;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use ReflectionMethod;
use Tests\TestCase;
/**
* Modulo S-13-I: il ciclo da quattro gruppi "Assegnato a" e la colonna
* "Restituito l'ultima volta il".
*
* E' la regola meno intuitiva del Registro — la quinta assegnazione non si
* aggiunge alle quattro precedenti, le sostituisce — e finisce su un modulo che
* viene firmato e archiviato: se sbaglia, sbaglia su carta.
*
* righeS13() e' privata di proposito (non e' un'azione Livewire); qui la si
* chiama in riflessione perche' e' il calcolo, non il PDF, quello da proteggere.
*/
class RegistroS13Test extends TestCase
{
use RefreshDatabase;
private User $utente;
private Proclamatore $proclamatore;
private AnnoTeocratico $anno;
protected function setUp(): void
{
parent::setUp();
$this->utente = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password',
]);
$this->proclamatore = Proclamatore::create([
'nome' => 'Mario',
'cognome' => 'Rossi',
'attivo' => true,
]);
$this->anno = AnnoTeocratico::create([
'label' => '2025-2026',
'start_date' => '2025-09-01',
'end_date' => '2026-08-31',
]);
}
// ─── Il primo ciclo ─────────────────────────────────────────
public function test_una_sola_assegnazione_riempie_la_prima_colonna_e_lascia_le_altre_in_bianco(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-10', '2025-10-15');
$riga = $this->righe()->sole();
$this->assertSame('1', $riga['numero']);
$this->assertSame('', $riga['riporto']);
$this->assertCount(4, $riga['assegnazioni']);
$this->assertSame(
['nome' => 'Rossi Mario', 'assegnato_il' => '10/09/2025', 'restituito_il' => '15/10/2025'],
$riga['assegnazioni'][0]
);
// Le colonne libere restano stampate: sul modulo cartaceo si compilano
// a mano.
foreach ([1, 2, 3] as $indice) {
$this->assertSame(
['nome' => '', 'assegnato_il' => '', 'restituito_il' => ''],
$riga['assegnazioni'][$indice]
);
}
}
public function test_un_assegnazione_ancora_aperta_ha_la_data_di_rientro_vuota(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-10');
$riga = $this->righe()->sole();
$this->assertSame('10/09/2025', $riga['assegnazioni'][0]['assegnato_il']);
$this->assertSame('', $riga['assegnazioni'][0]['restituito_il']);
}
public function test_tre_assegnazioni_occupano_le_prime_tre_colonne(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$riga = $this->righe()->sole();
$this->assertSame(
['01/09/2025', '01/10/2025', '01/11/2025', ''],
array_column($riga['assegnazioni'], 'assegnato_il')
);
$this->assertSame('', $riga['riporto']);
}
public function test_quattro_assegnazioni_riempiono_la_riga_senza_riporto(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$this->assegna($territorio, '2025-12-01', '2025-12-20');
$riga = $this->righe()->sole();
$this->assertSame(
['01/09/2025', '01/10/2025', '01/11/2025', '01/12/2025'],
array_column($riga['assegnazioni'], 'assegnato_il')
);
// Finche' il primo ciclo non e' esaurito non c'e' niente da riportare.
$this->assertSame('', $riga['riporto']);
}
// ─── Il ciclo che ricomincia ────────────────────────────────
/**
* Il caso che da' il nome alla regola: riempito il quarto gruppo si
* ricomincia da capo, come sul modulo cartaceo si aprirebbe una riga nuova.
* Le prime quattro assegnazioni escono dal modulo e restano solo nel
* riporto.
*/
public function test_la_quinta_assegnazione_apre_un_ciclo_nuovo_e_lascia_il_riporto(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$this->assegna($territorio, '2025-12-01', '2025-12-24');
$this->assegna($territorio, '2026-01-05', '2026-01-25');
$riga = $this->righe()->sole();
$this->assertSame(
['05/01/2026', '', '', ''],
array_column($riga['assegnazioni'], 'assegnato_il')
);
// La colonna riporta la data di rientro della quarta, cioe' quella che
// chiudeva il giro precedente — senza il nome di chi lo aveva.
$this->assertSame('24/12/2025', $riga['riporto']);
}
public function test_otto_assegnazioni_mostrano_il_secondo_ciclo_per_intero(): void
{
$territorio = $this->territorio('1');
$date = [
['2025-09-01', '2025-09-20'],
['2025-10-01', '2025-10-20'],
['2025-11-01', '2025-11-20'],
['2025-12-01', '2025-12-24'],
['2026-01-05', '2026-01-25'],
['2026-02-05', '2026-02-25'],
['2026-03-05', '2026-03-25'],
['2026-04-05', '2026-04-25'],
];
foreach ($date as [$da, $a]) {
$this->assegna($territorio, $da, $a);
}
$riga = $this->righe()->sole();
$this->assertSame(
['05/01/2026', '05/02/2026', '05/03/2026', '05/04/2026'],
array_column($riga['assegnazioni'], 'assegnato_il')
);
$this->assertSame('24/12/2025', $riga['riporto']);
}
public function test_alla_nona_assegnazione_il_riporto_e_quello_dell_ottava(): void
{
$territorio = $this->territorio('1');
$date = [
['2025-09-01', '2025-09-20'],
['2025-10-01', '2025-10-20'],
['2025-11-01', '2025-11-20'],
['2025-12-01', '2025-12-24'],
['2026-01-05', '2026-01-25'],
['2026-02-05', '2026-02-25'],
['2026-03-05', '2026-03-25'],
['2026-04-05', '2026-04-28'],
['2026-05-05', null],
];
foreach ($date as [$da, $a]) {
$this->assegna($territorio, $da, $a);
}
$riga = $this->righe()->sole();
$this->assertSame(
['05/05/2026', '', '', ''],
array_column($riga['assegnazioni'], 'assegnato_il')
);
$this->assertSame('28/04/2026', $riga['riporto']);
}
/**
* Il riporto legge la data di rientro dell'ultima assegnazione del giro
* precedente: se quella e' ancora aperta la colonna resta vuota invece di
* inventarsi una data.
*/
public function test_il_riporto_resta_vuoto_se_l_ultima_del_giro_precedente_non_e_rientrata(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$this->assegna($territorio, '2025-12-01', null);
$this->assegna($territorio, '2026-01-05', '2026-01-25');
$riga = $this->righe()->sole();
$this->assertSame('05/01/2026', $riga['assegnazioni'][0]['assegnato_il']);
$this->assertSame('', $riga['riporto']);
}
// ─── Cosa entra nel modulo ──────────────────────────────────
public function test_i_territori_disattivati_restano_fuori_dal_modulo(): void
{
$attivo = $this->territorio('1');
$disattivato = $this->territorio('2', attivo: false);
$this->assegna($attivo, '2025-09-01');
$this->assegna($disattivato, '2025-09-01');
$this->assertSame(['1'], $this->righe()->pluck('numero')->all());
}
public function test_i_territori_nel_cestino_restano_fuori_dal_modulo(): void
{
$vivo = $this->territorio('1');
$cestinato = $this->territorio('2');
$this->assegna($vivo, '2025-09-01');
$this->assegna($cestinato, '2025-09-01');
$cestinato->delete();
$this->assertSame(['1'], $this->righe()->pluck('numero')->all());
}
public function test_le_assegnazioni_di_un_altro_anno_di_servizio_non_compaiono(): void
{
$altroAnno = AnnoTeocratico::create([
'label' => '2024-2025',
'start_date' => '2024-09-01',
'end_date' => '2025-08-31',
]);
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-01-10', '2025-02-10', $altroAnno);
$riga = $this->righe()->sole();
$this->assertSame(
['01/09/2025', '', '', ''],
array_column($riga['assegnazioni'], 'assegnato_il')
);
}
public function test_un_territorio_senza_assegnazioni_nell_anno_non_produce_una_riga(): void
{
$this->territorio('1');
$this->assertTrue($this->righe()->isEmpty());
}
/**
* I numeri di territorio sono stringhe ("1", "1 A", "10"): l'ordinamento e'
* naturale, non alfabetico, altrimenti il 10 finirebbe fra l'1 e il 2.
*/
public function test_le_righe_sono_in_ordine_naturale_di_numero(): void
{
foreach (['10', '2', '1 A', '1'] as $numero) {
$this->assegna($this->territorio($numero), '2025-09-01');
}
$this->assertSame(['1', '1 A', '2', '10'], $this->righe()->pluck('numero')->all());
}
// ─── helper ─────────────────────────────────────────────────
private function territorio(string $numero, bool $attivo = true): Territorio
{
return Territorio::create([
'numero' => $numero,
'attivo' => $attivo,
]);
}
private function assegna(
Territorio $territorio,
string $assegnatoIl,
?string $rientratoIl = null,
?AnnoTeocratico $anno = null
): Assegnazione {
return Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $this->proclamatore->id,
'anno_teocratico_id' => ($anno ?? $this->anno)->id,
'assigned_at' => $assegnatoIl,
'returned_at' => $rientratoIl,
'created_by' => $this->utente->id,
]);
}
/**
* @return Collection<int, array>
*/
private function righe(?AnnoTeocratico $anno = null): Collection
{
$metodo = new ReflectionMethod(Registro::class, 'righeS13');
return $metodo->invoke(new Registro(), $anno ?? $this->anno);
}
}

View File

@@ -0,0 +1,264 @@
<?php
namespace Tests\Feature;
use App\Jobs\CreateBackupArchive;
use App\Jobs\ResetApplication;
use App\Livewire\Settings\BackupManager;
use App\Livewire\Settings\ZonaPericolosa;
use App\Models\User;
use App\Services\BackupOperationState;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* L'azzeramento: l'unica operazione irreversibile dell'applicazione.
*
* Questi test esistono perché il flusso è stato spostato da BackupManager a un
* componente suo, per poterlo mettere in fondo alla pagina invece che a metà. Lo
* spostamento non doveva cambiare niente di quello che protegge: i due tempi
* separati dal download, la password richiesta al secondo, il limite ai
* tentativi, il posto unico. Sono esattamente i controlli di cui non ci si
* accorge che si sono rotti finché non servono.
*/
class ZonaPericolosaTest extends TestCase
{
use RefreshDatabase;
private const PASSWORD = 'password-di-prova';
private User $admin;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => self::PASSWORD,
]);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
// ─── Primo tempo ────────────────────────────────────────────
public function test_senza_la_frase_esatta_non_parte_niente(): void
{
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', 'azzera')
->call('avviaAzzeramento')
->assertHasErrors('confermaReset');
Queue::assertNothingPushed();
$this->assertNull(app(BackupOperationState::class)->corrente());
}
public function test_la_frase_esatta_avvia_il_backup_preventivo_e_niente_altro(): void
{
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
->call('avviaAzzeramento')
->assertHasNoErrors();
// Il primo tempo crea solo l'archivio: la cancellazione arriva dopo, e
// solo su conferma umana.
Queue::assertPushed(CreateBackupArchive::class);
Queue::assertNotPushed(ResetApplication::class);
$stato = app(BackupOperationState::class);
$corrente = $stato->corrente();
$this->assertNotNull($corrente, 'L\'azzeramento deve occupare il posto unico.');
$this->assertSame(BackupOperationState::TIPO_RESET, $stato->get($corrente)['tipo']);
}
public function test_la_frase_di_conferma_si_svuota_dopo_l_avvio(): void
{
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
->call('avviaAzzeramento')
->assertSet('confermaReset', '');
}
public function test_un_backup_gia_in_corso_impedisce_l_azzeramento(): void
{
app(BackupOperationState::class)->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 10);
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
->call('avviaAzzeramento');
Queue::assertNotPushed(CreateBackupArchive::class);
$this->assertSame('op-backup', app(BackupOperationState::class)->corrente());
}
// ─── Secondo tempo ──────────────────────────────────────────
/**
* Il controllo che regge tutto il disegno a due tempi: senza la prova che
* l'archivio è stato spedito, la cancellazione non parte. Il server non può
* sapere se il file è *arrivato* — nginx bufferizza la risposta e un
* download annullato è indistinguibile da uno riuscito — ma può almeno
* sapere di averlo mandato.
*/
public function test_senza_archivio_servito_la_conferma_non_cancella_niente(): void
{
$this->operazioneInAttesa(servito: false);
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', self::PASSWORD)
->assertHasErrors('confermaReset');
Queue::assertNotPushed(ResetApplication::class);
}
public function test_senza_password_la_conferma_non_cancella_niente(): void
{
$this->operazioneInAttesa();
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', '')
->assertHasErrors('passwordConferma');
Queue::assertNotPushed(ResetApplication::class);
}
public function test_con_la_password_sbagliata_la_conferma_non_cancella_niente(): void
{
$this->operazioneInAttesa();
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', 'non-e-la-mia-password')
->assertHasErrors('passwordConferma');
Queue::assertNotPushed(ResetApplication::class);
}
public function test_con_la_password_giusta_l_azzeramento_parte(): void
{
$this->operazioneInAttesa();
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', self::PASSWORD)
->assertHasNoErrors();
Queue::assertPushed(ResetApplication::class);
}
/**
* Senza limite ai tentativi, chiedere la password su una sessione dirottata
* sarebbe solo un rallentamento: si proverebbero le password comuni a
* raffica, e il resto della pagina è già raggiungibile.
*/
public function test_cinque_tentativi_sbagliati_bloccano_l_operazione(): void
{
$this->operazioneInAttesa();
for ($tentativo = 1; $tentativo <= 5; $tentativo++) {
Livewire::test(ZonaPericolosa::class)->call('confermaAzzeramento', 'sbagliata');
}
// Il sesto tentativo è rifiutato anche se la password è quella giusta.
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', self::PASSWORD)
->assertHasErrors('passwordConferma');
Queue::assertNotPushed(ResetApplication::class);
}
public function test_annullare_libera_il_posto_e_lascia_il_backup_scaricabile(): void
{
$this->operazioneInAttesa();
Livewire::test(ZonaPericolosa::class)->call('annullaAzzeramento');
$stato = app(BackupOperationState::class);
$this->assertNull($stato->corrente(), 'Annullando, il posto deve tornare libero.');
Queue::assertNotPushed(ResetApplication::class);
// Da qui in poi è un backup come un altro: l'archivio resta valido, e la
// zona di backup deve poterlo offrire in download.
$finale = $stato->get('op-reset');
$this->assertSame(BackupOperationState::TIPO_BACKUP, $finale['tipo']);
$this->assertSame('completed', $finale['status']);
}
// ─── Console e proprietà dell'operazione ────────────────────
/**
* Ogni operazione ha un solo componente che la racconta. Senza, backup e
* zona pericolosa spedirebbero entrambi le stesse righe e la console le
* mostrerebbe due volte.
*/
public function test_la_zona_pericolosa_racconta_solo_i_propri_azzeramenti(): void
{
$stato = app(BackupOperationState::class);
$stato->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 4);
$stato->log('op-backup', 'Riga di un backup, non di un azzeramento.');
Livewire::test(ZonaPericolosa::class)->assertNotDispatched('console-operazione');
Livewire::test(BackupManager::class)->assertDispatched('console-operazione');
}
/**
* Il difetto che questo test presidia: chi non possiede l'operazione polla
* comunque, e consumerebbe per primo il segno "download già inviato" —
* spegnendo l'evento prima che il proprietario lo emetta. L'archivio
* sarebbe pronto e non arriverebbe a nessuno, e per l'azzeramento è pure il
* passo che sblocca la cancellazione.
*/
public function test_il_download_lo_emette_solo_chi_possiede_l_operazione(): void
{
$this->operazioneInAttesa(servito: false, downloadInviato: false);
Livewire::test(BackupManager::class)->assertNotDispatched('scarica-backup');
$this->assertFalse(
app(BackupOperationState::class)->get('op-reset')['result']['download_inviato'] ?? false,
'Il componente che non possiede l\'operazione non deve consumare il segno del download.'
);
Livewire::test(ZonaPericolosa::class)->assertDispatched('scarica-backup');
}
public function test_il_download_si_emette_una_volta_sola(): void
{
$this->operazioneInAttesa(servito: false, downloadInviato: false);
Livewire::test(ZonaPericolosa::class)->assertDispatched('scarica-backup');
Livewire::test(ZonaPericolosa::class)->assertNotDispatched('scarica-backup');
}
// ─── helper ─────────────────────────────────────────────────
/**
* Azzeramento fermo al secondo tempo: l'archivio esiste e, salvo diversa
* indicazione, risulta spedito.
*/
private function operazioneInAttesa(bool $servito = true, bool $downloadInviato = true): void
{
$stato = app(BackupOperationState::class);
$stato->initialize('op-reset', BackupOperationState::TIPO_RESET, 'Compressione', 3);
$stato->markAttesaDownload('op-reset', [
'file' => '/tmp/backup.zip',
'nome_download' => 'backup.zip',
'dimensione' => 1024,
'servito_at' => $servito ? now()->toDateTimeString() : null,
'download_inviato' => $downloadInviato,
]);
}
}