feat(api): mobile API Milestones 5+6 — media upload, sync_logs idempotency, OpenAPI
Milestone 5 (media): - POST /api/v1/media — multipart upload, attaches to feature/issue/project/ phase/layer, idempotent by uuid, authz member + 'upload media'. Added uuid+client_updated_at to media. - Bundle now includes a 'media' array (URLs) for the project's project/feature/ issue attachments (delta-aware). Milestone 6 (hardening + docs): - sync_logs table/model: every applied op is logged; /sync short-circuits on a repeated op uuid -> 'duplicate' (true idempotency for updates too, not just creates). - Rate limiting on login (10/min), sync (60/min), media (120/min). - docs/openapi.yaml: OpenAPI 3 contract for the mobile team. Tests: 18 passing (added media upload idempotency + sync_logs idempotency). The mobile API (Milestones 1-6) is now feature-complete on the webapp side. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Feature;
|
||||
use App\Models\Issue;
|
||||
use App\Models\Layer;
|
||||
use App\Models\Media;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MediaController extends Controller
|
||||
{
|
||||
private array $map = [
|
||||
'feature' => Feature::class,
|
||||
'issue' => Issue::class,
|
||||
'project' => Project::class,
|
||||
'phase' => Phase::class,
|
||||
'layer' => Layer::class,
|
||||
];
|
||||
|
||||
/** Upload a file (multipart) and attach it to a parent record. Idempotent by uuid. */
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'uuid' => ['required', 'uuid'],
|
||||
'parent_entity' => ['required', Rule::in(array_keys($this->map))],
|
||||
'parent_id' => ['required', 'integer'],
|
||||
'file' => ['required', 'file', 'max:20480'], // 20 MB
|
||||
'category' => ['nullable', 'in:image,document,other'],
|
||||
'description' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
// Idempotency: same uuid already uploaded → return it.
|
||||
if ($existing = Media::where('uuid', $data['uuid'])->first()) {
|
||||
return response()->json(['status' => 'duplicate', 'media' => $this->payload($existing)]);
|
||||
}
|
||||
|
||||
$parent = $this->map[$data['parent_entity']]::find($data['parent_id']);
|
||||
if (! $parent) {
|
||||
return response()->json(['status' => 'error', 'error' => 'parent not found'], 422);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$project = $this->projectOf($data['parent_entity'], $parent);
|
||||
abort_unless($this->canAccess($user, $project) && $user->can('upload media'), 403);
|
||||
|
||||
$file = $request->file('file');
|
||||
$path = $file->store("media/{$data['parent_entity']}/{$parent->id}", 'public');
|
||||
$mime = $file->getClientMimeType();
|
||||
|
||||
$media = $parent->media()->create([
|
||||
'uuid' => $data['uuid'],
|
||||
'name' => $file->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'file_type' => $mime,
|
||||
'file_extension' => $file->getClientOriginalExtension(),
|
||||
'file_size' => $file->getSize(),
|
||||
'category' => $data['category'] ?? (Str::startsWith($mime, 'image/') ? 'image' : 'document'),
|
||||
'description' => $data['description'] ?? null,
|
||||
'uploaded_by' => $user->id,
|
||||
'client_updated_at' => $request->input('client_updated_at'),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'applied', 'media' => $this->payload($media)]);
|
||||
}
|
||||
|
||||
private function projectOf(string $entity, $parent): ?Project
|
||||
{
|
||||
return match ($entity) {
|
||||
'project' => $parent,
|
||||
'phase' => $parent->project,
|
||||
'layer' => $parent->phase?->project,
|
||||
'feature' => $parent->layer?->phase?->project,
|
||||
'issue' => $parent->project,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function canAccess(User $user, ?Project $project): bool
|
||||
{
|
||||
if (! $project) {
|
||||
return false;
|
||||
}
|
||||
return $user->can('manage all')
|
||||
|| $project->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
private function payload(Media $m): array
|
||||
{
|
||||
return [
|
||||
'id' => $m->id,
|
||||
'uuid' => $m->uuid,
|
||||
'url' => $m->url,
|
||||
'name' => $m->name,
|
||||
'file_type' => $m->file_type,
|
||||
'category' => $m->category,
|
||||
'updated_at' => $m->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\Inspection;
|
||||
use App\Models\InspectionTemplate;
|
||||
use App\Models\Issue;
|
||||
use App\Models\Layer;
|
||||
use App\Models\Media;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Project;
|
||||
use Carbon\Carbon;
|
||||
@@ -48,6 +49,14 @@ class ProjectApiController extends Controller
|
||||
$issues = $changed(Issue::where('project_id', $project->id))->get();
|
||||
$templates = $changed(InspectionTemplate::where('project_id', $project->id))->get();
|
||||
|
||||
$featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id');
|
||||
$issueIds = Issue::where('project_id', $project->id)->pluck('id');
|
||||
$media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds) {
|
||||
$q->where(fn ($w) => $w->where('mediable_type', Project::class)->where('mediable_id', $project->id))
|
||||
->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds))
|
||||
->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds));
|
||||
}))->get();
|
||||
|
||||
return response()->json([
|
||||
'server_time' => now()->toIso8601String(),
|
||||
'project' => $this->mapProject($project),
|
||||
@@ -57,6 +66,7 @@ class ProjectApiController extends Controller
|
||||
'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(),
|
||||
'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(),
|
||||
'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(),
|
||||
'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(),
|
||||
'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds) : (object) [],
|
||||
]);
|
||||
}
|
||||
@@ -166,4 +176,21 @@ class ProjectApiController extends Controller
|
||||
'updated_at' => $t->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapMedia(Media $m): array
|
||||
{
|
||||
$entity = [
|
||||
Project::class => 'project',
|
||||
Feature::class => 'feature',
|
||||
Issue::class => 'issue',
|
||||
][$m->mediable_type] ?? class_basename($m->mediable_type);
|
||||
|
||||
return [
|
||||
'id' => $m->id, 'uuid' => $m->uuid,
|
||||
'parent_entity' => $entity, 'parent_id' => $m->mediable_id,
|
||||
'url' => $m->url, 'name' => $m->name, 'file_type' => $m->file_type,
|
||||
'category' => $m->category, 'updated_at' => $m->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Issue;
|
||||
use App\Models\Phase;
|
||||
use App\Models\ProgressUpdate;
|
||||
use App\Models\Project;
|
||||
use App\Models\SyncLog;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -49,8 +50,15 @@ class SyncController extends Controller
|
||||
{
|
||||
$uuid = $op['uuid'];
|
||||
|
||||
// Op-level idempotency: if this operation was already applied, replay its result.
|
||||
$prior = SyncLog::where('op_uuid', $uuid)
|
||||
->where('entity', $op['entity'])->where('op', $op['op'])->first();
|
||||
if ($prior) {
|
||||
return ['uuid' => $uuid, 'status' => 'duplicate', 'server_id' => $prior->server_id];
|
||||
}
|
||||
|
||||
try {
|
||||
return match ($op['entity'] . '.' . $op['op']) {
|
||||
$result = match ($op['entity'] . '.' . $op['op']) {
|
||||
'progress_update.create' => $this->progressUpdateCreate($user, $uuid, $op),
|
||||
'inspection.create' => $this->inspectionCreate($user, $uuid, $op),
|
||||
'issue.create' => $this->issueCreate($user, $uuid, $op),
|
||||
@@ -59,8 +67,22 @@ class SyncController extends Controller
|
||||
default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($uuid, $e->getMessage());
|
||||
$result = $this->error($uuid, $e->getMessage());
|
||||
}
|
||||
|
||||
// Record only terminal successes so conflicts/errors can be safely retried.
|
||||
if ($result['status'] === 'applied') {
|
||||
SyncLog::create([
|
||||
'user_id' => $user->id,
|
||||
'op_uuid' => $uuid,
|
||||
'entity' => $op['entity'],
|
||||
'op' => $op['op'],
|
||||
'status' => 'applied',
|
||||
'server_id' => $result['server_id'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ── progress_update.create ───────────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,7 @@ class Media extends Model
|
||||
'mediable_type', 'mediable_id',
|
||||
'name', 'file_path', 'file_type', 'file_extension', 'file_size',
|
||||
'category', 'description', 'metadata', 'uploaded_by',
|
||||
'uuid', 'client_updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SyncLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'op_uuid', 'entity', 'op', 'status', 'server_id', 'error',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('media', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('media', 'uuid')) {
|
||||
$table->uuid('uuid')->nullable()->unique()->after('id');
|
||||
}
|
||||
if (! Schema::hasColumn('media', 'client_updated_at')) {
|
||||
$table->timestamp('client_updated_at')->nullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('media', function (Blueprint $table) {
|
||||
foreach (['uuid', 'client_updated_at'] as $col) {
|
||||
if (Schema::hasColumn('media', $col)) {
|
||||
$table->dropColumn($col);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sync_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->uuid('op_uuid')->index(); // idempotency key of the operation
|
||||
$table->string('entity');
|
||||
$table->string('op');
|
||||
$table->string('status'); // applied | duplicate | conflict | error
|
||||
$table->unsignedBigInteger('server_id')->nullable();
|
||||
$table->text('error')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// One processed result per (entity, op, op_uuid).
|
||||
$table->unique(['entity', 'op', 'op_uuid']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sync_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: ConstruProgress Mobile API
|
||||
version: "1.0.0"
|
||||
description: >
|
||||
Offline-first sync API for the mobile app. Auth via Laravel Sanctum bearer
|
||||
tokens (ability `mobile-sync`). All protected endpoints require
|
||||
`Authorization: Bearer <token>`. See docs/MOBILE_SYNC_PROTOCOL.md.
|
||||
servers:
|
||||
- url: /api/v1
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/login:
|
||||
post:
|
||||
summary: Issue a device token
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email, password, device_name]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string }
|
||||
device_name: { type: string }
|
||||
app_version: { type: string, nullable: true }
|
||||
responses:
|
||||
"200":
|
||||
description: Token issued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token: { type: string }
|
||||
user: { $ref: '#/components/schemas/User' }
|
||||
"422": { description: Invalid credentials }
|
||||
/me:
|
||||
get:
|
||||
summary: Current user + effective permissions
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user: { $ref: '#/components/schemas/User' }
|
||||
"401": { description: Unauthenticated }
|
||||
/logout:
|
||||
post:
|
||||
summary: Revoke the current device token
|
||||
responses:
|
||||
"200": { description: Logged out }
|
||||
/projects:
|
||||
get:
|
||||
summary: Projects the user can access
|
||||
responses:
|
||||
"200": { description: OK }
|
||||
/projects/{project}/bundle:
|
||||
get:
|
||||
summary: Offline bundle (full, or delta when `since` is given)
|
||||
parameters:
|
||||
- name: project
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- name: since
|
||||
in: query
|
||||
required: false
|
||||
description: >
|
||||
ISO8601 timestamp. Returns only records changed after it, plus
|
||||
`deleted` tombstones. MUST be URL-encoded (the `+` offset).
|
||||
schema: { type: string, format: date-time }
|
||||
responses:
|
||||
"200":
|
||||
description: Bundle
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Bundle' }
|
||||
"403": { description: Not a member of the project }
|
||||
/templates:
|
||||
get:
|
||||
summary: Inspection templates for accessible projects (with version/hash)
|
||||
parameters:
|
||||
- name: since
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string, format: date-time }
|
||||
responses:
|
||||
"200": { description: OK }
|
||||
/sync:
|
||||
post:
|
||||
summary: Push a batch of offline mutations (idempotent by uuid)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [operations]
|
||||
properties:
|
||||
operations:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Operation' }
|
||||
responses:
|
||||
"200":
|
||||
description: Per-operation results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/OperationResult' }
|
||||
/media:
|
||||
post:
|
||||
summary: Upload a file (multipart) and attach it to a parent record
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [uuid, parent_entity, parent_id, file]
|
||||
properties:
|
||||
uuid: { type: string, format: uuid }
|
||||
parent_entity: { type: string, enum: [feature, issue, project, phase, layer] }
|
||||
parent_id: { type: integer }
|
||||
file: { type: string, format: binary }
|
||||
category: { type: string, enum: [image, document, other] }
|
||||
description: { type: string }
|
||||
responses:
|
||||
"200": { description: applied | duplicate }
|
||||
"403": { description: Forbidden }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
email: { type: string }
|
||||
roles: { type: array, items: { type: string } }
|
||||
permissions: { type: array, items: { type: string } }
|
||||
Operation:
|
||||
type: object
|
||||
required: [entity, op, uuid, data]
|
||||
properties:
|
||||
entity: { type: string, enum: [progress_update, inspection, issue, feature] }
|
||||
op: { type: string, enum: [create, update] }
|
||||
uuid: { type: string, format: uuid, description: client-generated idempotency key }
|
||||
client_updated_at: { type: string, format: date-time }
|
||||
data: { type: object }
|
||||
example:
|
||||
entity: feature
|
||||
op: update
|
||||
uuid: 0f8e...-uuid
|
||||
client_updated_at: "2026-06-18T12:00:00+00:00"
|
||||
data: { id: 5, status: completed, progress: 100 }
|
||||
OperationResult:
|
||||
type: object
|
||||
properties:
|
||||
uuid: { type: string, format: uuid }
|
||||
status: { type: string, enum: [applied, duplicate, conflict, error] }
|
||||
server_id: { type: integer, nullable: true }
|
||||
error: { type: string, nullable: true }
|
||||
server: { type: object, nullable: true, description: current server value on conflict }
|
||||
Bundle:
|
||||
type: object
|
||||
properties:
|
||||
server_time: { type: string, format: date-time }
|
||||
project: { type: object }
|
||||
phases: { type: array, items: { type: object } }
|
||||
layers: { type: array, items: { type: object } }
|
||||
features: { type: array, items: { type: object } }
|
||||
inspections: { type: array, items: { type: object } }
|
||||
issues: { type: array, items: { type: object } }
|
||||
templates: { type: array, items: { type: object } }
|
||||
media: { type: array, items: { type: object } }
|
||||
deleted:
|
||||
type: object
|
||||
description: tombstones (ids of soft-deleted records) when `since` is given
|
||||
properties:
|
||||
phases: { type: array, items: { type: integer } }
|
||||
layers: { type: array, items: { type: integer } }
|
||||
features: { type: array, items: { type: integer } }
|
||||
inspections: { type: array, items: { type: integer } }
|
||||
issues: { type: array, items: { type: integer } }
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\V1\AuthController;
|
||||
use App\Http\Controllers\Api\V1\MediaController;
|
||||
use App\Http\Controllers\Api\V1\ProjectApiController;
|
||||
use App\Http\Controllers\Api\V1\SyncController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -22,5 +23,6 @@ Route::prefix('v1')->group(function () {
|
||||
|
||||
// PUSH
|
||||
Route::post('sync', [SyncController::class, 'sync'])->middleware('throttle:60,1');
|
||||
Route::post('media', [MediaController::class, 'upload'])->middleware('throttle:120,1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,11 @@ use App\Models\Phase;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProgressUpdate;
|
||||
use App\Models\User;
|
||||
use App\Models\SyncLog;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
@@ -25,7 +28,7 @@ class MobileApiTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues'] as $p) {
|
||||
foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues', 'upload media'] as $p) {
|
||||
Permission::findOrCreate($p);
|
||||
}
|
||||
}
|
||||
@@ -371,4 +374,58 @@ class MobileApiTest extends TestCase
|
||||
$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());
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user