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:
2026-06-18 10:09:34 +02:00
parent b5deb1c53a
commit 9d2b63c8f4
6 changed files with 371 additions and 33 deletions
+96 -2
View File
@@ -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
}
}