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>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\Project;
use App\Support\ContentVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The number that tells the frontend its cache is stale.
*
* The public API is cached with a long `stale-while-revalidate`, which is
* right for an SSR frontend and wrong for an urgent correction. This is how a
* fix stops sitting behind a stale copy without a purge webhook or anything to
* deploy.
*/
class ContentVersionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
Queue::fake();
}
#[Test]
public function the_frontend_is_given_a_version(): void
{
$this->getJson('/api/public/v1/site')
->assertOk()
->assertJsonPath('data.content_version', ContentVersion::current());
}
#[Test]
public function publishing_something_moves_it(): void
{
$before = ContentVersion::current();
Project::factory()->published()->create();
$this->assertGreaterThan($before, ContentVersion::current());
}
#[Test]
public function a_draft_nobody_can_see_does_not(): void
{
Project::factory()->draft()->create();
$before = ContentVersion::current();
Project::query()->first()->update(['title' => ['es' => 'Otro borrador']]);
// Editing something invisible invalidates nobody's cache, and bumping
// on every keystroke of a half-written page would make the number
// meaningless.
$this->assertSame($before, ContentVersion::current());
}
#[Test]
public function taking_something_down_moves_it_too(): void
{
$page = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subDay(),
]);
$before = ContentVersion::current();
$page->update(['status' => PageStatus::Draft]);
// Going away is exactly as visible to a reader as arriving.
$this->assertGreaterThan($before, ContentVersion::current());
}
#[Test]
public function deleting_published_work_moves_it(): void
{
$project = Project::factory()->published()->create();
$before = ContentVersion::current();
$project->delete();
$this->assertGreaterThan($before, ContentVersion::current());
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Models\Project;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class MaintenanceModeTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
}
#[Test]
public function the_public_api_answers_503_with_the_notice(): void
{
Project::factory()->published()->create();
Maintenance::enable(message: ['es' => 'Volvemos en una hora.']);
$response = $this->getJson('/api/public/v1/projects');
$response->assertStatus(503)
->assertJsonPath('maintenance', true)
->assertJsonPath('message', 'Volvemos en una hora.');
$this->assertNotNull($response->headers->get('Retry-After'));
// A cached 503 would outlive the maintenance itself.
$this->assertSame('no-store, private', $response->headers->get('Cache-Control'));
}
#[Test]
public function the_notice_is_returned_in_the_requested_language(): void
{
Maintenance::enable(message: [
'es' => 'Estamos de obras.',
'ca' => 'Estem d\'obres.',
]);
$this->getJson('/api/public/v1/site?lang=ca')
->assertStatus(503)
->assertJsonPath('message', 'Estem d\'obres.');
}
#[Test]
public function a_language_without_a_written_notice_falls_back(): void
{
Maintenance::enable(message: ['es' => 'Solo en castellano.']);
// Falls back to the default locale's wording rather than showing
// nothing to an English visitor.
$this->getJson('/api/public/v1/site?lang=en')
->assertStatus(503)
->assertJsonPath('message', 'Solo en castellano.');
}
#[Test]
public function with_no_notice_written_a_translated_default_is_used(): void
{
Maintenance::enable();
$this->getJson('/api/public/v1/site?lang=en')
->assertStatus(503)
->assertJsonPath('message', __('maintenance.default_message', [], 'en'));
}
#[Test]
public function the_panel_keeps_working_while_the_site_is_down(): void
{
$this->seed(RolePermissionSeeder::class);
Maintenance::enable();
// The whole point: the collective must be able to fix whatever caused
// the maintenance.
$this->actingAs($this->userWithRoles('admin'))
->get('/admin')
->assertSuccessful();
}
#[Test]
public function the_admin_api_keeps_working_while_the_site_is_down(): void
{
$this->seed(RolePermissionSeeder::class);
Maintenance::enable();
$user = $this->userWithRoles('editor');
$this->getJson('/api/v1/me', $this->apiHeadersFor($user))->assertOk();
}
#[Test]
public function the_preview_secret_lets_the_team_see_the_real_site(): void
{
config()->set('site.preview_token', 'the-secret');
Project::factory()->published()->create();
Maintenance::enable();
$this->getJson('/api/public/v1/projects', ['X-Preview-Token' => 'the-secret'])
->assertOk()
->assertJsonCount(1, 'data');
}
#[Test]
public function a_trusted_ip_also_bypasses_it(): void
{
config()->set('site.trusted_ips', ['127.0.0.1']);
Project::factory()->published()->create();
Maintenance::enable();
$this->getJson('/api/public/v1/projects')->assertOk();
}
#[Test]
public function it_lifts_itself_once_the_window_has_passed(): void
{
Maintenance::enable(until: now()->addHour()->toImmutable());
$this->assertTrue(Maintenance::isEnabled());
$this->travel(2)->hours();
// A forgotten flag must not keep the site down forever.
$this->assertFalse(Maintenance::isEnabled());
$this->getJson('/api/public/v1/site')->assertOk();
}
#[Test]
public function retry_after_reflects_the_remaining_window(): void
{
Maintenance::enable(until: now()->addMinutes(10)->toImmutable());
$retryAfter = (int) $this->getJson('/api/public/v1/site')
->assertStatus(503)
->headers->get('Retry-After');
$this->assertGreaterThan(500, $retryAfter);
$this->assertLessThanOrEqual(600, $retryAfter);
}
#[Test]
public function disabling_brings_the_site_back(): void
{
Maintenance::enable();
$this->getJson('/api/public/v1/site')->assertStatus(503);
Maintenance::disable();
$this->getJson('/api/public/v1/site')->assertOk();
}
#[Test]
public function the_setting_is_cached_but_a_change_takes_effect_at_once(): void
{
$this->getJson('/api/public/v1/site')->assertOk();
Maintenance::enable();
// Writes clear the cache entry, so there is no stale window in which
// the site stays up after someone flipped the switch.
$this->getJson('/api/public/v1/site')->assertStatus(503);
}
#[Test]
public function toggling_it_needs_its_own_permission(): void
{
$this->seed(RolePermissionSeeder::class);
$editor = $this->userWithRoles('editor');
$admin = $this->userWithRoles('admin');
// Editors can see the state but not take the site down.
$this->assertTrue($editor->can('settings.view'));
$this->assertFalse($editor->can('settings.maintenance'));
$this->assertTrue($admin->can('settings.maintenance'));
}
#[Test]
public function the_command_reports_and_toggles(): void
{
$this->artisan('site:maintenance', ['--message' => 'Obras'])
->assertSuccessful();
$this->assertTrue(Maintenance::isEnabled());
$this->assertSame('Obras', Maintenance::message('es'));
$this->artisan('site:maintenance', ['--up' => true])->assertSuccessful();
$this->assertFalse(Maintenance::isEnabled());
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Models\Media;
use App\Models\Page;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Revision;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\Health\Severity;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Keeping the system from rotting: redirects that make things worse, and the
* three kinds of debris that otherwise accumulate for ever.
*/
class PruneAndRedirectHealthTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Storage::fake(config('media.disk'));
}
protected function finding(string $key): Finding
{
return app(HealthReport::class)->runAll()->firstWhere('key', $key)
?? throw new \RuntimeException("No finding for {$key}");
}
#[Test]
public function it_finds_a_redirect_that_chains_into_another(): void
{
Redirect::factory()->create(['from' => '/a', 'to' => '/b', 'locale' => 'es']);
Redirect::factory()->create(['from' => '/b', 'to' => '/c', 'locale' => 'es']);
$finding = $this->finding('broken-redirects');
// The visitor is sent somewhere that sends them somewhere else: a
// round trip per hop, and eventually a loop.
$this->assertSame(Severity::Error, $finding->severity);
$this->assertSame(1, $finding->count);
}
#[Test]
public function it_finds_a_redirect_that_lands_on_nothing(): void
{
Redirect::factory()->create(['from' => '/viejo', 'to' => '/proyectos/no-existe']);
// Worse than the 404 it was meant to prevent: the visitor is bounced
// and lands on the same nothing.
$this->assertSame(1, $this->finding('redirects-to-nowhere')->count);
}
#[Test]
public function a_redirect_to_a_real_page_is_fine(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Vivo'], 'slug' => ['es' => 'vivo'],
]);
Redirect::factory()->create(['from' => '/viejo', 'to' => '/proyectos/vivo']);
$this->assertSame(Severity::Ok, $this->finding('redirects-to-nowhere')->severity);
}
#[Test]
public function a_redirect_off_this_site_is_not_ours_to_verify(): void
{
Redirect::factory()->create(['from' => '/viejo', 'to' => 'https://otra-web.example/algo']);
$this->assertSame(Severity::Ok, $this->finding('redirects-to-nowhere')->severity);
}
#[Test]
public function pruning_keeps_the_first_and_last_of_a_history(): void
{
$page = Page::factory()->create();
foreach (range(1, 4) as $n) {
$page->update(['title' => ['es' => "Versión {$n}"]]);
}
Revision::query()->update(['created_at' => now()->subYears(2)]);
$this->artisan('content:prune')->assertSuccessful();
// Two facts survive whatever happens: when it appeared, and who
// touched it last.
$this->assertSame(2, $page->revisions()->count());
// `reorder`, because the relation sorts newest-first by default and
// an added orderBy would only be a tie-breaker behind it.
$this->assertSame('created', $page->revisions()->reorder('id')->first()->event);
}
#[Test]
public function recent_history_is_left_alone(): void
{
$page = Page::factory()->create();
$page->update(['title' => ['es' => 'Reciente']]);
$this->artisan('content:prune')->assertSuccessful();
$this->assertSame(2, $page->revisions()->count());
}
#[Test]
public function it_erases_what_has_been_in_the_bin_long_enough(): void
{
$old = Page::factory()->create();
$old->delete();
$old->forceFill(['deleted_at' => now()->subYear()])->save();
$recent = Page::factory()->create();
$recent->delete();
$this->artisan('content:prune')->assertSuccessful();
$this->assertNull(Page::withTrashed()->find($old->id));
$this->assertNotNull(Page::withTrashed()->find($recent->id));
}
#[Test]
public function a_dry_run_changes_nothing(): void
{
$page = Page::factory()->create();
$page->delete();
$page->forceFill(['deleted_at' => now()->subYear()])->save();
$this->artisan('content:prune', ['--dry-run' => true])->assertSuccessful();
$this->assertNotNull(Page::withTrashed()->find($page->id));
}
#[Test]
public function it_deletes_image_files_nothing_points_at(): void
{
$disk = Storage::disk(config('media.disk'));
$disk->put('projects/en-uso.jpg', 'x');
$disk->put('projects/huerfana.jpg', 'x');
Project::factory()->create(['cover_image' => 'projects/en-uso.jpg']);
Media::factory()->create(['path' => 'projects/huerfana.jpg']);
$this->artisan('content:prune', ['--media' => true])->assertSuccessful();
$this->assertTrue($disk->exists('projects/en-uso.jpg'));
$this->assertFalse($disk->exists('projects/huerfana.jpg'));
}
#[Test]
public function an_image_belonging_to_something_in_the_bin_is_kept(): void
{
$disk = Storage::disk(config('media.disk'));
$disk->put('projects/borrado.jpg', 'x');
$project = Project::factory()->create(['cover_image' => 'projects/borrado.jpg']);
$project->delete();
$this->artisan('content:prune', ['--media' => true])->assertSuccessful();
// It can still be restored, and restoring it to a missing image would
// be worse than keeping a file.
$this->assertTrue($disk->exists('projects/borrado.jpg'));
}
#[Test]
public function files_are_left_alone_unless_asked_for(): void
{
$disk = Storage::disk(config('media.disk'));
$disk->put('projects/huerfana.jpg', 'x');
Media::factory()->create(['path' => 'projects/huerfana.jpg']);
$this->artisan('content:prune')->assertSuccessful();
// Deleting a file is the one thing here that restoring a row cannot
// undo, so it never happens by routine.
$this->assertTrue($disk->exists('projects/huerfana.jpg'));
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Enums\CallStatus;
use App\Enums\PageStatus;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Content that is only true between two dates.
*
* The window is enforced by the scopes rather than by a scheduled job, so it
* holds even when nothing is running — a queue worker being down must not put
* last month's notice back on the site.
*/
class PublicationWindowTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
Queue::fake();
}
#[Test]
public function a_page_stays_off_the_site_until_its_date(): void
{
Page::factory()->create([
'status' => PageStatus::Published,
'title' => ['es' => 'El anuncio'],
'slug' => ['es' => 'el-anuncio'],
'published_at' => now()->addDay(),
]);
$this->getJson('/api/public/v1/pages/el-anuncio')->assertNotFound();
$this->travel(2)->days();
$this->getJson('/api/public/v1/pages/el-anuncio')->assertOk();
}
#[Test]
public function a_page_drops_off_the_site_on_its_end_date(): void
{
Page::factory()->create([
'status' => PageStatus::Published,
'title' => ['es' => 'Taller de octubre'],
'slug' => ['es' => 'taller'],
'published_at' => now()->subWeek(),
'unpublish_at' => now()->addDay(),
]);
$this->getJson('/api/public/v1/pages/taller')->assertOk();
$this->travel(2)->days();
// Nobody has to remember to take it down, which is the whole point:
// nobody ever does.
$this->getJson('/api/public/v1/pages/taller')->assertNotFound();
}
#[Test]
public function the_window_holds_without_anything_running(): void
{
Page::factory()->create([
'status' => PageStatus::Published,
'title' => ['es' => 'Caducada'],
'slug' => ['es' => 'caducada'],
'published_at' => now()->subMonth(),
'unpublish_at' => now()->subDay(),
]);
// No command, no queue, no scheduler — the scope is what decides.
$this->getJson('/api/public/v1/pages')
->assertOk()
->assertJsonCount(0, 'data');
}
#[Test]
public function an_expired_project_leaves_the_url_list_too(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Terminado'],
'slug' => ['es' => 'terminado'],
'unpublish_at' => now()->subDay(),
]);
$slugs = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data');
// Otherwise the frontend would keep it in the sitemap and point search
// engines at a 404.
$this->assertEmpty($slugs['projects'] ?? []);
}
#[Test]
public function preview_still_reaches_content_outside_its_window(): void
{
config()->set('site.preview_token', 'secreto');
Page::factory()->create([
'status' => PageStatus::Published,
'title' => ['es' => 'Aún no'],
'slug' => ['es' => 'aun-no'],
'published_at' => now()->addWeek(),
]);
// Checking a scheduled page before its date is exactly what preview is
// for.
$this->withHeaders(['X-Preview-Token' => 'secreto'])
->getJson('/api/public/v1/pages/aun-no')
->assertOk();
}
#[Test]
public function the_command_closes_a_call_whose_date_has_passed(): void
{
$call = Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => now()->subWeek(),
]);
$this->artisan('content:schedule')->assertSuccessful();
// The site already stopped offering it; this is about the panel not
// claiming otherwise, and a counter that stays worth reading.
$this->assertSame(CallStatus::Closed, $call->refresh()->status);
}
#[Test]
public function the_command_leaves_a_call_that_is_still_open(): void
{
$open = Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => now()->addWeek(),
]);
$undated = Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => null,
]);
$this->artisan('content:schedule')->assertSuccessful();
$this->assertSame(CallStatus::Open, $open->refresh()->status);
$this->assertSame(CallStatus::Open, $undated->refresh()->status);
}
#[Test]
public function a_dry_run_changes_nothing(): void
{
$call = Call::factory()->create([
'project_id' => null,
'status' => CallStatus::Open,
'closes_at' => now()->subWeek(),
]);
$this->artisan('content:schedule', ['--dry-run' => true])->assertSuccessful();
$this->assertSame(CallStatus::Open, $call->refresh()->status);
}
#[Test]
public function the_panel_can_tell_the_three_states_apart(): void
{
$scheduled = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->addDays(3),
]);
$expired = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subMonth(),
'unpublish_at' => now()->subDay(),
]);
$live = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subDay(),
]);
// A panel that reports "Published" for something the site is not
// showing is lying to whoever wrote it.
$this->assertTrue($scheduled->isScheduled());
$this->assertFalse($scheduled->hasExpired());
$this->assertTrue($expired->hasExpired());
$this->assertFalse($expired->isScheduled());
$this->assertFalse($live->isScheduled());
$this->assertFalse($live->hasExpired());
}
#[Test]
public function content_about_to_go_can_be_spotted_in_advance(): void
{
$soon = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subMonth(),
'unpublish_at' => now()->addDays(2),
]);
$later = Page::factory()->create([
'status' => PageStatus::Published,
'published_at' => now()->subMonth(),
'unpublish_at' => now()->addMonths(2),
]);
$this->assertTrue($soon->expiresSoon());
$this->assertFalse($later->expiresSoon());
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Site;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Service;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The net under a renamed slug.
*
* Renaming one breaks every link anybody shared, silently: nothing here
* notices and the frontend only finds out when a visitor gets a 404. The
* moment the slug changes is the only moment the old address is still known.
*/
class RedirectsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withHeaders(['Accept-Language' => 'es']);
Queue::fake();
}
#[Test]
public function renaming_a_slug_leaves_a_redirect_behind(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Mi proyecto'],
'slug' => ['es' => 'nombre-viejo'],
]);
$project->update(['slug' => ['es' => 'nombre-nuevo']]);
$redirect = Redirect::query()->sole();
$this->assertSame('/proyectos/nombre-viejo', $redirect->from);
$this->assertSame('/proyectos/nombre-nuevo', $redirect->to);
$this->assertSame(301, $redirect->status);
$this->assertSame('es', $redirect->locale);
$this->assertTrue($redirect->wasAutomatic());
}
#[Test]
public function each_language_gets_its_own_rule(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Uno', 'ca' => 'U'],
'slug' => ['es' => 'viejo-es', 'ca' => 'vell-ca'],
]);
$project->update(['slug' => ['es' => 'nuevo-es', 'ca' => 'nou-ca']]);
// The Catalan URL carries its own prefix and its own path segment, so
// one rule could never cover both.
$this->assertSame(
['/proyectos/viejo-es' => '/proyectos/nuevo-es', '/ca/projectes/vell-ca' => '/ca/projectes/nou-ca'],
Redirect::query()->pluck('to', 'from')->all(),
);
}
#[Test]
public function adding_a_translation_is_not_a_rename(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Uno'],
'slug' => ['es' => 'uno'],
]);
$project->update(['slug' => ['es' => 'uno', 'ca' => 'u']]);
// Nothing broke: there was no Catalan address before.
$this->assertSame(0, Redirect::query()->count());
}
#[Test]
public function a_second_rename_does_not_leave_a_chain_behind(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Uno'], 'slug' => ['es' => 'v1'],
]);
$project->update(['slug' => ['es' => 'v2']]);
$project->update(['slug' => ['es' => 'v3']]);
// Both old addresses point at the current one. A frontend following a
// chain would cost an extra round trip per hop.
$this->assertSame(
['/proyectos/v1' => '/proyectos/v3', '/proyectos/v2' => '/proyectos/v3'],
Redirect::query()->orderBy('from')->pluck('to', 'from')->all(),
);
}
#[Test]
public function renaming_back_does_not_create_a_loop(): void
{
$project = Project::factory()->published()->create([
'title' => ['es' => 'Uno'], 'slug' => ['es' => 'original'],
]);
$project->update(['slug' => ['es' => 'cambiado']]);
$project->update(['slug' => ['es' => 'original']]);
// A rule from a path to itself would send a browser round for ever.
foreach (Redirect::query()->get() as $redirect) {
$this->assertFalse($redirect->isCircular(), "{$redirect->from} → {$redirect->to}");
}
}
#[Test]
public function it_works_for_every_kind_of_record_with_a_public_url(): void
{
$service = Service::factory()->create([
'title' => ['es' => 'Servicio'], 'slug' => ['es' => 'antiguo'],
]);
$service->update(['slug' => ['es' => 'moderno']]);
$this->assertSame('/servicios/antiguo', Redirect::query()->sole()->from);
}
#[Test]
public function the_frontend_gets_the_whole_list_in_one_request(): void
{
Redirect::factory()->create(['from' => '/vieja', 'to' => '/nueva', 'locale' => null]);
Redirect::factory()->disabled()->create(['from' => '/apagada', 'to' => '/otra']);
$data = $this->getJson('/api/public/v1/redirects')->assertOk()->json('data');
// One request rather than a lookup per 404: there are never many, and
// an SSR frontend would otherwise ask on every miss it serves.
$this->assertCount(1, $data);
$this->assertSame('/vieja', $data[0]['from']);
$this->assertNull($data[0]['locale']);
}
#[Test]
public function paths_are_normalised_however_they_were_typed(): void
{
$this->assertSame('/proyectos/uno', Redirect::normalise('proyectos/uno'));
$this->assertSame('/proyectos/uno', Redirect::normalise('/proyectos/uno/'));
$this->assertSame('/proyectos/uno', Redirect::normalise('/proyectos/uno?utm=x'));
// So a rule typed by hand matches one written automatically.
$this->assertSame('/', Redirect::normalise('/'));
}
#[Test]
public function following_one_can_be_counted(): void
{
$redirect = Redirect::factory()->create(['hits' => 0]);
$redirect->markUsed();
// The number that decides whether a rule can ever be deleted.
$this->assertSame(1, $redirect->fresh()->hits);
$this->assertNotNull($redirect->fresh()->last_used_at);
}
}