Files
landing-api/tests/Feature/Panel/MissingUrlsTest.php
Josep Piguillem 3897113313 Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:55:27 +02:00

250 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\MissingUrlStatus;
use App\Filament\Resources\MissingUrls\MissingUrlResource;
use App\Filament\Resources\MissingUrls\Pages\ListMissingUrls;
use App\Models\MissingUrl;
use App\Models\Redirect;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\Health\Severity;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* What the panel does with a reported 404.
*
* The screen is a to-do list with one real action on it, so what is worth
* testing is that the action closes the loop — the redirect is written, and
* the entry stops asking to be dealt with — and that seeing the list is gated
* by the same permission as fixing what is on it.
*/
class MissingUrlsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function the_action_writes_the_redirect_and_closes_the_report(): void
{
$this->actingAs($this->userWithRoles('super_admin'));
$missing = MissingUrl::factory()->create([
'path' => '/proyectos/nombre-viejo',
'locale' => 'es',
]);
Livewire::test(ListMissingUrls::class)
->callTableAction('redirect', $missing, data: [
'to' => '/proyectos/nombre-nuevo',
'status' => 301,
'locale' => 'es',
])
->assertHasNoTableActionErrors();
$this->assertDatabaseHas('redirects', [
'from' => '/proyectos/nombre-viejo',
'to' => '/proyectos/nombre-nuevo',
'status' => 301,
'locale' => 'es',
]);
$this->assertSame(MissingUrlStatus::Resolved, $missing->refresh()->status);
}
/**
* The destination goes through the same normalisation as a hand-written
* rule, so an address typed with a trailing slash does not become a
* redirect that never matches.
*/
#[Test]
public function the_destination_is_normalised(): void
{
$this->actingAs($this->userWithRoles('super_admin'));
$missing = MissingUrl::factory()->create(['path' => '/algo', 'locale' => 'es']);
Livewire::test(ListMissingUrls::class)
->callTableAction('redirect', $missing, data: [
'to' => '/servicios/algo/',
'status' => 301,
'locale' => 'es',
]);
$this->assertDatabaseHas('redirects', ['to' => '/servicios/algo']);
}
#[Test]
public function ignoring_hides_an_entry_without_stopping_the_count(): void
{
$this->actingAs($this->userWithRoles('super_admin'));
$missing = MissingUrl::factory()->create([
'path' => '/ruido',
'locale' => 'es',
'hits' => 1,
]);
Livewire::test(ListMissingUrls::class)
->callTableAction('ignore', $missing);
$this->assertSame(MissingUrlStatus::Ignored, $missing->refresh()->status);
// Still counted: ignoring is a decision about the list, not about
// whether the address is being asked for.
MissingUrl::report('/ruido', 'es');
$missing->refresh();
$this->assertSame(2, (int) $missing->hits);
// And it stays ignored. These addresses are requested constantly — if
// the next request revived them, the button would never work twice.
$this->assertSame(MissingUrlStatus::Ignored, $missing->status);
}
/**
* Gated by the redirect permissions rather than a set of its own, so a
* role that cannot do anything about a broken link does not see the list
* of broken links either.
*/
#[Test]
public function a_viewer_cannot_open_the_list(): void
{
$this->actingAs($this->userWithRoles('viewer'));
$this->get(MissingUrlResource::getUrl('index'))->assertForbidden();
}
#[Test]
public function an_editor_can_open_it(): void
{
$this->actingAs($this->userWithRoles('editor'));
$this->get(MissingUrlResource::getUrl('index'))->assertSuccessful();
}
#[Test]
public function nobody_creates_one_by_hand(): void
{
$this->assertFalse(MissingUrlResource::canCreate());
}
#[Test]
public function the_health_check_counts_what_is_still_broken(): void
{
MissingUrl::factory()->create(['path' => '/roto', 'locale' => 'es', 'hits' => 12]);
// A single request is as likely to be a scanner as a person.
MissingUrl::factory()->create(['path' => '/una-vez', 'locale' => 'es', 'hits' => 1]);
// Already dealt with.
MissingUrl::factory()->resolved()->create(['path' => '/arreglado', 'locale' => 'es', 'hits' => 30]);
MissingUrl::factory()->ignored()->create(['path' => '/ruido', 'locale' => 'es', 'hits' => 30]);
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'missing-urls');
$this->assertInstanceOf(Finding::class, $finding);
$this->assertSame(1, $finding->count);
$this->assertSame(Severity::Warning, $finding->severity);
}
/**
* An address our own pages link to is not somebody else's stale bookmark:
* it is a fault in what we publish, and it will keep producing 404s.
*/
#[Test]
public function a_dead_link_on_our_own_site_is_an_error_not_a_warning(): void
{
MissingUrl::factory()->internal()->create([
'path' => '/roto',
'locale' => 'es',
'hits' => 4,
]);
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'missing-urls');
$this->assertSame(Severity::Error, $finding->severity);
}
#[Test]
public function pruning_forgets_one_off_scans_and_keeps_the_rest(): void
{
MissingUrl::factory()->create([
'path' => '/una-vez', 'locale' => 'es',
'hits' => 1, 'last_seen_at' => now()->subDays(60),
]);
MissingUrl::factory()->create([
'path' => '/repetida', 'locale' => 'es',
'hits' => 9, 'last_seen_at' => now()->subDays(60),
]);
MissingUrl::factory()->create([
'path' => '/reciente', 'locale' => 'es',
'hits' => 1, 'last_seen_at' => now()->subDay(),
]);
// A decision already made must not come back next week as though it
// had never been made.
MissingUrl::factory()->ignored()->create([
'path' => '/ignorada', 'locale' => 'es',
'hits' => 1, 'last_seen_at' => now()->subDays(60),
]);
$this->artisan('content:prune')->assertExitCode(0);
$this->assertDatabaseMissing('missing_urls', ['path' => '/una-vez']);
$this->assertDatabaseHas('missing_urls', ['path' => '/repetida']);
$this->assertDatabaseHas('missing_urls', ['path' => '/reciente']);
$this->assertDatabaseHas('missing_urls', ['path' => '/ignorada']);
}
#[Test]
public function a_dry_run_deletes_nothing(): void
{
MissingUrl::factory()->create([
'path' => '/una-vez', 'locale' => 'es',
'hits' => 1, 'last_seen_at' => now()->subDays(60),
]);
$this->artisan('content:prune --dry-run')->assertExitCode(0);
$this->assertDatabaseHas('missing_urls', ['path' => '/una-vez']);
}
/**
* A rule typed straight into the redirects screen closes the report too:
* the two lists must never disagree about whether an address is answered.
*/
#[Test]
public function a_hand_written_redirect_also_closes_the_report(): void
{
$missing = MissingUrl::factory()->create(['path' => '/a-mano', 'locale' => 'es']);
Redirect::create([
'from' => '/a-mano',
'to' => '/destino',
'status' => 301,
'locale' => 'es',
'is_enabled' => true,
]);
$this->assertSame(MissingUrlStatus::Resolved, $missing->refresh()->status);
}
}