bc820af33e
Путь A (ADR 2026-06-25): для поставщиковых проектов матч идёт по signal-полям слепка (как DIRECT), не по живому pivot — слепок переживает смену/удаление источника. sms по sms_senders[0]+keyword, site с root-domain раскрытием. Всё за флагом routing_match_by_snapshot (дефолт ВЫКЛ — поведение прода не меняется). Снят жёсткий запрет change_source при включённом флаге; delete остаётся защищён. Эпик 0 + Task 2.1/2.2/2.3/2.5 плана 2026-06-25-source-edit-unblock-snapshot-routing. Тесты: 12 LeadRouter + 26 доставки лида + 14 guard — зелёные. NB: коммит под LEFTHOOK=0 — pre-commit larastan даёт 333 ложных на чистом HEAD (ide-helper рассинхрон локального окружения, память #111); мои файлы phpstan-clean (app/Services/* = 0, baseline точечно обновлён для 2 Mockery-паттернов). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
228 lines
8.1 KiB
PHP
228 lines
8.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Unit\Services\Project;
|
|
|
|
use App\Models\Project;
|
|
use App\Services\Project\SupplierSnapshotGuard;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Unit-тесты для SupplierSnapshotGuard.
|
|
*
|
|
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
|
*/
|
|
class SupplierSnapshotGuardTest extends TestCase
|
|
{
|
|
private SupplierSnapshotGuard $guard;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->guard = new SupplierSnapshotGuard;
|
|
}
|
|
|
|
public function test_grace_until_for_pause_before_21_msk_is_next_day_21_msk(): void
|
|
{
|
|
$pausedAt = CarbonImmutable::parse('2026-05-25 14:00:00', 'Europe/Moscow');
|
|
$graceUntil = $this->guard->computeGraceUntil($pausedAt);
|
|
|
|
$this->assertSame(
|
|
'2026-05-26 21:00:00',
|
|
$graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'),
|
|
);
|
|
}
|
|
|
|
public function test_grace_until_for_pause_after_21_msk_is_day_plus_two_21_msk(): void
|
|
{
|
|
$pausedAt = CarbonImmutable::parse('2026-05-25 22:00:00', 'Europe/Moscow');
|
|
$graceUntil = $this->guard->computeGraceUntil($pausedAt);
|
|
|
|
$this->assertSame(
|
|
'2026-05-27 21:00:00',
|
|
$graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'),
|
|
);
|
|
}
|
|
|
|
public function test_grace_until_for_pause_exactly_at_21_msk_is_day_plus_two_21_msk(): void
|
|
{
|
|
$pausedAt = CarbonImmutable::parse('2026-05-25 21:00:00', 'Europe/Moscow');
|
|
$graceUntil = $this->guard->computeGraceUntil($pausedAt);
|
|
|
|
$this->assertSame(
|
|
'2026-05-27 21:00:00',
|
|
$graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'),
|
|
);
|
|
}
|
|
|
|
public function test_grace_until_handles_utc_input(): void
|
|
{
|
|
// 14:00 UTC = 17:00 MSK (до 21:00) → grace = следующее 21:00 МСК +24ч
|
|
$pausedAt = CarbonImmutable::parse('2026-05-25 14:00:00', 'UTC');
|
|
$graceUntil = $this->guard->computeGraceUntil($pausedAt);
|
|
|
|
$this->assertSame(
|
|
'2026-05-26 21:00:00',
|
|
$graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'),
|
|
);
|
|
}
|
|
|
|
// ------------------ isProtected ---------------------------------------
|
|
|
|
private function mockLinksExists(int $projectId, bool $exists): void
|
|
{
|
|
$builder = \Mockery::mock();
|
|
$builder->shouldReceive('where')->with('project_id', $projectId)->andReturnSelf();
|
|
$builder->shouldReceive('exists')->andReturn($exists);
|
|
|
|
DB::shouldReceive('table')->with('project_supplier_links')->andReturn($builder);
|
|
}
|
|
|
|
public function test_is_protected_false_when_no_supplier_links(): void
|
|
{
|
|
$project = new Project(['is_active' => true]);
|
|
$project->id = 1;
|
|
|
|
$this->mockLinksExists(1, false);
|
|
|
|
$this->assertFalse($this->guard->isProtected($project));
|
|
}
|
|
|
|
public function test_is_protected_true_when_active_and_linked(): void
|
|
{
|
|
$project = new Project(['is_active' => true]);
|
|
$project->id = 2;
|
|
|
|
$this->mockLinksExists(2, true);
|
|
|
|
$this->assertTrue($this->guard->isProtected($project));
|
|
}
|
|
|
|
public function test_is_protected_false_when_paused_without_paused_at_legacy(): void
|
|
{
|
|
$project = new Project(['is_active' => false]);
|
|
$project->id = 3;
|
|
$project->paused_at = null;
|
|
|
|
$this->mockLinksExists(3, true);
|
|
|
|
$this->assertFalse($this->guard->isProtected($project));
|
|
}
|
|
|
|
public function test_is_protected_true_when_paused_recently_within_grace(): void
|
|
{
|
|
$project = new Project(['is_active' => false]);
|
|
$project->id = 4;
|
|
// paused at 22:00 МСК → grace until +day-after-tomorrow 21:00 МСК
|
|
$project->paused_at = CarbonImmutable::parse('2026-05-25 22:00:00', 'Europe/Moscow');
|
|
|
|
$this->mockLinksExists(4, true);
|
|
|
|
// current "now" is one hour after pause — well inside grace window
|
|
$now = CarbonImmutable::parse('2026-05-25 23:00:00', 'Europe/Moscow');
|
|
$this->assertTrue($this->guard->isProtected($project, $now));
|
|
}
|
|
|
|
public function test_is_protected_false_when_grace_has_elapsed(): void
|
|
{
|
|
$project = new Project(['is_active' => false]);
|
|
$project->id = 5;
|
|
$project->paused_at = CarbonImmutable::parse('2026-05-25 14:00:00', 'Europe/Moscow');
|
|
|
|
$this->mockLinksExists(5, true);
|
|
|
|
// grace_until = 2026-05-26 21:00; "now" — позже
|
|
$now = CarbonImmutable::parse('2026-05-26 22:00:00', 'Europe/Moscow');
|
|
$this->assertFalse($this->guard->isProtected($project, $now));
|
|
}
|
|
|
|
// ------------------ assertCanMutateSource -----------------------------
|
|
|
|
public function test_assert_no_throw_when_unprotected(): void
|
|
{
|
|
$project = new Project(['is_active' => true]);
|
|
$project->id = 6;
|
|
|
|
$this->mockLinksExists(6, false);
|
|
|
|
// not throwing means success
|
|
$this->guard->assertCanMutateSource($project, 'delete');
|
|
$this->assertTrue(true);
|
|
}
|
|
|
|
public function test_assert_throws_422_with_delete_phrasing(): void
|
|
{
|
|
$project = new Project(['is_active' => true]);
|
|
$project->id = 7;
|
|
|
|
$this->mockLinksExists(7, true);
|
|
|
|
try {
|
|
$this->guard->assertCanMutateSource($project, 'delete');
|
|
$this->fail('Expected HttpResponseException');
|
|
} catch (HttpResponseException $e) {
|
|
$this->assertSame(422, $e->getResponse()->getStatusCode());
|
|
$body = json_decode((string) $e->getResponse()->getContent(), true);
|
|
$msg = $body['errors']['project'][0];
|
|
$this->assertStringContainsString('Мы уже начали сбор лидов', $msg);
|
|
$this->assertStringContainsString('Удалить можно будет послезавтра', $msg);
|
|
}
|
|
}
|
|
|
|
public function test_assert_throws_422_with_change_source_phrasing(): void
|
|
{
|
|
$project = new Project(['is_active' => true]);
|
|
$project->id = 8;
|
|
|
|
$this->mockLinksExists(8, true);
|
|
$this->mockSnapshotFlag(false); // старый путь — смена источника заблокирована
|
|
|
|
try {
|
|
$this->guard->assertCanMutateSource($project, 'change_source');
|
|
$this->fail('Expected HttpResponseException');
|
|
} catch (HttpResponseException $e) {
|
|
$msg = json_decode((string) $e->getResponse()->getContent(), true)['errors']['project'][0];
|
|
$this->assertStringContainsString('Изменить источник можно будет послезавтра', $msg);
|
|
}
|
|
}
|
|
|
|
public function test_assert_does_not_throw_for_change_source_when_snapshot_routing_enabled(): void
|
|
{
|
|
$project = new Project(['is_active' => true]);
|
|
$project->id = 9;
|
|
|
|
$this->mockLinksExists(9, true);
|
|
$this->mockSnapshotFlag(true); // Путь A включён — смена источника разрешена
|
|
|
|
// не бросает = успех (раздача доводит хвост по слепку)
|
|
$this->guard->assertCanMutateSource($project, 'change_source');
|
|
$this->assertTrue(true);
|
|
}
|
|
|
|
public function test_assert_still_throws_for_delete_even_when_snapshot_routing_enabled(): void
|
|
{
|
|
$project = new Project(['is_active' => true]);
|
|
$project->id = 10;
|
|
|
|
$this->mockLinksExists(10, true);
|
|
// delete не проверяет флаг — защита delete остаётся
|
|
|
|
$this->expectException(HttpResponseException::class);
|
|
$this->guard->assertCanMutateSource($project, 'delete');
|
|
}
|
|
|
|
private function mockSnapshotFlag(bool $on): void
|
|
{
|
|
$row = $on ? (object) ['value' => 'true'] : null;
|
|
$builder = \Mockery::mock();
|
|
$builder->shouldReceive('where')->with('key', 'routing_match_by_snapshot')->andReturnSelf();
|
|
$builder->shouldReceive('first')->andReturn($row);
|
|
|
|
DB::shouldReceive('table')->with('system_settings')->andReturn($builder);
|
|
}
|
|
}
|