feat(api): mobile API Milestones 5+6 — media upload, sync_logs idempotency, OpenAPI

Milestone 5 (media):
- POST /api/v1/media — multipart upload, attaches to feature/issue/project/
  phase/layer, idempotent by uuid, authz member + 'upload media'. Added
  uuid+client_updated_at to media.
- Bundle now includes a 'media' array (URLs) for the project's project/feature/
  issue attachments (delta-aware).

Milestone 6 (hardening + docs):
- sync_logs table/model: every applied op is logged; /sync short-circuits on a
  repeated op uuid -> 'duplicate' (true idempotency for updates too, not just
  creates).
- Rate limiting on login (10/min), sync (60/min), media (120/min).
- docs/openapi.yaml: OpenAPI 3 contract for the mobile team.

Tests: 18 passing (added media upload idempotency + sync_logs idempotency).
The mobile API (Milestones 1-6) is now feature-complete on the webapp side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 10:23:50 +02:00
parent 9d2b63c8f4
commit 14758136b6
10 changed files with 490 additions and 3 deletions
+58 -1
View File
@@ -11,8 +11,11 @@ use App\Models\Phase;
use App\Models\Project;
use App\Models\ProgressUpdate;
use App\Models\User;
use App\Models\SyncLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Spatie\Permission\Models\Permission;
@@ -25,7 +28,7 @@ class MobileApiTest extends TestCase
protected function setUp(): void
{
parent::setUp();
foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues'] as $p) {
foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues', 'upload media'] as $p) {
Permission::findOrCreate($p);
}
}
@@ -371,4 +374,58 @@ class MobileApiTest extends TestCase
$res->assertJsonPath('results.0.status', 'conflict');
$this->assertEquals(0, $feature->fresh()->progress); // unchanged
}
// ── Media (Milestone 5) + op idempotency via sync_logs (Milestone 6) ─────────
public function test_media_upload_attaches_to_feature_and_is_idempotent(): void
{
Storage::fake('public');
$user = User::factory()->create();
$user->givePermissionTo('upload media');
$project = $this->makeProject($user);
$feature = $this->makeFeature($this->makeLayer($this->makePhase($project)));
$uuid = (string) Str::uuid();
Sanctum::actingAs($user, ['mobile-sync']);
$payload = [
'uuid' => $uuid, 'parent_entity' => 'feature', 'parent_id' => $feature->id,
'file' => UploadedFile::fake()->image('foto.jpg'),
];
$this->post('/api/v1/media', $payload)->assertOk()->assertJsonPath('status', 'applied');
$this->assertDatabaseHas('media', [
'uuid' => $uuid, 'mediable_type' => Feature::class, 'mediable_id' => $feature->id,
]);
// Re-send same uuid → duplicate, no second row
$this->post('/api/v1/media', [
'uuid' => $uuid, 'parent_entity' => 'feature', 'parent_id' => $feature->id,
'file' => UploadedFile::fake()->image('foto.jpg'),
])->assertOk()->assertJsonPath('status', 'duplicate');
$this->assertEquals(1, \App\Models\Media::where('uuid', $uuid)->count());
}
public function test_sync_operation_is_idempotent_via_sync_logs(): void
{
$user = User::factory()->create();
$user->givePermissionTo('update progress');
$project = $this->makeProject($user);
$feature = $this->makeFeature($this->makeLayer($this->makePhase($project)));
$opUuid = (string) Str::uuid();
Sanctum::actingAs($user, ['mobile-sync']);
$op = ['operations' => [[
'entity' => 'feature', 'op' => 'update', 'uuid' => $opUuid,
'data' => ['id' => $feature->id, 'progress' => 60],
]]];
$this->postJson('/api/v1/sync', $op)->assertOk()->assertJsonPath('results.0.status', 'applied');
$this->assertEquals(1, SyncLog::where('op_uuid', $opUuid)->count());
// Replaying the same operation uuid → duplicate (served from sync_logs)
$this->postJson('/api/v1/sync', $op)->assertOk()->assertJsonPath('results.0.status', 'duplicate');
$this->assertEquals(1, SyncLog::where('op_uuid', $opUuid)->count());
}
}