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,330 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\PageStatus;
use App\Enums\ReviewEvent;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use App\Notifications\ReviewRequested;
use App\Notifications\ReviewReturned;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Handing work to somebody who is allowed to publish it.
*
* The feature exists because `publish` is a permission of its own: there are
* people who can finish a piece of work and not put it on the site. What these
* tests hold still is the handover — that asking needs no new permission, that
* answering needs the one that made the handover necessary, and that nothing
* can leave a record live and still marked as waiting.
*/
class ReviewsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Notification::fake();
}
/**
* Somebody who can write and not publish — the shipped `writer` role,
* which is the whole reason this feature has anywhere to happen.
*/
protected function writer(): User
{
return $this->userWithRoles('writer');
}
protected function draft(?User $author = null): Page
{
return Page::factory()->create([
'status' => PageStatus::Draft,
'published_at' => null,
'author_id' => $author?->id,
]);
}
#[Test]
public function asking_for_a_review_records_who_asked_and_what_they_said(): void
{
$editor = $this->userWithRoles('editor');
$page = $this->draft($editor);
$page->requestReview(null, 'Falta la foto de portada', $editor);
$page->refresh();
$this->assertTrue($page->isAwaitingReview());
$this->assertSame($editor->id, $page->review_requested_by);
$comment = $page->reviewComments()->first();
$this->assertSame(ReviewEvent::Requested, $comment->event);
$this->assertSame('Falta la foto de portada', $comment->body);
$this->assertSame($editor->name, $comment->author());
}
/**
* The status enum already had a `Review` value, shown in the table and
* offered as a filter. It has to keep telling the truth.
*/
#[Test]
public function a_page_in_review_says_so_in_its_status(): void
{
$page = $this->draft();
$page->requestReview();
$this->assertSame(PageStatus::Review, $page->refresh()->status);
}
/**
* Nothing about asking is a new permission: being allowed to edit
* something is already the right to say you have finished with it.
*/
#[Test]
public function somebody_who_cannot_publish_can_still_ask(): void
{
$writer = $this->writer();
$this->assertTrue($writer->cannot('pages.publish'));
$this->assertTrue($writer->can('pages.update'));
$page = $this->draft($writer);
$page->requestReview(null, null, $writer);
$this->assertTrue($page->refresh()->isAwaitingReview());
}
/**
* The distinction the catalogue always made and no role exercised: a
* separate `publish` permission is only meaningful if somebody holds
* `update` without it.
*/
#[Test]
public function the_shipped_writer_role_is_the_one_this_exists_for(): void
{
$writer = $this->writer();
$this->assertTrue($writer->can('pages.update'));
$this->assertTrue($writer->can('projects.update'));
$this->assertTrue($writer->can('services.update'));
$this->assertTrue($writer->cannot('pages.publish'));
$this->assertTrue($writer->cannot('projects.publish'));
$this->assertTrue($writer->cannot('services.publish'));
// Same reason the viewer does not have it: writing for the site and
// reading strangers' contact details are different jobs.
$this->assertTrue($writer->cannot('applications.view_any'));
}
#[Test]
public function everybody_who_could_publish_it_is_told(): void
{
$writer = $this->writer();
$admin = $this->userWithRoles('admin');
$viewer = $this->userWithRoles('viewer');
$page = $this->draft($writer);
$page->requestReview(null, 'Listo', $writer);
Notification::assertSentTo($admin, ReviewRequested::class);
// Being told about work you could never act on is noise.
Notification::assertNotSentTo($viewer, ReviewRequested::class);
Notification::assertNotSentTo($writer, ReviewRequested::class);
}
/**
* Naming somebody addresses the request, and then it is theirs alone —
* otherwise "assigned to Ana" would mean nothing.
*/
#[Test]
public function naming_a_reviewer_tells_only_them(): void
{
$writer = $this->writer();
$ana = $this->userWithRoles('admin');
$other = $this->userWithRoles('admin');
$page = $this->draft($writer);
$page->requestReview($ana, null, $writer);
Notification::assertSentTo($ana, ReviewRequested::class);
Notification::assertNotSentTo($other, ReviewRequested::class);
}
#[Test]
public function sending_it_back_tells_the_person_who_asked(): void
{
$writer = $this->writer();
$admin = $this->userWithRoles('admin');
$page = $this->draft($writer);
$page->requestReview(null, null, $writer);
$page->returnForChanges('Cambia el título, es el de otra página', $admin);
$page->refresh();
$this->assertFalse($page->isAwaitingReview());
$this->assertSame(PageStatus::Draft, $page->status);
Notification::assertSentTo($writer, ReviewReturned::class);
$comment = $page->reviewComments()->first();
$this->assertSame(ReviewEvent::Returned, $comment->event);
$this->assertSame('Cambia el título, es el de otra página', $comment->body);
}
/**
* The loop closes on the state, not on a button, so no route can leave
* something live and still marked as waiting for somebody.
*/
#[Test]
public function publishing_closes_the_review_however_it_was_published(): void
{
$page = $this->draft();
$page->requestReview();
// Straight to the model, as the form and the table action both do.
$page->update(['status' => PageStatus::Published, 'published_at' => now()]);
$page->refresh();
$this->assertFalse($page->isAwaitingReview());
$this->assertNull($page->reviewer_id);
$this->assertSame(
ReviewEvent::Published,
$page->reviewComments()->first()->event,
);
}
/**
* Scheduling is not publishing: a page whose date is next week is not on
* the site yet, so nobody's review is over.
*/
#[Test]
public function a_future_date_does_not_close_the_review(): void
{
$page = $this->draft();
$page->requestReview();
$page->update([
'status' => PageStatus::Published,
'published_at' => now()->addWeek(),
]);
$this->assertTrue($page->refresh()->isAwaitingReview());
}
#[Test]
public function the_review_columns_stay_out_of_the_change_history(): void
{
$page = $this->draft();
$page->requestReview(null, 'Nota');
// The thread already says this in words. Repeating it as a diff would
// bury the edits somebody actually came looking for.
$fields = $page->revisions()->get()->flatMap(fn ($revision) => $revision->fields());
$this->assertNotContains('review_requested_at', $fields);
$this->assertNotContains('reviewer_id', $fields);
}
#[Test]
public function an_unaddressed_review_is_in_everybodys_queue(): void
{
$admin = $this->userWithRoles('admin');
$ana = $this->userWithRoles('admin');
$this->draft()->requestReview();
$this->draft()->requestReview($ana);
// The unaddressed one, plus nothing addressed to somebody else.
$this->assertSame(1, Page::query()->awaitingReviewBy($admin)->count());
// Ana sees hers and the unaddressed one.
$this->assertSame(2, Page::query()->awaitingReviewBy($ana)->count());
}
#[Test]
public function it_works_the_same_for_projects_and_services(): void
{
$project = Project::factory()->create(['published_at' => null]);
$service = Service::factory()->create(['is_published' => false, 'published_at' => null]);
$project->requestReview();
$service->requestReview();
$this->assertTrue($project->refresh()->isAwaitingReview());
$this->assertTrue($service->refresh()->isAwaitingReview());
$project->update(['published_at' => now()]);
$service->update(['is_published' => true, 'published_at' => now()]);
$this->assertFalse($project->refresh()->isAwaitingReview());
$this->assertFalse($service->refresh()->isAwaitingReview());
}
#[Test]
public function a_comment_changes_nothing_but_is_recorded(): void
{
$page = $this->draft();
$page->requestReview();
$page->comment('¿La foto la tienes tú?');
$this->assertTrue($page->refresh()->isAwaitingReview());
$this->assertSame(
ReviewEvent::Commented,
$page->reviewComments()->first()->event,
);
}
#[Test]
public function the_health_check_finds_a_handover_nobody_answered(): void
{
$fresh = $this->draft();
$fresh->requestReview();
$stale = $this->draft();
$stale->requestReview();
$stale->forceFill(['review_requested_at' => now()->subDays(10)])->saveQuietly();
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'stale-reviews');
$this->assertInstanceOf(Finding::class, $finding);
$this->assertSame(1, $finding->count);
}
#[Test]
public function the_check_is_hidden_from_somebody_who_could_not_act_on_it(): void
{
$page = $this->draft();
$page->requestReview();
$page->forceFill(['review_requested_at' => now()->subDays(10)])->saveQuietly();
$keys = fn (User $user) => app(HealthReport::class)->runFor($user)->pluck('key');
$this->assertContains('stale-reviews', $keys($this->userWithRoles('admin')));
$this->assertNotContains('stale-reviews', $keys($this->userWithRoles('viewer')));
}
}