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>
250 lines
8.1 KiB
PHP
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);
|
|
}
|
|
}
|