Files
landing-api/tests/Feature/Api/CollectiveApiTest.php
Josep Piguillem 82ef2b38dd
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
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>
2026-09-29 16:07:20 +02:00

258 lines
8.8 KiB
PHP

<?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();
}
}