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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user