Files
portal/app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php
T
Дмитрий 63a2d53255 fix/projects: смена лимита-региона-дней на защищённом проекте больше не блокируется ложно как смена источника
Симптом: на проекте, по которому уже идут лиды от поставщика, правка только лимита, региона или дней отдавала 422 «Изменить источник можно будет после N» — хотя источник не менялся. Найдено приёмкой 25.06.2026 глазами через Playwright. Дефект на main, то есть живой на боевом liderra.ru.

Корень: ProjectService::update вычислял sourceFieldsTouched по присутствию ключа signal_identifier, а дроуэр site и call всегда его шлёт даже неизменённым.

Фикс: новый метод sourceValueChanged сравнивает фактическое значение источника, а не присутствие ключа. Guard срабатывает только на реальную смену источника.

TDD: добавлен падавший тест test_update_does_not_invoke_guard_when_signal_identifier_present_but_unchanged. Larastan чист, phpstan-baseline обновлён под Mockery-шум. Также project_rule добавлен в тип уведомлений и icon-map колокольчика; SchemaDeltaTest приведён к метрикам схемы v8.55 после 2 новых таблиц.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:34:55 +03:00

143 lines
6.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Project;
use App\Models\Project;
use App\Services\Audit\OperationsLogger;
use App\Services\Project\ProjectService;
use App\Services\Project\SupplierSnapshotGuard;
use Illuminate\Http\Exceptions\HttpResponseException;
use Mockery;
use Tests\TestCase;
/**
* Wiring-тесты: убеждаемся, что ProjectService::delete() и ProjectService::update()
* зовут SupplierSnapshotGuard::assertCanMutateSource перед мутацией.
*
* Это не behaviour-тесты самого guard (они в SupplierSnapshotGuardTest), а контракт
* интеграции — что переключение защиты на guard действительно произошло.
*
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 8 / Task 10).
*/
class ProjectServiceGuardWiringTest extends TestCase
{
public function test_delete_invokes_guard_with_delete_action(): void
{
$guard = Mockery::mock(SupplierSnapshotGuard::class);
$guard->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);
}
}