feat(api): mobile API Milestone 3 — full PULL (delta, tombstones, templates)

ProjectApiController bundle now supports incremental sync:
- ?since=<ISO8601> returns only records changed after that time (phases, layers,
  features, inspections, issues, templates), each filtered by its own updated_at.
- 'deleted' tombstones (soft-deleted ids since 'since') for phases/layers/
  features/inspections/issues so the device can purge locally.
- Bundle now also includes inspections, issues and inspection templates
  (with version + content hash for incremental template download).
- New GET /api/v1/templates (accessible projects, ?since= delta).
Tests: 12 passing (added delta, tombstones, templates cases). Note: the 'since'
query param must be URL-encoded by clients (ISO8601 '+' offset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 09:45:18 +02:00
parent 17a824f925
commit b5deb1c53a
3 changed files with 227 additions and 47 deletions
+91 -2
View File
@@ -2,11 +2,15 @@
namespace Tests\Feature\Api;
use App\Models\Feature;
use App\Models\InspectionTemplate;
use App\Models\Layer;
use App\Models\Phase;
use App\Models\Project;
use App\Models\ProgressUpdate;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Spatie\Permission\Models\Permission;
@@ -44,17 +48,39 @@ class MobileApiTest extends TestCase
return $project;
}
private function makePhase(Project $project): Phase
private function makePhase(Project $project, string $name = 'Fase 1'): Phase
{
return Phase::create([
'project_id' => $project->id,
'name' => 'Fase 1',
'name' => $name,
'order' => 1,
'color' => '#3b82f6',
'progress_percent' => 0,
]);
}
private function makeLayer(Phase $phase): Layer
{
return Layer::create([
'project_id' => $phase->project_id,
'phase_id' => $phase->id,
'name' => 'Capa 1',
'color' => '#10b981',
'uploaded_by' => $phase->project->created_by,
]);
}
private function makeFeature(Layer $layer): Feature
{
return Feature::create([
'layer_id' => $layer->id,
'name' => 'Elemento 1',
'geometry' => ['type' => 'Point', 'coordinates' => [-3.0, 40.0]],
'progress' => 0,
'status' => 'planned',
]);
}
// ── Auth ───────────────────────────────────────────────────────────────────
public function test_login_returns_a_token(): void
@@ -188,4 +214,67 @@ class MobileApiTest extends TestCase
$this->assertDatabaseCount('progress_updates', 0);
}
// ── Pull: delta + tombstones + templates (Milestone 3) ───────────────────────
public function test_bundle_delta_returns_only_changed_records(): void
{
$user = User::factory()->create();
Carbon::setTestNow('2026-06-18 10:00:00');
$project = $this->makeProject($user);
$old = $this->makePhase($project, 'Fase vieja');
$since = Carbon::parse('2026-06-18 10:00:30');
Carbon::setTestNow('2026-06-18 10:05:00');
$new = $this->makePhase($project, 'Fase nueva');
Sanctum::actingAs($user, ['mobile-sync']);
$res = $this->getJson("/api/v1/projects/{$project->id}/bundle?since=" . urlencode($since->toIso8601String()))->assertOk();
$ids = collect($res->json('phases'))->pluck('id');
$this->assertTrue($ids->contains($new->id));
$this->assertFalse($ids->contains($old->id));
Carbon::setTestNow();
}
public function test_bundle_delta_includes_tombstones_for_deletions(): void
{
$user = User::factory()->create();
Carbon::setTestNow('2026-06-18 10:00:00');
$project = $this->makeProject($user);
$feature = $this->makeFeature($this->makeLayer($this->makePhase($project)));
$since = Carbon::parse('2026-06-18 10:00:30');
Carbon::setTestNow('2026-06-18 10:05:00');
$feature->delete(); // soft delete
Sanctum::actingAs($user, ['mobile-sync']);
$res = $this->getJson("/api/v1/projects/{$project->id}/bundle?since=" . urlencode($since->toIso8601String()))->assertOk();
$this->assertContains($feature->id, $res->json('deleted.features'));
Carbon::setTestNow();
}
public function test_templates_endpoint_returns_accessible_templates_with_hash(): void
{
$user = User::factory()->create();
$project = $this->makeProject($user);
InspectionTemplate::create([
'project_id' => $project->id,
'name' => 'Plantilla A',
'fields' => [['name' => 'ok', 'label' => 'OK', 'type' => 'boolean']],
]);
Sanctum::actingAs($user, ['mobile-sync']);
$res = $this->getJson('/api/v1/templates')->assertOk();
$this->assertTrue(collect($res->json('templates'))->pluck('name')->contains('Plantilla A'));
$this->assertNotNull($res->json('templates.0.hash'));
}
}