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