++ fix e controlli
This commit is contained in:
149
tests/Feature/HomeMediaDurataTest.php
Normal file
149
tests/Feature/HomeMediaDurataTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
195
tests/Feature/PaginaBackupImportTest.php
Normal file
195
tests/Feature/PaginaBackupImportTest.php
Normal 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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
169
tests/Feature/PagineApplicativeTest.php
Normal file
169
tests/Feature/PagineApplicativeTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
333
tests/Feature/RegistroS13Test.php
Normal file
333
tests/Feature/RegistroS13Test.php
Normal 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);
|
||||
}
|
||||
}
|
||||
264
tests/Feature/ZonaPericolosaTest.php
Normal file
264
tests/Feature/ZonaPericolosaTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
tests/TestCase.php
Normal file
24
tests/TestCase.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
/**
|
||||
* L'applicazione si costruisce dallo stesso bootstrap/app.php che usa il
|
||||
* runtime: middleware, provider e rotte sono quelli veri, cosi' un test non
|
||||
* puo' passare grazie a una configurazione che esiste solo nei test.
|
||||
*/
|
||||
public function createApplication(): Application
|
||||
{
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
423
tests/Unit/BackupArchiveTest.php
Normal file
423
tests/Unit/BackupArchiveTest.php
Normal file
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\BackupArchive;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Mockery;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Il pacchetto di backup e' l'unico file di cui non ci si accorge che era rotto
|
||||
* finche' non serve: se sbaglia, sbaglia in silenzio e lo si scopre il giorno
|
||||
* del ripristino. Da qui la scelta di cominciare i test da qui.
|
||||
*
|
||||
* Non serve database: tutto gira su Storage::fake('public').
|
||||
*/
|
||||
class BackupArchiveTest extends TestCase
|
||||
{
|
||||
private BackupArchive $archivio;
|
||||
|
||||
/** Cartella dei file .zip prodotti dai test, ripulita a ogni giro. */
|
||||
private string $lavoro;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
|
||||
$this->archivio = new BackupArchive();
|
||||
$this->lavoro = storage_path('app/testing-backup');
|
||||
|
||||
$this->archivio->deleteDirectory($this->lavoro);
|
||||
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
|
||||
|
||||
mkdir($this->lavoro, 0775, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// extract() estrae in storage/app/backup-restore/<uuid>: e' un percorso
|
||||
// reale, non il disco finto, quindi va ripulito a mano.
|
||||
$this->archivio->deleteDirectory($this->lavoro);
|
||||
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ─── create() ───────────────────────────────────────────────
|
||||
|
||||
public function test_il_pacchetto_creato_si_riestrae_identico(): void
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/1.pdf', 'contenuto-uno');
|
||||
Storage::disk('public')->put('territori-thumbnails/1.png', 'miniatura-uno');
|
||||
Storage::disk('public')->put('logo.svg', 'logo');
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
|
||||
$this->assertFileExists($destinazione);
|
||||
|
||||
$esito = $this->archivio->extract($destinazione);
|
||||
|
||||
$this->assertSame('<dataset/>', $esito['xml']);
|
||||
$this->assertSame(3, $esito['files']);
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/territori-pdf/1.pdf', 'contenuto-uno');
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/territori-thumbnails/1.png', 'miniatura-uno');
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/logo.svg', 'logo');
|
||||
}
|
||||
|
||||
public function test_il_manifest_dichiara_versione_di_formato_e_conteggio(): void
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/1.pdf', 'x');
|
||||
Storage::disk('public')->put('territori-pdf/2.pdf', 'y');
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$this->assertTrue($zip->open($destinazione) === true);
|
||||
$manifest = json_decode($zip->getFromName('manifest.json'), true);
|
||||
$zip->close();
|
||||
|
||||
$this->assertSame(BackupArchive::FORMAT_VERSION, $manifest['format_version']);
|
||||
$this->assertSame('TerManager2', $manifest['application']);
|
||||
$this->assertSame(2, $manifest['files_count']);
|
||||
}
|
||||
|
||||
public function test_il_pacchetto_di_un_installazione_senza_file_contiene_comunque_i_dati(): void
|
||||
{
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
|
||||
$esito = $this->archivio->extract($destinazione);
|
||||
|
||||
$this->assertSame('<dataset/>', $esito['xml']);
|
||||
$this->assertSame(0, $esito['files']);
|
||||
}
|
||||
|
||||
/**
|
||||
* La barra conta unita' di lavoro finite, non tempo: l'ultimo avanzamento
|
||||
* deve coincidere col totale, altrimenti resterebbe sotto il 100% a
|
||||
* operazione conclusa.
|
||||
*/
|
||||
public function test_l_avanzamento_arriva_al_totale(): void
|
||||
{
|
||||
for ($i = 1; $i <= 25; $i++) {
|
||||
Storage::disk('public')->put("territori-pdf/{$i}.pdf", "file-{$i}");
|
||||
}
|
||||
|
||||
$avanzamenti = [];
|
||||
$this->archivio->create(
|
||||
'<dataset/>',
|
||||
$this->lavoro . '/backup.zip',
|
||||
function (int $fatti, int $totale) use (&$avanzamenti) {
|
||||
$avanzamenti[] = [$fatti, $totale];
|
||||
}
|
||||
);
|
||||
|
||||
$this->assertNotEmpty($avanzamenti);
|
||||
$this->assertSame([25, 25], end($avanzamenti));
|
||||
|
||||
$precedente = 0;
|
||||
foreach ($avanzamenti as [$fatti, $totale]) {
|
||||
$this->assertSame(25, $totale);
|
||||
$this->assertGreaterThanOrEqual($precedente, $fatti);
|
||||
$precedente = $fatti;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Il bug che questo test presidia: addFile() torna false se il file e'
|
||||
* sparito o e' illeggibile, e l'archivio finirebbe con un buco di cui
|
||||
* nessuno si accorge fino al ripristino. Meglio nessun backup che un backup
|
||||
* che sembra buono, quindi il file va anche rimosso.
|
||||
*
|
||||
* Il messaggio si verifica per esteso perche' e' la parte che si perde per
|
||||
* prima: addFile() emette un warning di PHP prima di restituire false, e
|
||||
* senza la soppressione in BackupArchive l'amministratore leggerebbe
|
||||
* "ZipArchive::addFile(): No such file or directory" al posto della frase
|
||||
* che dice quale file e cosa controllare.
|
||||
*/
|
||||
public function test_un_file_illeggibile_fa_fallire_il_backup_senza_lasciare_l_archivio(): void
|
||||
{
|
||||
$disco = Mockery::mock(FilesystemAdapter::class);
|
||||
$disco->shouldReceive('allFiles')->andReturn(['territori-pdf/sparito.pdf']);
|
||||
$disco->shouldReceive('path')->andReturn($this->lavoro . '/questo-file-non-esiste.pdf');
|
||||
|
||||
Storage::set('public', $disco);
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
|
||||
try {
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->fail('create() avrebbe dovuto sollevare su un file non leggibile.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertStringContainsString('territori-pdf/sparito.pdf', $e->getMessage());
|
||||
$this->assertStringContainsString('non e\' leggibile', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->assertFileDoesNotExist(
|
||||
$destinazione,
|
||||
'Un backup incompleto non deve restare sul disco: sembrerebbe utilizzabile.'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── extract(): rifiuti ─────────────────────────────────────
|
||||
|
||||
public function test_l_archivio_senza_manifest_non_e_riconosciuto(): void
|
||||
{
|
||||
$zip = $this->zipCon(['data.xml' => '<dataset/>']);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/manifest\.json/');
|
||||
|
||||
$this->archivio->extract($zip);
|
||||
}
|
||||
|
||||
public function test_l_archivio_senza_data_xml_e_incompleto(): void
|
||||
{
|
||||
$zip = $this->zipCon(['manifest.json' => $this->manifest()]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/data\.xml/');
|
||||
|
||||
$this->archivio->extract($zip);
|
||||
}
|
||||
|
||||
public function test_un_data_xml_vuoto_vale_come_mancante(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(),
|
||||
'data.xml' => " \n ",
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/data\.xml/');
|
||||
|
||||
$this->archivio->extract($zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un pacchetto prodotto da una versione futura si rifiuta invece di
|
||||
* ripristinarlo a meta': i campi che non si sanno leggere andrebbero persi
|
||||
* in silenzio.
|
||||
*/
|
||||
public function test_un_formato_piu_recente_viene_rifiutato(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(BackupArchive::FORMAT_VERSION + 1),
|
||||
'data.xml' => '<dataset/>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/versione piu\'? recente/');
|
||||
|
||||
$this->archivio->extract($zip);
|
||||
}
|
||||
|
||||
public function test_un_manifest_illeggibile_viene_rifiutato(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => 'non-e-json',
|
||||
'data.xml' => '<dataset/>',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/manifest/');
|
||||
|
||||
$this->archivio->extract($zip);
|
||||
}
|
||||
|
||||
public function test_un_entry_con_percorso_assoluto_viene_rifiutata(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(),
|
||||
'data.xml' => '<dataset/>',
|
||||
'storage//tmp/malevolo.txt' => 'fuori',
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/non ammesso/');
|
||||
|
||||
$this->archivio->extract($zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lo staging va rimosso anche quando l'estrazione fallisce a meta': i file
|
||||
* gia' estratti resterebbero altrimenti sul volume, senza che nulla li
|
||||
* mostri e senza che nulla li cancelli.
|
||||
*/
|
||||
public function test_lo_staging_non_sopravvive_a_un_estrazione_fallita(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(),
|
||||
'data.xml' => '<dataset/>',
|
||||
'storage/territori-pdf/buono.pdf' => 'ok',
|
||||
'storage//tmp/malevolo.txt' => 'fuori',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->archivio->extract($zip);
|
||||
$this->fail('extract() avrebbe dovuto sollevare sul percorso non ammesso.');
|
||||
} catch (RuntimeException) {
|
||||
// atteso
|
||||
}
|
||||
|
||||
$radice = storage_path('app/backup-restore');
|
||||
|
||||
$this->assertTrue(
|
||||
! is_dir($radice) || glob($radice . '/*') === [],
|
||||
'La cartella di staging e\' rimasta popolata dopo un ripristino fallito.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_le_entry_fuori_da_storage_vengono_ignorate(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(),
|
||||
'data.xml' => '<dataset/>',
|
||||
'storage/territori-pdf/1.pdf' => 'buono',
|
||||
'appunti/README.txt' => 'roba estranea al pacchetto',
|
||||
]);
|
||||
|
||||
$esito = $this->archivio->extract($zip);
|
||||
|
||||
$this->assertSame(1, $esito['files']);
|
||||
$this->assertFileExists($esito['staging'] . '/territori-pdf/1.pdf');
|
||||
$this->assertFileDoesNotExist($esito['staging'] . '/README.txt');
|
||||
$this->assertDirectoryDoesNotExist($esito['staging'] . '/appunti');
|
||||
}
|
||||
|
||||
// ─── safeRelativePath(): la regola dello zip slip ───────────
|
||||
|
||||
#[DataProvider('percorsiNonAmmessi')]
|
||||
public function test_i_percorsi_che_escono_dalla_destinazione_sono_rifiutati(string $percorso): void
|
||||
{
|
||||
$this->assertNull($this->safeRelativePath($percorso));
|
||||
}
|
||||
|
||||
public static function percorsiNonAmmessi(): array
|
||||
{
|
||||
return [
|
||||
'risalita semplice' => ['../fuori.txt'],
|
||||
'risalita in mezzo' => ['territori-pdf/../../fuori.txt'],
|
||||
'risalita con backslash' => ['..\\fuori.txt'],
|
||||
'percorso assoluto' => ['/etc/passwd'],
|
||||
'unita windows' => ['C:/Windows/system.ini'],
|
||||
'vuoto' => [''],
|
||||
'solo spazi' => [' '],
|
||||
'solo separatori' => ['///'],
|
||||
'solo punto' => ['.'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('percorsiAmmessi')]
|
||||
public function test_i_percorsi_interni_vengono_normalizzati(string $percorso, string $atteso): void
|
||||
{
|
||||
$this->assertSame($atteso, $this->safeRelativePath($percorso));
|
||||
}
|
||||
|
||||
public static function percorsiAmmessi(): array
|
||||
{
|
||||
return [
|
||||
'semplice' => ['logo.svg', 'logo.svg'],
|
||||
'annidato' => ['territori-pdf/1.pdf', 'territori-pdf/1.pdf'],
|
||||
'backslash convertiti' => ['territori-pdf\\1.pdf', 'territori-pdf/1.pdf'],
|
||||
'separatori doppi' => ['territori-pdf//1.pdf', 'territori-pdf/1.pdf'],
|
||||
'punto corrente' => ['./territori-pdf/./1.pdf', 'territori-pdf/1.pdf'],
|
||||
];
|
||||
}
|
||||
|
||||
// ─── restoreFiles() / svuotaDiscoPubblico() ─────────────────
|
||||
|
||||
/**
|
||||
* La sostituzione del disco e' totale, non un merge: i percorsi vivono
|
||||
* nelle righe che il ripristino ha appena riscritto, quindi un file
|
||||
* precedente non piu' citato resterebbe orfano per sempre.
|
||||
*/
|
||||
public function test_il_ripristino_sostituisce_il_disco_invece_di_fondersi(): void
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'da buttare');
|
||||
Storage::disk('public')->put('logo.svg', 'logo vecchio');
|
||||
|
||||
$staging = $this->lavoro . '/staging';
|
||||
mkdir($staging . '/territori-pdf', 0775, true);
|
||||
file_put_contents($staging . '/territori-pdf/nuovo.pdf', 'da tenere');
|
||||
file_put_contents($staging . '/logo.svg', 'logo nuovo');
|
||||
|
||||
$ripristinati = $this->archivio->restoreFiles($staging);
|
||||
|
||||
$this->assertSame(2, $ripristinati);
|
||||
$this->assertSame(
|
||||
['logo.svg', 'territori-pdf/nuovo.pdf'],
|
||||
collect(Storage::disk('public')->allFiles())->sort()->values()->all()
|
||||
);
|
||||
$this->assertSame('logo nuovo', Storage::disk('public')->get('logo.svg'));
|
||||
}
|
||||
|
||||
public function test_lo_svuotamento_conta_i_file_rimossi_e_lascia_il_disco_vuoto(): void
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/1.pdf', 'a');
|
||||
Storage::disk('public')->put('territori-pdf/2.pdf', 'b');
|
||||
Storage::disk('public')->put('logo.svg', 'c');
|
||||
|
||||
$rimossi = $this->archivio->svuotaDiscoPubblico();
|
||||
|
||||
$this->assertSame(3, $rimossi);
|
||||
$this->assertSame([], Storage::disk('public')->allFiles());
|
||||
}
|
||||
|
||||
public function test_lo_svuotamento_di_un_disco_gia_vuoto_non_e_un_errore(): void
|
||||
{
|
||||
$this->assertSame(0, $this->archivio->svuotaDiscoPubblico());
|
||||
}
|
||||
|
||||
// ─── helper ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function zipCon(array $entries): string
|
||||
{
|
||||
$path = $this->lavoro . '/costruito-' . count($entries) . '-' . md5(serialize($entries)) . '.zip';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$this->assertTrue(
|
||||
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true,
|
||||
'Impossibile preparare lo zip di prova.'
|
||||
);
|
||||
|
||||
foreach ($entries as $nome => $contenuto) {
|
||||
$zip->addFromString($nome, $contenuto);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function manifest(?int $formatVersion = null): string
|
||||
{
|
||||
return json_encode([
|
||||
'format_version' => $formatVersion ?? BackupArchive::FORMAT_VERSION,
|
||||
'application' => 'TerManager2',
|
||||
'generated_at' => '2026-07-30T12:00:00+02:00',
|
||||
'app_url' => 'http://localhost',
|
||||
'files_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function safeRelativePath(string $percorso): ?string
|
||||
{
|
||||
$metodo = new ReflectionMethod(BackupArchive::class, 'safeRelativePath');
|
||||
|
||||
return $metodo->invoke($this->archivio, $percorso);
|
||||
}
|
||||
}
|
||||
202
tests/Unit/BackupOperationStateTest.php
Normal file
202
tests/Unit/BackupOperationStateTest.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\BackupOperationState;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Il "posto unico": backup, ripristino e azzeramento lavorano tutti sullo
|
||||
* stesso database e sullo stesso disco, quindi due in parallelo non si
|
||||
* rallentano soltanto, si sovrascrivono. Qui si verifica che il posto venga
|
||||
* occupato, rilasciato e — soprattutto — che non resti occupato per sempre.
|
||||
*
|
||||
* Gira su cache 'array' (phpunit.xml): nessun Redis in mezzo.
|
||||
*/
|
||||
class BackupOperationStateTest extends TestCase
|
||||
{
|
||||
private const CHIAVE_CORRENTE = 'backup-op:corrente';
|
||||
|
||||
private BackupOperationState $stato;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->stato = new BackupOperationState();
|
||||
}
|
||||
|
||||
// ─── Il posto unico ─────────────────────────────────────────
|
||||
|
||||
public function test_senza_operazioni_il_posto_e_libero(): void
|
||||
{
|
||||
$this->assertNull($this->stato->corrente());
|
||||
}
|
||||
|
||||
public function test_avviare_un_operazione_occupa_il_posto(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 10);
|
||||
|
||||
$this->assertSame('op-1', $this->stato->corrente());
|
||||
}
|
||||
|
||||
public function test_il_completamento_libera_il_posto(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 10);
|
||||
$this->stato->markCompleted('op-1', ['file' => 'backup.zip']);
|
||||
|
||||
$this->assertNull($this->stato->corrente());
|
||||
$this->assertSame('completed', $this->stato->get('op-1')['status']);
|
||||
}
|
||||
|
||||
public function test_il_fallimento_libera_il_posto(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESTORE, 'Estrazione', 10);
|
||||
$this->stato->markFailed('op-1', 'disco pieno');
|
||||
|
||||
$this->assertNull($this->stato->corrente());
|
||||
|
||||
$finale = $this->stato->get('op-1');
|
||||
$this->assertSame('failed', $finale['status']);
|
||||
$this->assertSame('disco pieno', $finale['error']);
|
||||
$this->assertStringContainsString('ERRORE: disco pieno', end($finale['logs']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Il caso che il TTL da solo non copre: lo stato dell'operazione e' scaduto
|
||||
* ma il puntatore no. Senza questa pulizia il posto resterebbe occupato da
|
||||
* un'operazione che non esiste piu'.
|
||||
*/
|
||||
public function test_un_puntatore_orfano_non_tiene_occupato_il_posto(): void
|
||||
{
|
||||
Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600);
|
||||
|
||||
$this->assertNull($this->stato->corrente());
|
||||
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE), 'Il puntatore orfano va anche rimosso.');
|
||||
}
|
||||
|
||||
public function test_un_puntatore_a_un_operazione_gia_chiusa_non_tiene_occupato_il_posto(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 5);
|
||||
|
||||
// Chiusura "a mano", come se il worker fosse morto dopo aver scritto lo
|
||||
// stato ma prima di rilasciare il posto.
|
||||
$this->stato->update('op-1', function (array $s) {
|
||||
$s['status'] = 'completed';
|
||||
|
||||
return $s;
|
||||
});
|
||||
Cache::put(self::CHIAVE_CORRENTE, 'op-1', 3600);
|
||||
|
||||
$this->assertNull($this->stato->corrente());
|
||||
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Senza il confronto sull'id, la chiusura tardiva di un'operazione vecchia
|
||||
* sbloccherebbe quella appena avviata da qualcun altro — cioe' proprio il
|
||||
* parallelismo che il posto unico esiste per impedire.
|
||||
*/
|
||||
public function test_un_operazione_vecchia_che_si_chiude_non_libera_il_posto_della_nuova(): void
|
||||
{
|
||||
$this->stato->initialize('op-vecchia', BackupOperationState::TIPO_BACKUP, 'Preparazione', 3);
|
||||
$this->stato->initialize('op-nuova', BackupOperationState::TIPO_RESTORE, 'Estrazione', 3);
|
||||
|
||||
$this->stato->markCompleted('op-vecchia');
|
||||
|
||||
$this->assertSame('op-nuova', $this->stato->corrente());
|
||||
}
|
||||
|
||||
/**
|
||||
* L'azzeramento si ferma in attesa del download e riparte da un secondo
|
||||
* clic: in quella pausa il posto deve restare occupato, altrimenti un'altra
|
||||
* operazione potrebbe infilarsi fra il backup preventivo e la cancellazione.
|
||||
*/
|
||||
public function test_l_attesa_del_download_tiene_il_posto_occupato(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 4);
|
||||
$this->stato->markAttesaDownload('op-1', ['file' => 'backup.zip']);
|
||||
|
||||
$this->assertSame('op-1', $this->stato->corrente());
|
||||
|
||||
$corrente = $this->stato->get('op-1');
|
||||
$this->assertSame(BackupOperationState::FASE_ATTESA_DOWNLOAD, $corrente['fase']);
|
||||
$this->assertSame('running', $corrente['status']);
|
||||
$this->assertSame($corrente['total'], $corrente['processed'], 'La barra deve risultare piena.');
|
||||
}
|
||||
|
||||
// ─── Avanzamento ────────────────────────────────────────────
|
||||
|
||||
public function test_il_cambio_di_fase_riazzera_l_avanzamento_sul_nuovo_totale(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESTORE, 'Estrazione', 100);
|
||||
$this->stato->avanza('op-1', 80);
|
||||
|
||||
$this->stato->fase('op-1', 'Scrittura file', 7);
|
||||
|
||||
$corrente = $this->stato->get('op-1');
|
||||
$this->assertSame('Scrittura file', $corrente['fase']);
|
||||
$this->assertSame(7, $corrente['total']);
|
||||
$this->assertSame(0, $corrente['processed']);
|
||||
}
|
||||
|
||||
public function test_il_completamento_porta_la_barra_al_totale(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Compressione', 12);
|
||||
$this->stato->avanza('op-1', 9);
|
||||
|
||||
$this->stato->markCompleted('op-1');
|
||||
|
||||
$this->assertSame(12, $this->stato->get('op-1')['processed']);
|
||||
}
|
||||
|
||||
public function test_il_risultato_del_completamento_non_cancella_quello_gia_registrato(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 1);
|
||||
$this->stato->markAttesaDownload('op-1', ['file' => 'backup.zip']);
|
||||
|
||||
$this->stato->markCompleted('op-1', ['tabelle' => 9]);
|
||||
|
||||
$risultato = $this->stato->get('op-1')['result'];
|
||||
$this->assertSame('backup.zip', $risultato['file']);
|
||||
$this->assertSame(9, $risultato['tabelle']);
|
||||
}
|
||||
|
||||
// ─── Log ────────────────────────────────────────────────────
|
||||
|
||||
public function test_la_coda_dei_log_tiene_solo_le_ultime_righe(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 0);
|
||||
|
||||
for ($i = 1; $i <= 75; $i++) {
|
||||
$this->stato->log('op-1', "riga {$i}");
|
||||
}
|
||||
|
||||
$logs = $this->stato->get('op-1')['logs'];
|
||||
|
||||
// Lo stato viaggia per intero a ogni poll: una coda illimitata
|
||||
// gonfierebbe ogni risposta di un'operazione lunga.
|
||||
$this->assertCount(60, $logs);
|
||||
$this->assertStringContainsString('riga 75', end($logs));
|
||||
$this->assertStringContainsString('riga 16', $logs[0]);
|
||||
}
|
||||
|
||||
// ─── Bordi ──────────────────────────────────────────────────
|
||||
|
||||
public function test_aggiornare_un_operazione_inesistente_non_la_crea(): void
|
||||
{
|
||||
$this->assertNull($this->stato->update('mai-esistita', fn (array $s) => $s));
|
||||
$this->assertNull($this->stato->get('mai-esistita'));
|
||||
}
|
||||
|
||||
public function test_forget_rimuove_lo_stato(): void
|
||||
{
|
||||
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 1);
|
||||
|
||||
$this->stato->forget('op-1');
|
||||
|
||||
$this->assertNull($this->stato->get('op-1'));
|
||||
$this->assertNull($this->stato->corrente(), 'Senza stato il posto risulta libero.');
|
||||
}
|
||||
}
|
||||
163
tests/Unit/StileUnificatoTest.php
Normal file
163
tests/Unit/StileUnificatoTest.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Guardia contro il ritorno dei vocabolari di stile paralleli.
|
||||
*
|
||||
* L'applicazione aveva quattro modi di scrivere un pulsante: style= inline con
|
||||
* i colori a mano (piu' onmouseover per l'hover), catene di utility Tailwind
|
||||
* ripetute a memoria, .btn-action* in sei tinte e .btn-primary-* in due. La
|
||||
* stessa azione cambiava aspetto da una pagina all'altra e nessuno di quei
|
||||
* pulsanti aveva uno stato di focus: chi naviga da tastiera non vedeva dove si
|
||||
* trovava.
|
||||
*
|
||||
* Adesso c'e' <x-btn> e basta. Questo test non giudica l'estetica: verifica che
|
||||
* i modi vecchi non rientrino dalla finestra, cosa che succede da sola alla
|
||||
* prossima pagina scritta copiando una vecchia.
|
||||
*
|
||||
* Legge i sorgenti, quindi non serve ne' applicazione ne' database.
|
||||
*/
|
||||
class StileUnificatoTest extends TestCase
|
||||
{
|
||||
/** Viste dell'applicazione: quelle pubblicate da pacchetti terzi non ci riguardano. */
|
||||
private const ESCLUSI = [
|
||||
'resources/views/vendor/',
|
||||
// I template dei PDF non sono pagine: li impagina dompdf, che di
|
||||
// Tailwind e delle nostre classi non sa niente.
|
||||
'resources/views/pdf/',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<int, string> percorsi relativi delle viste da controllare
|
||||
*/
|
||||
private static function viste(): array
|
||||
{
|
||||
$radice = dirname(__DIR__, 2);
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($radice . '/resources/views', \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
$viste = [];
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
/** @var \SplFileInfo $file */
|
||||
if (! $file->isFile() || ! str_ends_with($file->getFilename(), '.blade.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativo = str_replace('\\', '/', substr($file->getPathname(), strlen($radice) + 1));
|
||||
|
||||
foreach (self::ESCLUSI as $escluso) {
|
||||
if (str_starts_with($relativo, $escluso)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$viste[] = $relativo;
|
||||
}
|
||||
|
||||
sort($viste);
|
||||
|
||||
return $viste;
|
||||
}
|
||||
|
||||
public static function vistePerProvider(): array
|
||||
{
|
||||
return array_map(fn (string $vista) => [$vista], self::viste());
|
||||
}
|
||||
|
||||
private function contenuto(string $vista): string
|
||||
{
|
||||
return (string) file_get_contents(dirname(__DIR__, 2) . '/' . $vista);
|
||||
}
|
||||
|
||||
#[DataProvider('vistePerProvider')]
|
||||
public function test_nessuna_vista_usa_le_vecchie_classi_dei_pulsanti(string $vista): void
|
||||
{
|
||||
$html = $this->rimuoviCommenti($this->contenuto($vista));
|
||||
|
||||
$this->assertStringNotContainsString('btn-action', $html, "{$vista}: .btn-action non esiste piu', usa <x-btn>.");
|
||||
$this->assertStringNotContainsString('btn-primary-', $html, "{$vista}: .btn-primary-* non esiste piu', usa <x-btn>.");
|
||||
}
|
||||
|
||||
/**
|
||||
* onmouseover/onmouseout servivano a simulare :hover su pulsanti con lo
|
||||
* stile inline. Non funzionano da tastiera e non lasciano traccia nel
|
||||
* foglio di stile: se ricompaiono, e' ricomparso anche il pulsante scritto
|
||||
* a mano.
|
||||
*/
|
||||
#[DataProvider('vistePerProvider')]
|
||||
public function test_nessuna_vista_simula_l_hover_con_javascript_inline(string $vista): void
|
||||
{
|
||||
$html = $this->rimuoviCommenti($this->contenuto($vista));
|
||||
|
||||
$this->assertStringNotContainsString('onmouseover', $html, "{$vista}: l'hover si scrive nel CSS, non in un attributo.");
|
||||
$this->assertStringNotContainsString('onmouseout', $html, "{$vista}: l'hover si scrive nel CSS, non in un attributo.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Un <button> o un <a> con le utility di riempimento addosso e' un pulsante
|
||||
* scritto a mano: gli manca lo stato di focus e prima o poi diverge dagli
|
||||
* altri. Il controllo guarda solo il tag di apertura, quindi non inciampa
|
||||
* nelle celle di tabella o nei contenitori che usano le stesse utility.
|
||||
*/
|
||||
#[DataProvider('vistePerProvider')]
|
||||
public function test_nessun_pulsante_e_riscritto_a_mano(string $vista): void
|
||||
{
|
||||
$html = $this->rimuoviCommenti($this->contenuto($vista));
|
||||
|
||||
preg_match_all('/<(?:button|a)\s[^>]*>/i', $html, $trovati);
|
||||
|
||||
// Molte viste non hanno pulsanti: senza questa riga il test risulta
|
||||
// "rischioso" (nessuna asserzione) invece che superato.
|
||||
$this->assertIsArray($trovati[0]);
|
||||
|
||||
foreach ($trovati[0] as $tag) {
|
||||
$riempimento = preg_match('/\bbg-(?:indigo|red|green|emerald|amber|blue|gray)-\d{3}\b/', $tag) === 1;
|
||||
$sfondoInline = preg_match('/style="[^"]*background\s*:/i', $tag) === 1;
|
||||
|
||||
$this->assertFalse(
|
||||
$riempimento || $sfondoInline,
|
||||
"{$vista}: pulsante con lo sfondo scritto a mano, usa <x-btn>.\n" . trim($tag)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Il componente e' l'unico posto in cui le classi .tm-btn si scrivono: se
|
||||
* compaiono in una vista, qualcuno ha saltato <x-btn> e si e' portato
|
||||
* dietro solo l'aspetto, non le regole.
|
||||
*/
|
||||
#[DataProvider('vistePerProvider')]
|
||||
public function test_le_classi_del_sistema_non_si_scrivono_a_mano(string $vista): void
|
||||
{
|
||||
if ($vista === 'resources/views/components/btn.blade.php') {
|
||||
$this->assertTrue(true, 'E\' il componente stesso.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$html = $this->rimuoviCommenti($this->contenuto($vista));
|
||||
|
||||
$this->assertDoesNotMatchRegularExpression(
|
||||
'/class="[^"]*\btm-btn\b/',
|
||||
$html,
|
||||
"{$vista}: le classi tm-btn le mette <x-btn>, non la vista."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* I commenti Blade spiegano proprio le regole che questi test verificano, e
|
||||
* contengono quindi i nomi vietati. Toglierli evita di dover scrivere le
|
||||
* spiegazioni a mezze parole per non far fallire il controllo.
|
||||
*/
|
||||
private function rimuoviCommenti(string $html): string
|
||||
{
|
||||
return (string) preg_replace('/\{\{--.*?--\}\}/s', '', $html);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user