Files
termanager2/tests/Unit/BackupOperationStateTest.php
Francesco Picone e7148c7b32 Piantine pubblicate anche dopo un ripristino; posto unico senza gare
- Lo stato "Pubblicata" non dipende più dall'id del territorio, che il
  ripristino ricrea: l'impronta usa il numero, e la piantina ricorda il
  percorso del PDF pubblicato (pdf_pubblicato, con migrazione che riconosce
  le piantine già pubblicate).
- BackupOperationState: corrente() in sola lettura e liberaCorrente() sotto
  lo stesso mutex di occupa(), così una lettura non cancella il puntatore
  di un'operazione appena avviata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:33:07 +02:00

215 lines
8.5 KiB
PHP

<?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. Il posto deve risultare libero e potersi prendere;
* il puntatore lo sovrascrive l'operazione nuova (corrente() non lo
* cancella: girando fuori dal mutex poteva togliere quello di un'altra).
*/
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($this->stato->occupa('op-nuova', BackupOperationState::TIPO_BACKUP, 'In coda'));
$this->assertSame('op-nuova', $this->stato->corrente());
}
/** Un corrente() fuori dal mutex non deve togliere il puntatore appena scritto. */
public function test_corrente_non_cancella_il_puntatore(): void
{
Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600);
$this->stato->corrente();
$this->assertSame('op-svanita', Cache::get(self::CHIAVE_CORRENTE));
}
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($this->stato->occupa('op-2', BackupOperationState::TIPO_BACKUP, 'In coda'));
}
/**
* 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.');
}
}