S-13 sul modulo ufficiale e correzioni all'integrità dei dati

Modulo S-13
- Il PDF scrive sopra il modulo ufficiale (FPDI, matrice convertita in PDF 1.4)
  invece di ricostruirlo in HTML: 20 righe per foglio come l'originale.
- Una riga per ogni territorio attivo; riporto dell'ultimo rientro prima
  dell'anno; assegnazioni a cavallo d'anno su entrambi i fogli; scelta per
  data e non per anno registrato; nomi come "Cognome I.".
- Logica spostata in App\Services\Registro\ModuloS13.

Integrità dei dati
- Export/import XML (e quindi backup/ripristino): le date non slittano più di
  un giorno; i file vecchi in UTC tornano al giorno giusto.
- Import XML: rifiuta file estranei o incompleti senza toccare i dati, errore
  con riga e motivo, territori senza zona importati con il loro storico,
  caratteri di controllo rimossi in export, chi importa conserva password e
  ruolo di amministratore.
- Assegnazioni: assegnazioneCorrente corretta (filtro dentro ofMany), periodi
  sovrapposti rifiutati in Assegna e Registro sotto lock, anno di servizio
  ricavato dalla data, niente date future nel Registro.
- Utenti: non si declassa sé stessi né l'ultimo amministratore.
- Entrypoint: non sovrascrive più la copia persistente di APP_KEY con un
  valore diverso; serve APP_KEY_ROTATE=1 per un cambio voluto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:35:51 +02:00
parent 9c37c1ad7b
commit d433c9392d
17 changed files with 1361 additions and 457 deletions

View File

@@ -0,0 +1,211 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
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 Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/**
* Un territorio è fuori al massimo una volta: nessuna doppia assegnazione
* aperta, nessuna uscita retrodatata a prima dell'ultimo rientro, nessun
* periodo accavallato dalle correzioni nel Registro.
*/
class AssegnazioneSovrapposizioneTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Territorio $territorio;
private Proclamatore $proclamatore;
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow('2026-10-09 10:00:00');
foreach (['settings.manage', 'territori.assign', 'territori.return', 'registro.view'] as $permesso) {
Permission::create(['name' => $permesso, 'guard_name' => 'web']);
}
Role::create(['name' => 'amministratore', 'guard_name' => 'web'])->givePermissionTo(Permission::all());
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password']);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
$this->territorio = Territorio::create(['numero' => '5', 'attivo' => true]);
$this->proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
// ─── L'assegnazione in corso ────────────────────────────────
/**
* Il caso che rendeva il territorio "libero" mentre era fuori: una chiusa
* con data più recente dell'aperta.
*/
public function test_l_assegnazione_in_corso_si_trova_anche_con_una_chiusa_piu_recente(): void
{
$aperta = $this->assegna('2026-08-20');
$this->assegna('2026-09-01', '2026-09-15');
$this->assertTrue($this->territorio->fresh()->assegnazioneCorrente?->is($aperta));
}
// ─── Il controllo di sovrapposizione ────────────────────────
public function test_rientro_e_nuova_uscita_nello_stesso_giorno_non_si_sovrappongono(): void
{
$this->assegna('2026-09-01', '2026-09-20');
$this->assertNull(Assegnazione::sovrappostaA($this->territorio->id, Carbon::parse('2026-09-20')));
}
public function test_un_uscita_prima_dell_ultimo_rientro_si_sovrappone(): void
{
$chiusa = $this->assegna('2026-09-01', '2026-09-20');
$this->assertTrue(Assegnazione::sovrappostaA($this->territorio->id, Carbon::parse('2026-09-10'))?->is($chiusa));
}
public function test_con_un_assegnazione_aperta_ogni_nuova_uscita_si_sovrappone(): void
{
$aperta = $this->assegna('2026-09-01');
$this->assertTrue(Assegnazione::sovrappostaA($this->territorio->id, Carbon::parse('2026-10-01'))?->is($aperta));
}
public function test_un_periodo_chiuso_inserito_prima_di_tutti_non_si_sovrappone(): void
{
$this->assegna('2026-09-01');
$this->assertNull(Assegnazione::sovrappostaA($this->territorio->id, Carbon::parse('2026-06-01'), Carbon::parse('2026-07-01')));
}
// ─── Pagina Assegna ─────────────────────────────────────────
public function test_assegna_rifiuta_un_uscita_retrodatata_a_prima_dell_ultimo_rientro(): void
{
$this->assegna('2026-09-01', '2026-09-20');
Livewire::test(Assegna::class, ['territorioId' => $this->territorio->id])
->set('proclamatore_id', $this->proclamatore->id)
->set('assigned_at', '2026-09-10')
->call('save')
->assertHasErrors('assigned_at');
$this->assertSame(1, Assegnazione::count());
}
public function test_assegna_accetta_l_uscita_dal_giorno_del_rientro(): void
{
$this->assegna('2026-09-01', '2026-09-20');
Livewire::test(Assegna::class, ['territorioId' => $this->territorio->id])
->set('proclamatore_id', $this->proclamatore->id)
->set('assigned_at', '2026-09-20')
->call('save')
->assertHasNoErrors();
$this->assertSame(2, Assegnazione::count());
}
// ─── Registro (correzioni dell'amministratore) ──────────────
public function test_il_registro_ricava_l_anno_di_servizio_dalla_data(): void
{
Livewire::test(Registro::class)
->call('openCreate')
->set('form_territorio_id', (string) $this->territorio->id)
->set('form_proclamatore_id', (string) $this->proclamatore->id)
->set('form_assigned_at', '2025-08-10')
->set('form_returned_at', '2025-09-05')
->call('save')
->assertHasNoErrors();
$this->assertSame('2024-2025', Assegnazione::sole()->annoTeocratico->label);
}
public function test_il_registro_non_crea_una_seconda_assegnazione_aperta(): void
{
$this->assegna('2026-09-01');
Livewire::test(Registro::class)
->call('openCreate')
->set('form_territorio_id', (string) $this->territorio->id)
->set('form_proclamatore_id', (string) $this->proclamatore->id)
->set('form_assigned_at', '2026-10-01')
->call('save')
->assertHasErrors('form_assigned_at');
$this->assertSame(1, Assegnazione::count());
}
public function test_il_registro_non_riapre_un_assegnazione_chiusa_se_il_territorio_e_fuori(): void
{
$chiusa = $this->assegna('2026-08-01', '2026-08-20');
$this->assegna('2026-09-01');
Livewire::test(Registro::class)
->call('openEdit', $chiusa->id)
->set('form_returned_at', '')
->call('save')
->assertHasErrors('form_assigned_at');
$this->assertNotNull($chiusa->fresh()->returned_at);
}
public function test_il_registro_rifiuta_le_date_future(): void
{
Livewire::test(Registro::class)
->call('openCreate')
->set('form_territorio_id', (string) $this->territorio->id)
->set('form_proclamatore_id', (string) $this->proclamatore->id)
->set('form_assigned_at', '2026-11-01')
->call('save')
->assertHasErrors('form_assigned_at');
}
public function test_il_registro_permette_di_correggere_la_stessa_assegnazione(): void
{
$aperta = $this->assegna('2026-09-01');
Livewire::test(Registro::class)
->call('openEdit', $aperta->id)
->set('form_assigned_at', '2026-09-03')
->call('save')
->assertHasNoErrors();
$this->assertSame('2026-09-03', $aperta->fresh()->assigned_at->toDateString());
}
// ─── helper ─────────────────────────────────────────────────
private function assegna(string $uscita, ?string $rientro = null): Assegnazione
{
return Assegnazione::create([
'territorio_id' => $this->territorio->id,
'proclamatore_id' => $this->proclamatore->id,
'anno_teocratico_id' => AnnoTeocratico::perData(Carbon::parse($uscita))->id,
'assigned_at' => $uscita,
'returned_at' => $rientro,
'created_by' => $this->admin->id,
]);
}
}

View File

@@ -2,27 +2,23 @@
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 App\Services\Registro\ModuloS13;
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".
* Modulo S-13-I: il ciclo da quattro gruppi "Assegnato a", la colonna
* "Restituito l'ultima volta il" e le assegnazioni a cavallo d'anno.
*
* 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
{
@@ -68,7 +64,7 @@ class RegistroS13Test extends TestCase
$this->assertSame('', $riga['riporto']);
$this->assertCount(4, $riga['assegnazioni']);
$this->assertSame(
['nome' => 'Rossi Mario', 'assegnato_il' => '10/09/2025', 'restituito_il' => '15/10/2025'],
['nome' => 'Rossi M.', 'assegnato_il' => '10/09/2025', 'restituito_il' => '15/10/2025'],
$riga['assegnazioni'][0]
);
@@ -82,6 +78,19 @@ class RegistroS13Test extends TestCase
}
}
public function test_il_nome_e_cognome_piu_iniziale_del_nome(): void
{
$proclamatore = Proclamatore::create([
'nome' => 'élena Maria',
'cognome' => "Dell'Acqua Bianchi",
'attivo' => true,
]);
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-10')->update(['proclamatore_id' => $proclamatore->id]);
$this->assertSame("Dell'Acqua Bianchi É.", $this->righe()->sole()['assegnazioni'][0]['nome']);
}
public function test_un_assegnazione_ancora_aperta_ha_la_data_di_rientro_vuota(): void
{
$territorio = $this->territorio('1');
@@ -255,17 +264,11 @@ class RegistroS13Test extends TestCase
$this->assertSame(['1'], $this->righe()->pluck('numero')->all());
}
public function test_le_assegnazioni_di_un_altro_anno_di_servizio_non_compaiono(): void
public function test_le_assegnazioni_chiuse_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);
$this->assegna($territorio, '2025-01-10', '2025-02-10', $this->annoPrecedente());
$riga = $this->righe()->sole();
@@ -275,11 +278,168 @@ class RegistroS13Test extends TestCase
);
}
public function test_un_territorio_senza_assegnazioni_nell_anno_non_produce_una_riga(): void
/**
* Il registro serve a vedere anche i territori fermi: una riga vuota con
* la data dell'ultimo rientro dice da quanto non escono.
*/
public function test_un_territorio_senza_assegnazioni_nell_anno_ha_comunque_la_sua_riga(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-03-01', '2025-04-12', $this->annoPrecedente());
$riga = $this->righe()->sole();
$this->assertSame('1', $riga['numero']);
$this->assertSame('12/04/2025', $riga['riporto']);
$this->assertSame(['', '', '', ''], array_column($riga['assegnazioni'], 'nome'));
}
public function test_un_territorio_mai_assegnato_ha_la_riga_vuota(): void
{
$this->territorio('1');
$this->assertTrue($this->righe()->isEmpty());
$riga = $this->righe()->sole();
$this->assertSame('', $riga['riporto']);
$this->assertSame(['', '', '', ''], array_column($riga['assegnazioni'], 'assegnato_il'));
}
// ─── Il riporto a inizio anno ───────────────────────────────
/**
* La nota del modulo: "quando si inizia un nuovo foglio, registrare in
* questa colonna l'ultima data in cui e' stato restituito ogni territorio".
*/
public function test_nel_primo_ciclo_il_riporto_e_l_ultimo_rientro_prima_dell_anno(): void
{
$precedente = $this->annoPrecedente();
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-01-10', '2025-02-10', $precedente);
$this->assegna($territorio, '2025-05-02', '2025-06-30', $precedente);
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$riga = $this->righe()->sole();
$this->assertSame('30/06/2025', $riga['riporto']);
$this->assertSame('01/10/2025', $riga['assegnazioni'][0]['assegnato_il']);
}
// ─── A cavallo d'anno ───────────────────────────────────────
/**
* Uscito ad agosto, rientrato a ottobre: sul foglio nuovo occupa il primo
* gruppo con entrambe le date, e il riporto e' il rientro precedente.
*/
public function test_l_assegnazione_a_cavallo_apre_il_foglio_nuovo_con_entrambe_le_date(): void
{
$precedente = $this->annoPrecedente();
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-05-02', '2025-06-30', $precedente);
$this->assegna($territorio, '2025-08-10', '2025-10-05', $precedente);
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$riga = $this->righe()->sole();
$this->assertSame('30/06/2025', $riga['riporto']);
$this->assertSame(
['nome' => 'Rossi M.', 'assegnato_il' => '10/08/2025', 'restituito_il' => '05/10/2025'],
$riga['assegnazioni'][0]
);
$this->assertSame('01/11/2025', $riga['assegnazioni'][1]['assegnato_il']);
}
public function test_sul_foglio_vecchio_l_assegnazione_a_cavallo_resta_senza_rientro(): void
{
$precedente = $this->annoPrecedente();
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-08-10', '2025-10-05', $precedente);
$riga = $this->righe($precedente)->sole();
$this->assertSame('10/08/2025', $riga['assegnazioni'][0]['assegnato_il']);
$this->assertSame('', $riga['assegnazioni'][0]['restituito_il']);
}
public function test_un_assegnazione_ancora_fuori_da_un_anno_precedente_compare_sul_foglio_nuovo(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-07-01', null, $this->annoPrecedente());
$riga = $this->righe()->sole();
$this->assertSame('01/07/2025', $riga['assegnazioni'][0]['assegnato_il']);
$this->assertSame('', $riga['assegnazioni'][0]['restituito_il']);
}
/** L'assegnazione a cavallo conta nel ciclo: con altre quattro nell'anno, la riga ricomincia. */
public function test_l_assegnazione_a_cavallo_conta_nel_ciclo_da_quattro(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-08-10', '2025-09-15', $this->annoPrecedente());
$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');
$riga = $this->righe()->sole();
$this->assertSame('24/12/2025', $riga['riporto']);
$this->assertSame(['05/01/2026', '', '', ''], array_column($riga['assegnazioni'], 'assegnato_il'));
}
/**
* Il foglio sceglie per data, non per anno_teocratico_id: una data
* corretta a mano nel Registro o un import legacy possono lasciare l'anno
* registrato incoerente con il giorno di uscita.
*/
public function test_conta_la_data_di_uscita_non_l_anno_registrato(): void
{
$precedente = $this->annoPrecedente();
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-10-01', '2025-10-20', $precedente);
$this->assertSame('01/10/2025', $this->righe()->sole()['assegnazioni'][0]['assegnato_il']);
$this->assertSame('', $this->righe($precedente)->sole()['assegnazioni'][0]['assegnato_il']);
}
public function test_l_ultimo_giorno_dell_anno_appartiene_al_foglio(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2026-08-31', '2026-08-31');
$riga = $this->righe()->sole();
$this->assertSame('31/08/2026', $riga['assegnazioni'][0]['assegnato_il']);
$this->assertSame('31/08/2026', $riga['assegnazioni'][0]['restituito_il']);
}
// ─── Impaginazione e PDF ────────────────────────────────────
public function test_ogni_foglio_ha_venti_righe_come_il_modulo(): void
{
foreach (range(1, 21) as $numero) {
$this->territorio((string) $numero);
}
$pagine = (new ModuloS13())->pagine($this->righe());
$this->assertCount(2, $pagine);
$this->assertCount(20, $pagine[0]);
$this->assertCount(20, $pagine[1]);
$this->assertSame('21', $pagine[1][0]['numero']);
$this->assertSame('', $pagine[1][1]['numero']);
}
public function test_il_pdf_e_il_modulo_ufficiale_con_una_pagina_per_foglio(): void
{
foreach (range(1, 21) as $numero) {
$this->assegna($this->territorio((string) $numero), '2025-09-01');
}
$pdf = (new ModuloS13())->genera($this->anno);
$this->assertStringStartsWith('%PDF-', $pdf);
$this->assertSame(2, preg_match_all('#/Type /Page\b(?!s)#', $pdf));
}
/**
@@ -326,8 +486,15 @@ class RegistroS13Test extends TestCase
*/
private function righe(?AnnoTeocratico $anno = null): Collection
{
$metodo = new ReflectionMethod(Registro::class, 'righeS13');
return (new ModuloS13())->righe($anno ?? $this->anno);
}
return $metodo->invoke(new Registro(), $anno ?? $this->anno);
private function annoPrecedente(): AnnoTeocratico
{
return AnnoTeocratico::create([
'label' => '2024-2025',
'start_date' => '2024-09-01',
'end_date' => '2025-08-31',
]);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\UsersIndex;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/** L'installazione non deve mai restare senza un amministratore. */
class UltimoAmministratoreTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
Role::create(['name' => 'assistente', 'guard_name' => 'web']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password']);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
public function test_non_si_toglie_a_se_stessi_il_ruolo_di_amministratore(): void
{
// Anche con un secondo amministratore: il declassamento di sé stessi
// va chiesto a un altro.
User::create(['name' => 'Altro', 'email' => 'altro@example.test', 'password' => 'password'])
->assignRole('amministratore');
$this->modificaRuolo($this->admin, 'assistente')->assertHasErrors('editSelectedRole');
$this->assertTrue($this->admin->fresh()->hasRole('amministratore'));
}
public function test_non_si_declassa_l_ultimo_amministratore(): void
{
// Chi modifica non è l'amministratore stesso, così la regola su sé
// stessi non entra in gioco: si prova solo quella sull'ultimo. Il
// permesso della pagina sta nel middleware della rotta, che
// Livewire::test non attraversa.
$operatore = User::create(['name' => 'Operatore', 'email' => 'op@example.test', 'password' => 'password']);
$operatore->assignRole('assistente');
$this->actingAs($operatore);
$this->modificaRuolo($this->admin, 'assistente')->assertHasErrors('editSelectedRole');
$this->assertTrue($this->admin->fresh()->hasRole('amministratore'));
}
public function test_un_amministratore_ne_declassa_un_altro_se_non_e_l_ultimo(): void
{
$altro = User::create(['name' => 'Altro', 'email' => 'altro@example.test', 'password' => 'password']);
$altro->assignRole('amministratore');
$this->modificaRuolo($altro, 'assistente')->assertHasNoErrors();
$this->assertTrue($altro->fresh()->hasRole('assistente'));
$this->assertFalse($altro->fresh()->hasRole('amministratore'));
}
private function modificaRuolo(User $utente, string $ruolo)
{
return Livewire::test(UsersIndex::class)
->set('editingUserId', $utente->id)
->set('editName', $utente->name)
->set('editEmail', $utente->email)
->set('editSelectedRole', $ruolo)
->call('updateUser');
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\XmlExchange;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use App\Models\Zona;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/**
* Export e import XML: lo stesso percorso del data.xml dei backup, quindi
* ogni difetto qui è un difetto del ripristino.
*
* L'applicazione gira su Europe/Rome (default di config/app.php, nessun
* override in phpunit.xml): è la condizione in cui le date slittavano.
*/
class XmlExchangeImportTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
Role::create(['name' => 'assistente', 'guard_name' => 'web']);
$this->admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-attuale',
]);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
// ─── Round-trip ─────────────────────────────────────────────
/**
* Il difetto che spostava ogni data indietro di un giorno a ogni giro di
* backup e ripristino: toArray() le serializzava in UTC.
*/
public function test_export_e_reimport_conservano_le_date_esatte(): void
{
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
Campagna::create(['descrizione' => 'Commemorazione', 'start_date' => '2026-03-01', 'end_date' => '2026-03-31']);
$territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
$proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $proclamatore->id,
'anno_teocratico_id' => $anno->id,
'assigned_at' => '2026-03-15',
'returned_at' => '2026-04-01',
'created_by' => $this->admin->id,
]);
$this->reimporta();
$assegnazione = Assegnazione::sole();
$this->assertSame('2026-03-15', $assegnazione->assigned_at->toDateString());
$this->assertSame('2026-04-01', $assegnazione->returned_at->toDateString());
$anno = AnnoTeocratico::sole();
$this->assertSame('2025-09-01', $anno->start_date->toDateString());
$this->assertSame('2026-08-31', $anno->end_date->toDateString());
$campagna = Campagna::sole();
$this->assertSame('2026-03-01', $campagna->start_date->toDateString());
$this->assertSame('2026-03-31', $campagna->end_date->toDateString());
}
/** I backup fatti prima della correzione hanno le date in UTC: tornano giuste. */
public function test_un_backup_vecchio_con_date_in_utc_viene_riportato_al_giorno_giusto(): void
{
$xml = $this->xmlMinimo(
anni: '<anno legacy_id="1"><label>2025-2026</label><start_date>2025-08-31T22:00:00.000000Z</start_date><end_date>2026-08-30T22:00:00.000000Z</end_date></anno>',
assegnazioni: '<assegnazione legacy_id="1"><legacy_territorio_id>1</legacy_territorio_id><legacy_proclamatore_id>1</legacy_proclamatore_id><legacy_anno_id>1</legacy_anno_id><assigned_at>2026-03-14T23:00:00.000000Z</assigned_at><returned_at></returned_at></assegnazione>',
);
$this->assertArrayNotHasKey('error', $this->importa($xml));
$this->assertSame('2026-03-15', Assegnazione::sole()->assigned_at->toDateString());
$this->assertSame('2025-09-01', AnnoTeocratico::sole()->start_date->toDateString());
$this->assertSame('2026-08-31', AnnoTeocratico::sole()->end_date->toDateString());
}
/** Un carattere di controllo in una nota rendeva l'intero file illeggibile. */
public function test_un_carattere_di_controllo_in_una_nota_non_rompe_il_file(): void
{
Territorio::create(['numero' => '1', 'attivo' => true, 'note' => "Citofono\x0Brotto\x01"]);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertNotFalse(simplexml_load_string($xml));
$this->reimporta($xml);
$this->assertSame('Citofonorotto', Territorio::sole()->note);
}
/**
* Un territorio resta senza zona quando la sua zona viene eliminata: al
* reimport veniva scartato insieme a tutte le sue assegnazioni.
*/
public function test_un_territorio_senza_zona_viene_importato_con_il_suo_storico(): void
{
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
$territorio = Territorio::create(['numero' => '3', 'attivo' => true, 'zona_id' => null]);
$proclamatore = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Bianchi', 'attivo' => true]);
Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $proclamatore->id,
'anno_teocratico_id' => $anno->id,
'assigned_at' => '2025-10-01',
'created_by' => $this->admin->id,
]);
$this->reimporta();
$this->assertNull(Territorio::sole()->zona_id);
$this->assertSame(1, Assegnazione::count());
}
// ─── File da rifiutare ──────────────────────────────────────
public function test_un_xml_estraneo_viene_rifiutato_senza_toccare_i_dati(): void
{
Territorio::create(['numero' => '1', 'attivo' => true]);
$esito = $this->importa('<?xml version="1.0"?><urlset><url><loc>https://example.org</loc></url></urlset>');
$this->assertStringContainsString('non è un export di TerManager2', $esito['error']);
$this->assertSame(1, Territorio::count());
}
public function test_un_export_senza_le_sezioni_principali_viene_rifiutato(): void
{
Territorio::create(['numero' => '1', 'attivo' => true]);
$esito = $this->importa('<?xml version="1.0"?><termanager2><settings/></termanager2>');
$this->assertStringContainsString('mancano le sezioni', $esito['error']);
$this->assertSame(1, Territorio::count());
}
public function test_un_file_malformato_indica_riga_e_motivo(): void
{
$esito = $this->importa("<?xml version=\"1.0\"?>\n<termanager2>\n<territori>\n</termanager2>");
$this->assertStringContainsString('riga', $esito['error']);
}
// ─── Chi importa non resta fuori ────────────────────────────
/**
* Il file può contenere l'account di chi importa con una password vecchia
* e senza ruoli: l'amministratore che lo carica non deve perdere né l'una
* né gli altri.
*/
public function test_chi_importa_conserva_password_e_ruolo_di_amministratore(): void
{
$hashVecchio = Hash::make('password-di-un-anno-fa');
$xml = $this->xmlMinimo(
utenti: '<user legacy_id="9"><name>Amministratore</name><email>admin@example.test</email><password>' . $hashVecchio . '</password><roles></roles></user>'
. '<user legacy_id="10"><name>Altro</name><email>altro@example.test</email><password>' . $hashVecchio . '</password><roles><role>assistente</role></roles></user>',
);
$this->assertArrayNotHasKey('error', $this->importa($xml));
$admin = $this->admin->fresh();
$this->assertTrue(Hash::check('password-attuale', $admin->password));
$this->assertTrue($admin->hasRole('amministratore'));
// Gli altri account invece prendono i dati del file.
$altro = User::where('email', 'altro@example.test')->sole();
$this->assertTrue(Hash::check('password-di-un-anno-fa', $altro->password));
$this->assertTrue($altro->hasRole('assistente'));
}
// ─── helper ─────────────────────────────────────────────────
private function reimporta(?string $xml = null): void
{
$xml ??= (new XmlExchange())->currentXmlBackup();
$esito = $this->importa($xml);
$this->assertArrayNotHasKey('error', $esito, $esito['error'] ?? '');
$this->assertSame([], $esito['issues'], json_encode($esito['issues']));
}
private function importa(string $xml): array
{
return (new XmlExchange())->importXmlFromContent($xml);
}
private function xmlMinimo(string $utenti = '', string $anni = '', string $assegnazioni = ''): string
{
return '<?xml version="1.0" encoding="UTF-8"?><termanager2 version="1.0">'
. '<settings/><users>' . $utenti . '</users><zones/><tipologie/>'
. '<proclamatori><proclamatore legacy_id="1"><nome>Mario</nome><cognome>Rossi</cognome><attivo>1</attivo></proclamatore></proclamatori>'
. '<territori><territorio legacy_id="1"><numero>1</numero><legacy_zona_id></legacy_zona_id><legacy_tipologia_id></legacy_tipologia_id><attivo>1</attivo></territorio></territori>'
. '<anni_teocratici>' . $anni . '</anni_teocratici><campagne/>'
. '<assegnazioni>' . $assegnazioni . '</assegnazioni>'
. '</termanager2>';
}
}