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); // Эпик 6.2: update() сначала спрашивает isProtected (для уведомления о хвосте), // затем assertCanMutateSource. Допускаем оба вызова. $guard->shouldReceive('isProtected')->andReturn(true); $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_signal_identifier_present_but_unchanged(): void { // Реальный баг (найден глазами): дроуэр для site/call ВСЕГДА шлёт signal_identifier, // даже если клиент его не трогал (поменял только лимит). Guard должен срабатывать // на ФАКТИЧЕСКОЕ изменение источника, а не на присутствие ключа — иначе смена // лимита/региона/дней на защищённом проекте ложно блокируется как «смена источника». $guard = Mockery::mock(SupplierSnapshotGuard::class); // Если guard вызовут — он бросит 422; тест провалится на HttpResponseException ниже. $guard->shouldReceive('assertCanMutateSource') ->andThrow(new HttpResponseException(response()->json([], 422))); $guard->shouldReceive('isProtected')->andReturn(true); $guard->shouldReceive('appliesFrom')->andReturn(null); $service = new ProjectService(new OperationsLogger, $guard); $project = new Project([ 'tenant_id' => 1, 'signal_type' => 'site', 'signal_identifier' => 'okna-moskva.test', 'delivered_today' => 0, ]); $project->id = 102; // signal_identifier тот же, меняется только лимит → источник НЕ тронут → guard молчит. try { $service->update($project, ['signal_identifier' => 'okna-moskva.test', 'daily_limit_target' => 50]); } catch (HttpResponseException $e) { $this->fail('Guard ложно сработал на неизменённый источник: 422 при смене только лимита'); } catch (\Throwable) { // Ожидаемо: update доходит до DB-запроса (assertSourceUnique / $project->update()) // и падает без таблицы — это значит guard НЕ блокировал. Тест проходит. } $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); } }