Manage projects, members and services through the API
/api/v1/projects, /members and /services: create, list, show, update, delete and restore, with the panel's own rules — so the MCP server can load the collective's work, people and offer without anyone retyping them into forms. The MCP server picks the tools up from openapi.yaml. - Relations travel as ids and are replaced whole when sent, left alone when not: a project's technology_ids, service_ids and members with each one's role; a member's technology_ids; a service's technology_ids and project_ids. Record and relations are saved in one transaction. - Publishing stays a permission of its own. Without projects.publish or services.publish, published_at, unpublish_at and is_published are refused — the fields the panel disables for the same people. - MemberPolicy still lets someone edit their own profile. - Uploads accept `for: projects` and `for: members`, landing where the panel's own upload fields put them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\Technology;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
@@ -66,6 +69,9 @@ class AdminApiContractTest extends TestCase
|
||||
'categories' => ['path' => '/v1/categories'],
|
||||
'users' => ['path' => '/v1/users'],
|
||||
'technologies' => ['path' => '/v1/technologies'],
|
||||
'projects' => ['path' => '/v1/projects'],
|
||||
'members' => ['path' => '/v1/members'],
|
||||
'services' => ['path' => '/v1/services'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -75,6 +81,9 @@ class AdminApiContractTest extends TestCase
|
||||
$page = Page::factory()->create();
|
||||
$category = Category::factory()->create();
|
||||
$technology = Technology::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$member = Member::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
|
||||
// One request, one documented path — the URL a real caller hits and
|
||||
// the path key that describes it, side by side rather than derived
|
||||
@@ -87,6 +96,12 @@ class AdminApiContractTest extends TestCase
|
||||
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
|
||||
'technologies' => ['/v1/technologies', '/v1/technologies'],
|
||||
'technology' => ["/v1/technologies/{$technology->id}", '/v1/technologies/{technology}'],
|
||||
'projects' => ['/v1/projects', '/v1/projects'],
|
||||
'project' => ["/v1/projects/{$project->id}", '/v1/projects/{project}'],
|
||||
'members' => ['/v1/members', '/v1/members'],
|
||||
'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'],
|
||||
'services' => ['/v1/services', '/v1/services'],
|
||||
'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'],
|
||||
'users' => ['/v1/users', '/v1/users'],
|
||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||
'me' => ['/v1/me', '/v1/me'],
|
||||
|
||||
257
tests/Feature/Api/CollectiveApiTest.php
Normal file
257
tests/Feature/Api/CollectiveApiTest.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\ProjectStatus;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\Technology;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Projects, members and services through the admin API: what the MCP server
|
||||
* uses to load the collective's work without anyone retyping it into forms.
|
||||
*/
|
||||
class CollectiveApiTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
protected function actingWithRole(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles([$role]);
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($user));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_project_arrives_with_its_team_stack_client_and_services(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
[$ada, $linus] = Member::factory()->count(2)->create();
|
||||
$technologies = Technology::factory()->count(2)->create();
|
||||
$client = Client::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
|
||||
$response = $this->postJson('/api/v1/projects?lang=es&with=translations', [
|
||||
'title' => ['es' => 'Catálogo abierto', 'en' => 'Open catalogue'],
|
||||
'slug' => ['es' => 'catalogo', 'en' => 'catalogue'],
|
||||
'tagline' => ['es' => 'Una herramienta pequeña.'],
|
||||
'status' => 'active',
|
||||
'client_id' => $client->id,
|
||||
'lead_member_id' => $ada->id,
|
||||
'technology_ids' => $technologies->pluck('id')->all(),
|
||||
'service_ids' => [$service->id],
|
||||
'members' => [['id' => $ada->id, 'role' => 'Responsable'], ['id' => $linus->id]],
|
||||
])->assertCreated();
|
||||
|
||||
$response
|
||||
->assertJsonPath('data.title', 'Catálogo abierto')
|
||||
->assertJsonPath('data.translations.title.en', 'Open catalogue')
|
||||
->assertJsonPath('data.status', 'active')
|
||||
->assertJsonPath('data.client_id', $client->id)
|
||||
->assertJsonCount(2, 'data.technology_ids')
|
||||
->assertJsonPath('data.members.0.role', 'Responsable');
|
||||
|
||||
$project = Project::query()->sole();
|
||||
|
||||
$this->assertSame('Responsable', $project->members()->find($ada->id)->pivot->role);
|
||||
$this->assertNull($project->members()->find($linus->id)->pivot->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_partial_update_leaves_relations_alone_unless_they_are_sent(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$project = Project::factory()->create();
|
||||
$technology = Technology::factory()->create();
|
||||
$project->technologies()->attach($technology);
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.technology_ids', [$technology->id]);
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", ['technology_ids' => []])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.technology_ids', []);
|
||||
|
||||
$this->assertSame(ProjectStatus::Paused, $project->fresh()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function writing_a_project_is_not_publishing_it(): void
|
||||
{
|
||||
// A writer authors projects but may not put them on the site.
|
||||
$this->actingWithRole('writer');
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Borrador'],
|
||||
'slug' => ['es' => 'borrador'],
|
||||
'published_at' => now()->toIso8601String(),
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('published_at');
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Borrador'],
|
||||
'slug' => ['es' => 'borrador'],
|
||||
])->assertCreated();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_editor_may_publish_a_project(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Publicado'],
|
||||
'slug' => ['es' => 'publicado'],
|
||||
'published_at' => '2026-09-01T10:00:00Z',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.published_at', '2026-09-01T10:00:00+00:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_deleted_project_can_be_brought_back(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->deleteJson("/api/v1/projects/{$project->id}")->assertNoContent();
|
||||
$this->assertSoftDeleted($project);
|
||||
|
||||
$this->getJson('/api/v1/projects?trashed=1')->assertJsonCount(1, 'data');
|
||||
|
||||
$this->postJson("/api/v1/projects/{$project->id}/restore")->assertOk();
|
||||
$this->assertNotSoftDeleted($project);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_member_arrives_with_links_and_technologies(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$technology = Technology::factory()->create();
|
||||
|
||||
$this->postJson('/api/v1/members?lang=es', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'slug' => 'ada',
|
||||
'headline' => ['es' => 'Backend', 'en' => 'Backend'],
|
||||
'github_username' => 'ada',
|
||||
'links' => ['web' => 'https://ada.example.org'],
|
||||
'is_founder' => true,
|
||||
'joined_at' => '2024-09-01',
|
||||
'technology_ids' => [$technology->id],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.headline', 'Backend')
|
||||
->assertJsonPath('data.links.web', 'https://ada.example.org')
|
||||
->assertJsonPath('data.joined_at', '2024-09-01')
|
||||
->assertJsonPath('data.technology_ids', [$technology->id]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function member_slugs_and_links_are_checked(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
Member::factory()->create(['slug' => 'ada']);
|
||||
|
||||
$this->postJson('/api/v1/members', ['name' => 'Ada', 'slug' => 'ada'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('slug');
|
||||
|
||||
$this->postJson('/api/v1/members', ['name' => 'Bob', 'slug' => 'bob', 'links' => ['web' => 'not a url']])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('links.web');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function someone_may_edit_their_own_profile_without_members_update(): void
|
||||
{
|
||||
$viewer = $this->actingWithRole('viewer');
|
||||
|
||||
$own = Member::factory()->create(['user_id' => $viewer->id]);
|
||||
$someoneElse = Member::factory()->create();
|
||||
|
||||
$this->patchJson("/api/v1/members/{$own->id}", ['github_username' => 'me'])->assertOk();
|
||||
$this->patchJson("/api/v1/members/{$someoneElse->id}", ['github_username' => 'them'])->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_service_links_to_the_work_behind_it(): void
|
||||
{
|
||||
$this->actingWithRole('editor');
|
||||
|
||||
$project = Project::factory()->create();
|
||||
$technology = Technology::factory()->create();
|
||||
|
||||
$this->postJson('/api/v1/services?lang=es', [
|
||||
'title' => ['es' => 'Formación'],
|
||||
'slug' => ['es' => 'formacion'],
|
||||
'tagline' => ['es' => 'Talleres prácticos.'],
|
||||
'icon' => 'support',
|
||||
'is_published' => true,
|
||||
'project_ids' => [$project->id],
|
||||
'technology_ids' => [$technology->id],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.title', 'Formación')
|
||||
->assertJsonPath('data.is_published', true)
|
||||
->assertJsonPath('data.project_ids', [$project->id]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function writing_a_service_is_not_publishing_it(): void
|
||||
{
|
||||
$this->actingWithRole('writer');
|
||||
|
||||
$service = Service::factory()->create(['is_published' => false]);
|
||||
|
||||
$this->patchJson("/api/v1/services/{$service->id}", ['is_published' => true])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('is_published');
|
||||
|
||||
$this->patchJson("/api/v1/services/{$service->id}", ['tagline' => ['es' => 'Nuevo texto']])
|
||||
->assertOk();
|
||||
|
||||
$this->assertFalse($service->fresh()->is_published);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_reader_can_list_but_not_write(): void
|
||||
{
|
||||
$this->actingWithRole('viewer');
|
||||
|
||||
Project::factory()->create();
|
||||
|
||||
$this->getJson('/api/v1/projects')->assertOk()->assertJsonCount(1, 'data');
|
||||
$this->getJson('/api/v1/services')->assertOk();
|
||||
$this->getJson('/api/v1/members')->assertOk();
|
||||
|
||||
$this->postJson('/api/v1/projects', ['title' => ['es' => 'X'], 'slug' => ['es' => 'x']])->assertForbidden();
|
||||
$this->postJson('/api/v1/services', ['title' => ['es' => 'X'], 'slug' => ['es' => 'x']])->assertForbidden();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user