'TEST-1', 'name' => 'Proyecto Test', 'address' => 'Calle Falsa 123', 'lat' => 40.0, 'lng' => -3.0, 'start_date' => now()->toDateString(), 'end_date_estimated' => now()->addMonths(6)->toDateString(), 'status' => 'in_progress', 'created_by' => $member?->id ?? User::factory()->create()->id, ]); if ($member) { $project->users()->attach($member->id, ['role_in_project' => 'supervisor']); } return $project; } private function makePhase(Project $project, string $name = 'Fase 1'): Phase { return Phase::create([ 'project_id' => $project->id, '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 { $user = User::factory()->create(); $res = $this->postJson('/api/v1/login', [ 'email' => $user->email, 'password' => 'password', 'device_name' => 'Pixel-Test', ]); $res->assertOk()->assertJsonStructure(['token', 'user' => ['id', 'email', 'permissions']]); $this->assertDatabaseHas('devices', ['user_id' => $user->id, 'name' => 'Pixel-Test']); } public function test_login_fails_with_wrong_password(): void { $user = User::factory()->create(); $this->postJson('/api/v1/login', [ 'email' => $user->email, 'password' => 'incorrecta', 'device_name' => 'Pixel-Test', ])->assertStatus(422); } public function test_me_requires_authentication(): void { $this->getJson('/api/v1/me')->assertStatus(401); } public function test_me_returns_user_with_token(): void { $user = User::factory()->create(); Sanctum::actingAs($user, ['mobile-sync']); $this->getJson('/api/v1/me') ->assertOk() ->assertJsonPath('user.id', $user->id); } // ── Pull ───────────────────────────────────────────────────────────────────── public function test_projects_index_only_returns_accessible_projects(): void { $user = User::factory()->create(); $mine = $this->makeProject($user); $other = $this->makeProject(); // not a member Sanctum::actingAs($user, ['mobile-sync']); $res = $this->getJson('/api/v1/projects')->assertOk(); $ids = collect($res->json('projects'))->pluck('id'); $this->assertTrue($ids->contains($mine->id)); $this->assertFalse($ids->contains($other->id)); } public function test_bundle_is_forbidden_for_non_member(): void { $user = User::factory()->create(); $project = $this->makeProject(); // user is not a member Sanctum::actingAs($user, ['mobile-sync']); $this->getJson("/api/v1/projects/{$project->id}/bundle")->assertStatus(403); } public function test_bundle_returns_structure_for_member(): void { $user = User::factory()->create(); $project = $this->makeProject($user); $phase = $this->makePhase($project); Sanctum::actingAs($user, ['mobile-sync']); $this->getJson("/api/v1/projects/{$project->id}/bundle") ->assertOk() ->assertJsonPath('project.id', $project->id) ->assertJsonPath('phases.0.id', $phase->id); } // ── Push (sync) ────────────────────────────────────────────────────────────── public function test_sync_creates_progress_update_and_is_idempotent(): void { $user = User::factory()->create(); $user->givePermissionTo('update progress'); $project = $this->makeProject($user); $phase = $this->makePhase($project); $uuid = (string) Str::uuid(); Sanctum::actingAs($user, ['mobile-sync']); $payload = ['operations' => [[ 'entity' => 'progress_update', 'op' => 'create', 'uuid' => $uuid, 'client_updated_at' => now()->toIso8601String(), 'data' => ['phase_id' => $phase->id, 'progress' => 75, 'comment' => 'Avance'], ]]]; // First push → applied $this->postJson('/api/v1/sync', $payload) ->assertOk() ->assertJsonPath('results.0.status', 'applied'); $this->assertDatabaseHas('progress_updates', ['uuid' => $uuid, 'progress_percent' => 75, 'user_id' => $user->id]); $this->assertEquals(75, $phase->fresh()->progress_percent); // Re-send same uuid → duplicate (no second row) $this->postJson('/api/v1/sync', $payload) ->assertOk() ->assertJsonPath('results.0.status', 'duplicate'); $this->assertEquals(1, ProgressUpdate::where('uuid', $uuid)->count()); } public function test_sync_returns_error_without_permission(): void { $user = User::factory()->create(); // member but WITHOUT 'update progress' $project = $this->makeProject($user); $phase = $this->makePhase($project); Sanctum::actingAs($user, ['mobile-sync']); $this->postJson('/api/v1/sync', ['operations' => [[ 'entity' => 'progress_update', 'op' => 'create', 'uuid' => (string) Str::uuid(), 'data' => ['phase_id' => $phase->id, 'progress' => 50], ]]]) ->assertOk() ->assertJsonPath('results.0.status', 'error'); $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')); } // ── 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', 'type' => 'safety'], ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); $issue = Issue::where('uuid', $uuid)->firstOrFail(); $this->assertEquals('open', $issue->status); $this->assertEquals('safety', $issue->type); // 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 } // ── 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()); } // ── Issue tasks / comments (enriquecimiento incidencias) ───────────────────── private function makeIssue(Project $project, ?User $reporter = null): Issue { return Issue::create([ 'project_id' => $project->id, 'title' => 'Incidencia test', 'status' => 'open', 'priority' => 'medium', 'reported_by' => $reporter?->id ?? $project->created_by, ]); } public function test_sync_creates_and_updates_an_issue_task(): void { $user = User::factory()->create(); $user->givePermissionTo('edit issues'); $project = $this->makeProject($user); $issue = $this->makeIssue($project, $user); Sanctum::actingAs($user, ['mobile-sync']); // create task $uuid = (string) Str::uuid(); $this->postJson('/api/v1/sync', ['operations' => [[ 'entity' => 'issue_task', 'op' => 'create', 'uuid' => $uuid, 'data' => ['issue_id' => $issue->id, 'title' => 'Sanear zona'], ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); $task = IssueTask::where('uuid', $uuid)->firstOrFail(); $this->assertFalse($task->is_done); // mark done via update $this->postJson('/api/v1/sync', ['operations' => [[ 'entity' => 'issue_task', 'op' => 'update', 'uuid' => (string) Str::uuid(), 'data' => ['id' => $task->id, 'is_done' => true], ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); $task->refresh(); $this->assertTrue($task->is_done); $this->assertEquals($user->id, $task->done_by); $this->assertNotNull($task->done_at); } public function test_sync_issue_task_is_forbidden_without_edit_permission(): void { $user = User::factory()->create(); // member but no 'edit issues' $project = $this->makeProject($user); $issue = $this->makeIssue($project, $user); Sanctum::actingAs($user, ['mobile-sync']); $this->postJson('/api/v1/sync', ['operations' => [[ 'entity' => 'issue_task', 'op' => 'create', 'uuid' => (string) Str::uuid(), 'data' => ['issue_id' => $issue->id, 'title' => 'X'], ]]])->assertOk()->assertJsonPath('results.0.status', 'error'); $this->assertDatabaseCount('issue_tasks', 0); } public function test_sync_creates_an_issue_comment(): void { $user = User::factory()->create(); $user->givePermissionTo('view issues'); $project = $this->makeProject($user); $issue = $this->makeIssue($project, $user); Sanctum::actingAs($user, ['mobile-sync']); $uuid = (string) Str::uuid(); $this->postJson('/api/v1/sync', ['operations' => [[ 'entity' => 'issue_comment', 'op' => 'create', 'uuid' => $uuid, 'data' => ['issue_id' => $issue->id, 'body' => 'Revisado en obra'], ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); $this->assertDatabaseHas('issue_comments', [ 'uuid' => $uuid, 'issue_id' => $issue->id, 'user_id' => $user->id, 'body' => 'Revisado en obra', ]); } public function test_media_uploads_to_issue_task_and_comment(): void { Storage::fake('public'); $user = User::factory()->create(); $user->givePermissionTo('upload media'); $project = $this->makeProject($user); $issue = $this->makeIssue($project, $user); $task = $issue->tasks()->create(['title' => 'T', 'uuid' => (string) Str::uuid()]); $comment = $issue->comments()->create(['user_id' => $user->id, 'body' => 'c', 'uuid' => (string) Str::uuid()]); Sanctum::actingAs($user, ['mobile-sync']); $this->post('/api/v1/media', [ 'uuid' => (string) Str::uuid(), 'parent_entity' => 'issue_task', 'parent_id' => $task->id, 'file' => UploadedFile::fake()->image('a.jpg'), ])->assertOk()->assertJsonPath('status', 'applied'); $this->post('/api/v1/media', [ 'uuid' => (string) Str::uuid(), 'parent_entity' => 'issue_comment', 'parent_id' => $comment->id, 'file' => UploadedFile::fake()->image('b.jpg'), ])->assertOk()->assertJsonPath('status', 'applied'); $this->assertDatabaseHas('media', ['mediable_type' => IssueTask::class, 'mediable_id' => $task->id]); $this->assertDatabaseHas('media', ['mediable_type' => IssueComment::class, 'mediable_id' => $comment->id]); } public function test_bundle_includes_issue_tasks_and_comments(): void { $user = User::factory()->create(); $project = $this->makeProject($user); $issue = $this->makeIssue($project, $user); $task = $issue->tasks()->create(['title' => 'Tarea bundle', 'uuid' => (string) Str::uuid()]); $comment = $issue->comments()->create(['user_id' => $user->id, 'body' => 'Comentario bundle', 'uuid' => (string) Str::uuid()]); Sanctum::actingAs($user, ['mobile-sync']); $res = $this->getJson("/api/v1/projects/{$project->id}/bundle")->assertOk(); $this->assertTrue(collect($res->json('issue_tasks'))->pluck('id')->contains($task->id)); $this->assertTrue(collect($res->json('issue_comments'))->pluck('id')->contains($comment->id)); } 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()); } }