17a824f925
Milestone 1 (auth foundation):
- Installed laravel/sanctum; HasApiTokens on User; published config + migration.
- routes/api.php with /api/v1; Sanctum 'ability' middleware alias registered.
- AuthController: POST login (long-lived revocable device token w/ ability
mobile-sync + devices table), GET me, POST logout. New Device model/table.
Milestone 2 (vertical slice, offline-first):
- progress_updates: +uuid (client-generated) +client_updated_at.
- ProjectApiController: GET projects (accessibleBy), GET projects/{id}/bundle
(project/phases/layers/features, membership-authorized).
- SyncController: POST sync — batch ops, idempotent by uuid, per-op result
(applied/duplicate/error), server-set user_id, authz by permission+membership.
Currently handles progress_update.create.
Tests: tests/Feature/Api/MobileApiTest (9 passing) — auth, accessible projects,
bundle authz, sync apply+idempotency, permission enforcement.
Also fixed a latent schema bug: projects.reference (and external_reference_1)
existed in the live DB but had no migration — added a guarded migration so fresh
installs match production.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
6.7 KiB
PHP
192 lines
6.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Models\Phase;
|
|
use App\Models\Project;
|
|
use App\Models\ProgressUpdate;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Spatie\Permission\Models\Permission;
|
|
use Tests\TestCase;
|
|
|
|
class MobileApiTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Permission::findOrCreate('update progress');
|
|
Permission::findOrCreate('manage all');
|
|
}
|
|
|
|
private function makeProject(?User $member = null): Project
|
|
{
|
|
$project = Project::create([
|
|
'reference' => '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): Phase
|
|
{
|
|
return Phase::create([
|
|
'project_id' => $project->id,
|
|
'name' => 'Fase 1',
|
|
'order' => 1,
|
|
'color' => '#3b82f6',
|
|
'progress_percent' => 0,
|
|
]);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|