Editor piantine: bozza automatica e pubblicazione con anteprima

- Il disegno è bozza e si salva da solo; i dati del territorio si
  modificano dall'editor e valgono subito nel gestionale.
- Un solo "Pubblica" (immagine + PDF), uguale in editor e gestione:
  anteprima preparata in coda su disco privato, conferma obbligatoria,
  rifiutata se nel frattempo è cambiato qualcosa di stampato.
- Stati Bozza / Pubblicata / Modifiche da pubblicare, calcolati
  sull'impronta di confine e dati stampati e sul PDF in uso.
- Gestione riscritta: stato, ricerca, filtri, storico paginato.
- Editor ridisegnato a tre zone, utilizzabile anche su tablet e telefono.
- Avvisi via evento (trait Avvisa + <x-avvisi>).
- .gitattributes: script shell sempre LF, altrimenti il container
  locale su Windows non parte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:08:34 +02:00
parent d433c9392d
commit 056d95cfcb
25 changed files with 3084 additions and 980 deletions

View File

@@ -0,0 +1,286 @@
<?php
namespace Tests\Feature;
use App\Livewire\Piantine\Pubblicazione;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use App\Models\Zona;
use App\Services\Piantine\PubblicazionePiantina;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use RuntimeException;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* Dalla bozza al PDF ufficiale: l'anteprima non tocca niente, la conferma
* pubblica esattamente quello che si è visto, o niente.
*/
class PubblicazionePiantinaTest extends TestCase
{
use RefreshDatabase;
private const GEOMETRIA = [
'type' => 'Polygon',
'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
];
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('public');
Storage::fake('local');
config(['piantine.formato' => 'jpeg']);
// Un JPEG vero: FPDF lo legge davvero quando compone il PDF.
$immagine = imagecreatetruecolor(80, 50);
imagefilledrectangle($immagine, 0, 0, 79, 49, imagecolorallocate($immagine, 230, 230, 230));
ob_start();
imagejpeg($immagine);
$jpeg = ob_get_clean();
imagedestroy($immagine);
$this->jpeg = $jpeg;
}
private string $jpeg;
private function rendererRisponde(): void
{
Http::fake(['*/render' => Http::response($this->jpeg)]);
}
public function test_l_anteprima_si_prepara_senza_toccare_territorio_ne_piantina(): void
{
[$territorio, $mappa] = $this->piantinaCollegata();
$servizio = app(PubblicazionePiantina::class);
// In test la coda è sincrona: il job ha già lavorato al ritorno.
$token = $servizio->avvia($mappa);
$this->assertSame(PubblicazionePiantina::PRONTA, $servizio->stato($token)['stato']);
$this->assertStringStartsWith('%PDF', file_get_contents($servizio->pdfAnteprima($token)));
$this->assertNull($territorio->fresh()->pdf_path);
$this->assertNull($mappa->fresh()->pubblicata_at);
$this->assertNull($mappa->fresh()->image_path);
$this->assertSame(TerritorioMappa::STATO_BOZZA, $mappa->fresh()->stato());
}
public function test_il_pdf_dell_anteprima_si_vede_solo_con_il_token_giusto(): void
{
[, $mappa] = $this->piantinaCollegata();
$token = app(PubblicazionePiantina::class)->avvia($mappa);
$this->actingAs($this->admin())
->get(route('piantine.anteprima', $token))
->assertOk()
->assertHeader('Content-Type', 'application/pdf');
$this->actingAs($this->admin())
->get(route('piantine.anteprima', str_repeat('a', 40)))
->assertNotFound();
}
public function test_la_conferma_sostituisce_il_pdf_e_archivia_il_precedente(): void
{
[$territorio, $mappa] = $this->piantinaCollegata();
Storage::disk('public')->put('territori-pdf/vecchio.pdf', '%PDF-vecchio');
$territorio->update(['pdf_path' => 'territori-pdf/vecchio.pdf']);
$servizio = app(PubblicazionePiantina::class);
$token = $servizio->avvia($mappa);
$this->actingAs($this->admin());
$servizio->conferma($token);
$territorio->refresh();
$mappa->refresh();
$this->assertStringStartsWith('territori-pdf/piantina-' . $territorio->id . '-', $territorio->pdf_path);
Storage::disk('public')->assertExists($territorio->pdf_path);
$this->assertSame(1, TerritorioPdfVersione::where('territorio_id', $territorio->id)->count());
$this->assertNotNull($mappa->image_path);
Storage::disk('public')->assertExists($mappa->image_path);
$this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->stato());
$this->assertSame($mappa->geojson, $mappa->geojson_pubblicato);
// L'anteprima confermata non resta in giro.
$this->assertNull($servizio->stato($token));
$this->assertSame([], Storage::disk('local')->files('piantine-anteprime'));
}
public function test_se_la_piantina_cambia_dopo_l_anteprima_la_conferma_viene_rifiutata(): void
{
[$territorio, $mappa] = $this->piantinaCollegata();
$servizio = app(PubblicazionePiantina::class);
$token = $servizio->avvia($mappa);
// I confini sono stampati sulla cartolina: cambiarli rende vecchia l'anteprima.
$territorio->update(['confini' => 'Nord: via Nuova']);
try {
$servizio->conferma($token);
$this->fail('La conferma doveva essere rifiutata.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('cambiat', $e->getMessage());
}
$this->assertNull($territorio->fresh()->pdf_path);
$this->assertNull($mappa->fresh()->pubblicata_at);
}
public function test_una_piantina_scollegata_non_si_pubblica(): void
{
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
$this->expectException(RuntimeException::class);
app(PubblicazionePiantina::class)->avvia($mappa);
}
public function test_un_renderer_che_non_risponde_lascia_un_errore_leggibile(): void
{
Http::fake(['*/render' => Http::response(['error' => 'area troppo estesa'], 413)]);
[, $mappa] = $this->piantinaCollegata(renderer: false);
$servizio = app(PubblicazionePiantina::class);
$token = $servizio->avvia($mappa);
$stato = $servizio->stato($token);
$this->assertSame(PubblicazionePiantina::ERRORE, $stato['stato']);
$this->assertStringContainsString('area troppo estesa', $stato['messaggio']);
$this->assertNull($servizio->pdfAnteprima($token));
}
public function test_annullare_butta_l_anteprima(): void
{
[, $mappa] = $this->piantinaCollegata();
$servizio = app(PubblicazionePiantina::class);
$token = $servizio->avvia($mappa);
$servizio->annulla($token);
$this->assertNull($servizio->stato($token));
$this->assertSame([], Storage::disk('local')->files('piantine-anteprime'));
}
public function test_la_finestra_porta_dalla_richiesta_alla_conferma(): void
{
[$territorio, $mappa] = $this->piantinaCollegata();
Livewire::actingAs($this->admin())
->test(Pubblicazione::class)
->dispatch('pubblica-piantina', mappaId: $mappa->id)
->assertSet('fase', PubblicazionePiantina::PRONTA)
->assertSee('Anteprima del PDF da pubblicare', false)
->call('conferma')
->assertSet('mappaId', null)
->assertDispatched('piantina-pubblicata', mappaId: $mappa->id);
$this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->fresh()->stato());
$this->assertNotNull($territorio->fresh()->pdf_path);
}
public function test_chiudere_la_finestra_non_pubblica_niente(): void
{
[$territorio, $mappa] = $this->piantinaCollegata();
Livewire::actingAs($this->admin())
->test(Pubblicazione::class)
->dispatch('pubblica-piantina', mappaId: $mappa->id)
->call('chiudi')
->assertSet('mappaId', null);
$this->assertNull($territorio->fresh()->pdf_path);
$this->assertSame([], Storage::disk('local')->files('piantine-anteprime'));
}
// ─── Stati del modello ──────────────────────────────────────────────────
public function test_dopo_la_pubblicazione_ogni_dato_stampato_riporta_la_piantina_da_pubblicare(): void
{
[$territorio, $mappa] = $this->piantinaCollegata();
$servizio = app(PubblicazionePiantina::class);
$this->actingAs($this->admin());
$servizio->conferma($servizio->avvia($mappa));
$this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->fresh()->stato());
$territorio->update(['zona_id' => Zona::create(['nome' => 'Altra', 'attivo' => true])->id]);
$this->assertSame(TerritorioMappa::STATO_MODIFICATA, $mappa->fresh()->stato());
}
public function test_un_pdf_sostituito_per_altre_vie_riporta_la_piantina_da_pubblicare(): void
{
[$territorio, $mappa] = $this->piantinaCollegata();
$servizio = app(PubblicazionePiantina::class);
$this->actingAs($this->admin());
$servizio->conferma($servizio->avvia($mappa));
$territorio->update(['pdf_path' => 'territori-pdf/caricato-a-mano.pdf']);
$this->assertSame(TerritorioMappa::STATO_MODIFICATA, $mappa->fresh()->stato());
}
public function test_motivo_non_pubblicabile_dice_cosa_manca(): void
{
$senzaDisegno = TerritorioMappa::create(['geojson' => 'rotto']);
$this->assertStringContainsString('Disegna', $senzaDisegno->motivoNonPubblicabile());
$scollegata = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
$this->assertStringContainsString('Collega', $scollegata->motivoNonPubblicabile());
[$territorio, $mappa] = $this->piantinaCollegata();
$this->assertNull($mappa->motivoNonPubblicabile());
$territorio->delete();
$this->assertStringContainsString('cestino', $mappa->fresh()->motivoNonPubblicabile());
$this->assertSame('Terr. 7', $mappa->fresh()->etichetta());
}
private function piantinaCollegata(bool $renderer = true): array
{
if ($renderer) {
$this->rendererRisponde();
}
$zona = Zona::firstOrCreate(['nome' => 'Centro'], ['attivo' => true]);
$territorio = Territorio::create([
'numero' => '7',
'attivo' => true,
'zona_id' => $zona->id,
'confini' => 'Nord: via Roma',
]);
$mappa = TerritorioMappa::create([
'territorio_id' => $territorio->id,
'geojson' => json_encode(self::GEOMETRIA),
]);
return [$territorio, $mappa];
}
private function admin(): User
{
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
$utente = User::firstOrCreate(
['email' => 'admin@example.test'],
['name' => 'Amministratore', 'password' => 'password-di-prova'],
);
$utente->assignRole('amministratore');
return $utente;
}
}