Files
landing-api/tests/Feature/Site/RedirectsTest.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

169 lines
5.6 KiB
PHP

<?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);
}
}