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>
331 lines
11 KiB
PHP
331 lines
11 KiB
PHP
<?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')));
|
|
}
|
|
}
|