feat(api): mobile API Milestone 4 — full PUSH (inspections/issues/features + conflicts)
SyncController now handles the full mutation vocabulary: - inspection.create (idempotent by uuid; project/layer derived from feature; authz member + 'create inspections'; status defaults to completed). - issue.create (idempotent; authz member + 'create issues'). - issue.update (by server id; authz member + 'edit issues'; sets resolved_at when resolved/closed; last-write-wins conflict). - feature.update (by server id; authz member + 'update progress'; recomputes phase progress; last-write-wins conflict). - Conflict detection: client_updated_at vs server updated_at → returns 'conflict' with the current server value. Added uuid + client_updated_at to features/inspections/issues (guarded migration) and their fillables. Tests: 16 passing (added inspection/issue/feature + conflict). Note: 2 PRE-EXISTING test failures remain (not from this work, sqlite-only): ExampleTest expects '/'=200 (app redirects), and the dashboard route uses MySQL FIELD() which sqlite lacks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Feature;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Issue;
|
||||
use App\Models\Layer;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Project;
|
||||
@@ -23,8 +25,9 @@ class MobileApiTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Permission::findOrCreate('update progress');
|
||||
Permission::findOrCreate('manage all');
|
||||
foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues'] as $p) {
|
||||
Permission::findOrCreate($p);
|
||||
}
|
||||
}
|
||||
|
||||
private function makeProject(?User $member = null): Project
|
||||
@@ -277,4 +280,95 @@ class MobileApiTest extends TestCase
|
||||
$this->assertTrue(collect($res->json('templates'))->pluck('name')->contains('Plantilla A'));
|
||||
$this->assertNotNull($res->json('templates.0.hash'));
|
||||
}
|
||||
|
||||
// ── Push: inspections / issues / features (Milestone 4) ──────────────────────
|
||||
|
||||
public function test_sync_creates_inspection_and_is_idempotent(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('create inspections');
|
||||
$project = $this->makeProject($user);
|
||||
$feature = $this->makeFeature($this->makeLayer($this->makePhase($project)));
|
||||
$uuid = (string) Str::uuid();
|
||||
|
||||
Sanctum::actingAs($user, ['mobile-sync']);
|
||||
$payload = ['operations' => [[
|
||||
'entity' => 'inspection', 'op' => 'create', 'uuid' => $uuid,
|
||||
'data' => ['feature_id' => $feature->id, 'data' => ['ok' => true], 'result' => 'pass'],
|
||||
]]];
|
||||
|
||||
$this->postJson('/api/v1/sync', $payload)->assertOk()->assertJsonPath('results.0.status', 'applied');
|
||||
$this->assertDatabaseHas('inspections', ['uuid' => $uuid, 'feature_id' => $feature->id, 'user_id' => $user->id]);
|
||||
|
||||
$this->postJson('/api/v1/sync', $payload)->assertOk()->assertJsonPath('results.0.status', 'duplicate');
|
||||
$this->assertEquals(1, Inspection::where('uuid', $uuid)->count());
|
||||
}
|
||||
|
||||
public function test_sync_creates_and_updates_an_issue(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo(['create issues', 'edit issues']);
|
||||
$project = $this->makeProject($user);
|
||||
|
||||
Sanctum::actingAs($user, ['mobile-sync']);
|
||||
|
||||
// create
|
||||
$uuid = (string) Str::uuid();
|
||||
$this->postJson('/api/v1/sync', ['operations' => [[
|
||||
'entity' => 'issue', 'op' => 'create', 'uuid' => $uuid,
|
||||
'data' => ['project_id' => $project->id, 'title' => 'Grieta', 'priority' => 'high'],
|
||||
]]])->assertOk()->assertJsonPath('results.0.status', 'applied');
|
||||
|
||||
$issue = Issue::where('uuid', $uuid)->firstOrFail();
|
||||
$this->assertEquals('open', $issue->status);
|
||||
|
||||
// update (resolve) → resolved_at set
|
||||
$this->postJson('/api/v1/sync', ['operations' => [[
|
||||
'entity' => 'issue', 'op' => 'update', 'uuid' => (string) Str::uuid(),
|
||||
'data' => ['id' => $issue->id, 'status' => 'resolved', 'resolution_notes' => 'Sellada'],
|
||||
]]])->assertOk()->assertJsonPath('results.0.status', 'applied');
|
||||
|
||||
$this->assertEquals('resolved', $issue->fresh()->status);
|
||||
$this->assertNotNull($issue->fresh()->resolved_at);
|
||||
}
|
||||
|
||||
public function test_sync_updates_feature_and_recomputes_phase_progress(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('update progress');
|
||||
$project = $this->makeProject($user);
|
||||
$phase = $this->makePhase($project);
|
||||
$feature = $this->makeFeature($this->makeLayer($phase));
|
||||
|
||||
Sanctum::actingAs($user, ['mobile-sync']);
|
||||
$this->postJson('/api/v1/sync', ['operations' => [[
|
||||
'entity' => 'feature', 'op' => 'update', 'uuid' => (string) Str::uuid(),
|
||||
'data' => ['id' => $feature->id, 'status' => 'completed', 'progress' => 100],
|
||||
]]])->assertOk()->assertJsonPath('results.0.status', 'applied');
|
||||
|
||||
$this->assertEquals(100, $feature->fresh()->progress);
|
||||
$this->assertEquals(100, $phase->fresh()->progress_percent);
|
||||
}
|
||||
|
||||
public function test_sync_returns_conflict_when_server_is_newer(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo('update progress');
|
||||
$project = $this->makeProject($user);
|
||||
|
||||
Carbon::setTestNow('2026-06-18 12:00:00');
|
||||
$feature = $this->makeFeature($this->makeLayer($this->makePhase($project)));
|
||||
Carbon::setTestNow(); // feature->updated_at = 12:00
|
||||
|
||||
Sanctum::actingAs($user, ['mobile-sync']);
|
||||
// client edited BEFORE the server's last update → conflict
|
||||
$res = $this->postJson('/api/v1/sync', ['operations' => [[
|
||||
'entity' => 'feature', 'op' => 'update', 'uuid' => (string) Str::uuid(),
|
||||
'client_updated_at' => '2026-06-18 11:00:00',
|
||||
'data' => ['id' => $feature->id, 'progress' => 50],
|
||||
]]])->assertOk();
|
||||
|
||||
$res->assertJsonPath('results.0.status', 'conflict');
|
||||
$this->assertEquals(0, $feature->fresh()->progress); // unchanged
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user