From 84272c5ccd89555bdc3e18e6ce3667220e18fb06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 26 May 2026 11:26:12 +0300 Subject: [PATCH] feat(project-service): wire SupplierSnapshotGuard into delete() and update() --- app/app/Services/Project/ProjectService.php | 15 +++ .../Project/ProjectServiceGuardWiringTest.php | 103 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index d324f67e..7a6e8749 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -18,6 +18,7 @@ class ProjectService { public function __construct( private readonly OperationsLogger $ops = new OperationsLogger, + private readonly SupplierSnapshotGuard $snapshotGuard = new SupplierSnapshotGuard, ) {} public function update(Project $project, array $data): Project @@ -30,6 +31,15 @@ class ProjectService $data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'], ); + // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md + // Если меняем источник (signal_identifier / sms_senders / sms_keyword) — guard. + $sourceFieldsTouched = array_key_exists('signal_identifier', $data) + || array_key_exists('sms_senders', $data) + || array_key_exists('sms_keyword', $data); + if ($sourceFieldsTouched) { + $this->snapshotGuard->assertCanMutateSource($project, 'change_source'); + } + if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) { throw new HttpResponseException(response()->json([ 'errors' => [ @@ -149,6 +159,11 @@ class ProjectService public function delete(Project $project): void { + // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md + // Guard поставщикова слепка ПЕРЕД has-deals (приоритетней) — клиент должен + // увидеть формулировку про «уже заказали лиды», а не «есть сделки». + $this->snapshotGuard->assertCanMutateSource($project, 'delete'); + $hasDeals = DB::table('deals')->where('project_id', $project->id)->exists(); if ($hasDeals) { throw new HttpResponseException(response()->json([ diff --git a/app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php b/app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php new file mode 100644 index 00000000..fb9360fb --- /dev/null +++ b/app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php @@ -0,0 +1,103 @@ +shouldReceive('assertCanMutateSource') + ->once() + ->with(Mockery::on(fn ($p) => $p instanceof Project && $p->id === 99), 'delete') + ->andThrow(new HttpResponseException(response()->json([], 422))); + + $service = new ProjectService(new OperationsLogger, $guard); + + $project = new Project(['tenant_id' => 1]); + $project->id = 99; + + // We expect guard to throw → ProjectService::delete bails out before touching DB. + // Если guard НЕ вызывался — Mockery скажет shouldReceive missed → fail. + try { + $service->delete($project); + $this->fail('Expected HttpResponseException'); + } catch (HttpResponseException) { + $this->assertTrue(true); + } + } + + public function test_update_invokes_guard_with_change_source_action_when_signal_identifier_changes(): void + { + $guard = Mockery::mock(SupplierSnapshotGuard::class); + $guard->shouldReceive('assertCanMutateSource') + ->once() + ->with(Mockery::on(fn ($p) => $p instanceof Project && $p->id === 100), 'change_source') + ->andThrow(new HttpResponseException(response()->json([], 422))); + + $service = new ProjectService(new OperationsLogger, $guard); + + $project = new Project([ + 'tenant_id' => 1, + 'signal_type' => 'call', + 'signal_identifier' => '79161234567', + 'delivered_today' => 0, + ]); + $project->id = 100; + + try { + $service->update($project, ['signal_identifier' => '79169999999']); + $this->fail('Expected HttpResponseException'); + } catch (HttpResponseException) { + $this->assertTrue(true); + } + } + + public function test_update_does_not_invoke_guard_when_only_non_source_fields_change(): void + { + $guard = Mockery::mock(SupplierSnapshotGuard::class); + $guard->shouldNotReceive('assertCanMutateSource'); + + $service = new ProjectService(new OperationsLogger, $guard); + + $project = new Project([ + 'tenant_id' => 1, + 'signal_type' => 'call', + 'signal_identifier' => '79161234567', + 'delivered_today' => 0, + 'daily_limit_target' => 10, + ]); + $project->id = 101; + + // Меняем только daily_limit_target / regions — guard вызываться не должен. + // Реальный update упадёт на $project->update() (нет таблицы) — это нормально, + // нам важна только проверка mockery expectation на guard. + try { + $service->update($project, ['daily_limit_target' => 20]); + } catch (\Throwable) { + // ignore — нас интересует только что guard НЕ был вызван + } + + // mockery expectations проверятся в tearDown — если guard ВЫЗВАЛСЯ, тест провалится + $this->assertTrue(true); + } +}