Files
landing-api/tests/Feature/Api/BatchApiTest.php
Josep Piguillem 0119004f2c Run batches and import JSON or CSV through the API
Each operation is dispatched as a request of its own, so a batch is held to
the same validation and permissions as the calls it contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:58:36 +02:00

182 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Member;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Batches and imports: several admin calls in one, each still validated and
* authorised on its own.
*/
class BatchApiTest 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_batch_runs_each_operation_and_reports_each_answer(): void
{
$this->actingWithRole('editor');
$member = Member::factory()->create();
$this->postJson('/api/v1/batch', [
'operations' => [
['method' => 'POST', 'path' => '/v1/technologies', 'body' => ['name' => 'Vue', 'slug' => 'vue']],
['method' => 'PATCH', 'path' => "/v1/members/{$member->id}", 'body' => ['github_username' => 'ada']],
['method' => 'POST', 'path' => '/v1/technologies', 'body' => ['name' => '']],
['method' => 'GET', 'path' => '/v1/me'],
],
])
->assertOk()
->assertJsonPath('data.0.status', 201)
->assertJsonPath('data.0.body.data.slug', 'vue')
->assertJsonPath('data.1.status', 200)
->assertJsonPath('data.2.status', 422)
->assertJsonPath('data.3.status', 200)
->assertJsonPath('meta.committed', true)
->assertJsonPath('meta.failed', 1);
$this->assertSame('ada', $member->fresh()->github_username);
$this->assertTrue(Technology::query()->where('slug', 'vue')->exists());
}
#[Test]
public function an_atomic_batch_keeps_nothing_when_one_fails(): void
{
$this->actingWithRole('editor');
$this->postJson('/api/v1/batch', [
'atomic' => true,
'operations' => [
['method' => 'POST', 'path' => '/v1/technologies', 'body' => ['name' => 'Vue', 'slug' => 'vue']],
['method' => 'POST', 'path' => '/v1/technologies', 'body' => ['name' => '']],
['method' => 'POST', 'path' => '/v1/technologies', 'body' => ['name' => 'Never', 'slug' => 'never']],
],
])
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('meta.committed', false);
$this->assertSame(0, Technology::query()->count());
}
#[Test]
public function a_batch_cannot_do_what_its_caller_cannot(): void
{
$this->actingWithRole('viewer');
$this->postJson('/api/v1/batch', [
'operations' => [['method' => 'POST', 'path' => '/v1/technologies', 'body' => ['name' => 'Vue', 'slug' => 'vue']]],
])->assertJsonPath('data.0.status', 403);
$this->assertSame(0, Technology::query()->count());
}
#[Test]
public function batches_do_not_nest_or_leave_the_admin_api(): void
{
$this->actingWithRole('editor');
foreach (['/v1/batch', '/v1/import/pages', '/v1/auth/token', '/public/v1/site', '/v1/../public/v1/site'] as $path) {
$this->postJson('/api/v1/batch', ['operations' => [['method' => 'POST', 'path' => $path]]])
->assertUnprocessable()
->assertJsonValidationErrors('operations.0.path');
}
}
#[Test]
public function an_import_creates_new_rows_and_updates_matching_ones(): void
{
$this->actingWithRole('editor');
Technology::factory()->create(['name' => 'Angular', 'slug' => 'angular', 'website' => null]);
$this->postJson('/api/v1/import/technologies', [
'match' => 'slug',
'rows' => [
['name' => 'Angular', 'slug' => 'angular', 'website' => 'https://angular.dev'],
['name' => 'Svelte', 'slug' => 'svelte', 'description' => ['es' => 'Compilador', 'en' => 'Compiler']],
],
])
->assertOk()
->assertJsonPath('data.0.method', 'PATCH')
->assertJsonPath('data.1.method', 'POST')
->assertJsonPath('meta.succeeded', 2);
$this->assertSame('https://angular.dev', Technology::query()->where('slug', 'angular')->value('website'));
$this->assertSame(2, Technology::query()->count());
}
#[Test]
public function a_csv_import_reads_languages_lists_and_booleans(): void
{
$this->actingWithRole('editor');
[$vue, $php] = Technology::factory()->count(2)->create();
$csv = "\xEF\xBB\xBFname,slug,headline.es,headline.en,technology_ids,is_public,github_username\n"
."Ada Lovelace,ada,Backend,Backend dev,{$vue->id}|{$php->id},true,\n"
."Linus,linus,Sistemas,,,false,linus\n";
$this->post('/api/v1/import/members', [
'file' => UploadedFile::fake()->createWithContent('members.csv', $csv),
], ['Accept' => 'application/json'])
->assertOk()
->assertJsonPath('meta.failed', 0);
$ada = Member::query()->where('slug', 'ada')->sole();
$this->assertSame('Backend dev', $ada->getTranslation('headline', 'en'));
$this->assertCount(2, $ada->technologies);
$this->assertTrue($ada->is_public);
$this->assertFalse(Member::query()->where('slug', 'linus')->sole()->is_public);
}
#[Test]
public function a_dry_run_validates_everything_and_keeps_nothing(): void
{
$this->actingWithRole('editor');
$this->postJson('/api/v1/import/technologies', [
'dry_run' => true,
'rows' => [
['name' => 'Vue', 'slug' => 'vue'],
['name' => 'Svelte', 'slug' => 'svelte'],
],
])
->assertOk()
->assertJsonPath('meta.dry_run', true)
->assertJsonPath('meta.committed', false)
->assertJsonPath('meta.succeeded', 2);
$this->assertSame(0, Technology::query()->count());
}
}