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:
96
tests/Feature/Site/ContentVersionTest.php
Normal file
96
tests/Feature/Site/ContentVersionTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
207
tests/Feature/Site/MaintenanceModeTest.php
Normal file
207
tests/Feature/Site/MaintenanceModeTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
193
tests/Feature/Site/PruneAndRedirectHealthTest.php
Normal file
193
tests/Feature/Site/PruneAndRedirectHealthTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
225
tests/Feature/Site/PublicationWindowTest.php
Normal file
225
tests/Feature/Site/PublicationWindowTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
168
tests/Feature/Site/RedirectsTest.php
Normal file
168
tests/Feature/Site/RedirectsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user