diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index 39cfb691..7313d037 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\StoreProjectRequest; +use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; use App\Models\Project; use App\Services\Project\ProjectService; @@ -90,6 +91,15 @@ class ProjectController extends Controller return response()->json(['data' => new ProjectResource($project)], 201); } + /** PATCH /api/projects/{id} */ + public function update(UpdateProjectRequest $request, int $id): JsonResponse + { + $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); + $updated = $this->projects->update($project, $request->validated()); + + return response()->json(['data' => new ProjectResource($updated)]); + } + /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { diff --git a/app/app/Http/Requests/UpdateProjectRequest.php b/app/app/Http/Requests/UpdateProjectRequest.php new file mode 100644 index 00000000..1401e289 --- /dev/null +++ b/app/app/Http/Requests/UpdateProjectRequest.php @@ -0,0 +1,31 @@ +user() !== null; + } + + public function rules(): array + { + // signal_type immutable: не валидируется в правилах, controller игнорирует поле + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'], + 'region_mask' => ['sometimes', 'integer', 'min:0'], + 'region_mode' => ['sometimes', Rule::in(['include', 'exclude'])], + 'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'], + 'sms_senders' => ['sometimes', 'array', 'min:1'], + 'sms_senders.*' => ['string', 'max:11'], + 'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'], + ]; + } +} diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index e869fd49..4714790f 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -11,6 +11,37 @@ use Illuminate\Http\Exceptions\HttpResponseException; class ProjectService { + public function update(Project $project, array $data): Project + { + // Immutable fields — silently drop (don't 422) + unset( + $data['tenant_id'], $data['signal_type'], $data['signal_identifier'], + $data['delivered_today'], $data['delivered_in_month'], + $data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'], + $data['archived_at'], + ); + + if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) { + throw new HttpResponseException(response()->json([ + 'errors' => [ + 'daily_limit_target' => [ + "Лимит не может быть меньше уже доставленных лидов сегодня ({$project->delivered_today}).", + ], + ], + ], 422)); + } + + $needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data); + + $project->update($data); + + if ($needsResync) { + SyncSupplierProjectJob::dispatch($project->id); + } + + return $project->fresh(); + } + public function create(Tenant $tenant, array $data): Project { $limit = (int) ($tenant->limits['max_projects'] ?? 10); diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index db6a9d5e..1caf90d3 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -876,6 +876,12 @@ parameters: count: 9 path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 6 + path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/routes/web.php b/app/routes/web.php index 9972aa87..d536c4ac 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -152,6 +152,7 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(fu Route::get('/', 'App\Http\Controllers\Api\ProjectController@index')->name('projects.index'); Route::post('/', 'App\Http\Controllers\Api\ProjectController@store')->name('projects.store'); Route::get('/{id}', 'App\Http\Controllers\Api\ProjectController@show')->name('projects.show')->where('id', '[0-9]+'); + Route::patch('/{id}', 'App\Http\Controllers\Api\ProjectController@update')->name('projects.update')->where('id', '[0-9]+'); }); // Receive endpoint для входящих webhook'ов (narrative §5.5). diff --git a/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php b/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php new file mode 100644 index 00000000..96b1fb52 --- /dev/null +++ b/app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php @@ -0,0 +1,91 @@ + Queue::fake()); + +it('updates name+daily_limit without resync', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'signal_type' => 'site', + 'signal_identifier' => 'a.ru', 'daily_limit_target' => 10, + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'name' => 'New name', 'daily_limit_target' => 50, + ])->assertOk(); + + expect($project->fresh()->name)->toBe('New name'); + expect($project->fresh()->daily_limit_target)->toBe(50); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); + +it('changing sms_senders triggers resync', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'signal_type' => 'sms', + 'sms_senders' => ['OLD'], 'sms_keyword' => 'x', + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'sms_senders' => ['NEW'], + ])->assertOk(); + + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +it('rejects daily_limit_target below delivered_today', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, 'daily_limit_target' => 50, 'delivered_today' => 30, + ]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'daily_limit_target' => 20, + ])->assertStatus(422); +}); + +it('rejects update of signal_type (immutable)', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'test.ru']); + + $response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'signal_type' => 'call', + ]); + + // signal_type должен быть проигнорирован (не падает 422, но и не меняется) + expect($project->fresh()->signal_type)->toBe('site'); +}); + +it('cross-tenant update returns 404', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + $userA = User::factory()->create(['tenant_id' => $tenantA->id]); + $project = Project::factory()->create(['tenant_id' => $tenantB->id]); + + $this->actingAs($userA)->patchJson("/api/projects/{$project->id}", [ + 'name' => 'hack', + ])->assertStatus(404); +}); + +it('updates region_mask and delivery_days_mask', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $project = Project::factory()->create(['tenant_id' => $tenant->id]); + + $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [ + 'region_mask' => 78, 'region_mode' => 'exclude', 'delivery_days_mask' => 31, + ])->assertOk(); + + expect($project->fresh()->region_mask)->toBe(78); +});