Review, publish, preview and undo changes through the API
Revisions now call publishing only what publishes: a project's status is whether the work is active, and a service is published by its flag. The fake identity server answers by token, so a test can sign in a second person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,14 @@ trait FakesPireon
|
||||
{
|
||||
protected string $pireonServer = 'https://oauth.pireon.test';
|
||||
|
||||
/**
|
||||
* Identity payloads by bearer token, for every user a test has signed in
|
||||
* through the API.
|
||||
*
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
protected array $pireonIdentities = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identity Overrides for the GET /api/user payload.
|
||||
* @param array<string, mixed> $token Overrides for the POST /oauth/token payload.
|
||||
@@ -74,20 +82,39 @@ trait FakesPireon
|
||||
*/
|
||||
protected function apiHeadersFor(User $user): array
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/api/user" => Http::response([
|
||||
'id' => $user->oauth_id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'is_active' => true,
|
||||
'roles' => ['user'],
|
||||
'app_roles' => [],
|
||||
'approved_at' => '2026-01-01T00:00:00+00:00',
|
||||
]),
|
||||
]);
|
||||
$token = 'token-for-'.$user->oauth_id;
|
||||
|
||||
// Answered by token rather than with one fixed payload: the first
|
||||
// `Http::fake` for a URL wins, so a test that signs in a second person
|
||||
// would otherwise keep being told it was the first.
|
||||
if ($this->pireonIdentities === []) {
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/api/user" => function ($request) {
|
||||
$presented = str_replace('Bearer ', '', $request->header('Authorization')[0] ?? '');
|
||||
|
||||
return isset($this->pireonIdentities[$presented])
|
||||
? Http::response($this->pireonIdentities[$presented])
|
||||
: Http::response(['message' => 'Unauthenticated.'], 401);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pireonIdentities[$token] = [
|
||||
'id' => $user->oauth_id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'is_active' => true,
|
||||
'roles' => ['user'],
|
||||
'app_roles' => [],
|
||||
'approved_at' => '2026-01-01T00:00:00+00:00',
|
||||
];
|
||||
|
||||
// The guard remembers whoever it resolved last; a test that switches
|
||||
// people halfway has to make it ask again.
|
||||
$this->app['auth']->forgetGuards();
|
||||
|
||||
return [
|
||||
'Authorization' => 'Bearer token-for-'.$user->oauth_id,
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ class AdminApiContractTest extends TestCase
|
||||
$repository = Repository::factory()->create();
|
||||
Setting::set(Maintenance::KEY, ['enabled' => false]);
|
||||
$media = Media::factory()->create();
|
||||
$page->comment('Primera nota', $this->caller);
|
||||
|
||||
// One request, one documented path — the URL a real caller hits and
|
||||
// the path key that describes it, side by side rather than derived
|
||||
@@ -153,6 +154,8 @@ class AdminApiContractTest extends TestCase
|
||||
'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
|
||||
'media' => ['/v1/media', '/v1/media'],
|
||||
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
|
||||
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
|
||||
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],
|
||||
'users' => ['/v1/users', '/v1/users'],
|
||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||
'me' => ['/v1/me', '/v1/me'],
|
||||
|
||||
211
tests/Feature/Api/ReviewAndHistoryApiTest.php
Normal file
211
tests/Feature/Api/ReviewAndHistoryApiTest.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use App\Support\PreviewLink;
|
||||
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;
|
||||
|
||||
/**
|
||||
* History, review, publication and preview links through the admin API.
|
||||
*/
|
||||
class ReviewAndHistoryApiTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Notification::fake();
|
||||
}
|
||||
|
||||
protected function actingWithRole(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles([$role]);
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($user));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_history_lists_each_change_with_who_made_it(): void
|
||||
{
|
||||
$editor = $this->actingWithRole('editor');
|
||||
|
||||
$project = Project::factory()->create(['status' => 'active']);
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])->assertOk();
|
||||
|
||||
$this->getJson("/api/v1/projects/{$project->id}/revisions")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.event', 'updated')
|
||||
->assertJsonPath('data.0.author.id', $editor->id)
|
||||
->assertJsonPath('data.0.changes.status.new', 'paused');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_change_is_undone_and_the_undoing_is_recorded(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$service = Service::factory()->create(['tagline' => ['es' => 'Antes']]);
|
||||
|
||||
$this->patchJson("/api/v1/services/{$service->id}", ['tagline' => ['es' => 'Después']])->assertOk();
|
||||
|
||||
$revision = $service->revisions()->first();
|
||||
|
||||
$this->postJson("/api/v1/revisions/{$revision->id}/restore")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.changes.tagline.new.es', 'Antes');
|
||||
|
||||
$this->assertSame('Antes', $service->fresh()->getTranslation('tagline', 'es'));
|
||||
$this->assertSame(3, $service->revisions()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function undoing_a_publication_takes_the_publish_permission(): void
|
||||
{
|
||||
$page = Page::factory()->draft()->create();
|
||||
|
||||
$this->actingAs($editor = User::factory()->create()->syncRoles(['editor']));
|
||||
$page->update(['status' => PageStatus::Published, 'published_at' => now()]);
|
||||
|
||||
$this->actingWithRole('writer');
|
||||
|
||||
$this->postJson('/api/v1/revisions/'.$page->revisions()->first()->id.'/restore')
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('revision');
|
||||
|
||||
$this->assertSame(PageStatus::Published, $page->fresh()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_writer_asks_for_review_and_an_editor_sees_it_waiting(): void
|
||||
{
|
||||
$writer = $this->actingWithRole('writer');
|
||||
|
||||
$page = Page::factory()->draft()->create();
|
||||
|
||||
$this->postJson("/api/v1/pages/{$page->id}/review", ['note' => 'Lista para revisar.'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.awaiting_review', true)
|
||||
->assertJsonPath('data.requested_by.id', $writer->id);
|
||||
|
||||
$this->assertSame(PageStatus::Review, $page->fresh()->status);
|
||||
|
||||
$this->postJson("/api/v1/pages/{$page->id}/review")->assertUnprocessable();
|
||||
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$this->getJson('/api/v1/reviews')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.type', 'pages')
|
||||
->assertJsonPath('data.0.id', $page->id);
|
||||
|
||||
$this->getJson("/api/v1/pages/{$page->id}/reviews")
|
||||
->assertJsonPath('data.0.event', 'requested')
|
||||
->assertJsonPath('data.0.body', 'Lista para revisar.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_editor_returns_it_with_a_reason(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create()->syncRoles(['writer']));
|
||||
$page = Page::factory()->draft()->create();
|
||||
$page->requestReview();
|
||||
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$this->postJson("/api/v1/pages/{$page->id}/return", [])->assertUnprocessable();
|
||||
|
||||
$this->postJson("/api/v1/pages/{$page->id}/return", ['note' => 'Falta la imagen.'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.awaiting_review', false);
|
||||
|
||||
$this->assertSame(PageStatus::Draft, $page->fresh()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function publishing_later_answers_the_review_now(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create()->syncRoles(['writer']));
|
||||
$project = Project::factory()->draft()->create();
|
||||
$project->requestReview();
|
||||
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$at = now()->addDays(3)->startOfMinute();
|
||||
|
||||
$this->postJson("/api/v1/projects/{$project->id}/publish", ['at' => $at->toIso8601String()])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.published', false)
|
||||
->assertJsonPath('data.awaiting_review', false)
|
||||
->assertJsonPath('data.published_at', $at->toIso8601String());
|
||||
|
||||
$this->postJson("/api/v1/projects/{$project->id}/unpublish")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.published_at', null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_writer_cannot_publish_or_return(): void
|
||||
{
|
||||
$this->actingWithRole('writer');
|
||||
|
||||
$service = Service::factory()->create(['is_published' => false]);
|
||||
|
||||
$this->postJson("/api/v1/services/{$service->id}/publish")->assertForbidden();
|
||||
$this->postJson("/api/v1/services/{$service->id}/return", ['note' => 'x'])->assertForbidden();
|
||||
|
||||
$this->assertFalse($service->fresh()->is_published);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preview_links_open_that_draft_only(): void
|
||||
{
|
||||
config(['site.url' => 'https://pireon.test']);
|
||||
|
||||
$this->actingWithRole('writer');
|
||||
|
||||
$page = Page::factory()->draft()->create();
|
||||
|
||||
$links = $this->postJson("/api/v1/pages/{$page->id}/preview")
|
||||
->assertCreated()
|
||||
->json('data.links');
|
||||
|
||||
$this->assertArrayHasKey('es', $links);
|
||||
$this->assertStringStartsWith('https://pireon.test/', $links['es']);
|
||||
|
||||
parse_str((string) parse_url($links['es'], PHP_URL_QUERY), $query);
|
||||
|
||||
$this->assertSame(['type' => 'page', 'id' => $page->id, 'locale' => 'es'], PreviewLink::open($query['preview']));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function comments_join_the_thread(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$page = Page::factory()->create();
|
||||
|
||||
$this->postJson("/api/v1/pages/{$page->id}/comments", ['body' => '¿Revisamos el título?'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.event', 'commented');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user