f78ab4af3d
Создавать проекты можно всегда; баланс проверяется в момент ЗАПУСКА (создать-и-запустить / запустить / возобновить одиночно и пачкой / автоподбор) под замком на клиента (без гонок). Не хватает — проект остаётся на паузе с меткой preflight_blocked_at, клиенту сообщение в рублях (сколько пополнить). Групповой запуск «сколько влезло». Нет активного тарифа на дату → запуск запрещён (fail-closed). Гейт реквизитов первого проекта добавлен и в автоподбор. - LaunchBalanceGate — единый гейт вместо 3 копий preflight (ProjectController store/update, AutopodborController), под DB::transaction + lockForUpdate(Tenant). - ProjectService::create($launch) + новый setActive(); bulk resume «сколько влезло». - AutopodborProjectCreator: пачка в транзакции через общий ProjectService::create. - Идемпотентность box/phone_type миграций автоподбора (Schema::hasColumn guard). - Тест-инфра: afterRefreshingDatabase восстанавливает месячные партиции. Тесты фичи 40/40 зелёные. Спека и план — docs/superpowers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
63 lines
2.7 KiB
PHP
63 lines
2.7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Tests\Unit\Services\Project;
|
||
|
||
use App\Services\Project\ProjectService;
|
||
use ReflectionMethod;
|
||
use Tests\TestCase;
|
||
|
||
/**
|
||
* Гарантирует, что переключение `is_active` всегда сопровождается записью `paused_at`:
|
||
* - is_active = false → paused_at := NOW()
|
||
* - is_active = true → paused_at := null
|
||
*
|
||
* Без этого SupplierSnapshotGuard для bulk-paused проектов начнёт считать их
|
||
* "защищёнными навсегда" (paused_at NULL trait), и удаление никогда не разблокируется.
|
||
*
|
||
* Тест читает исходник методов и проверяет наличие явной записи `paused_at` рядом
|
||
* с записью `is_active`. Это структурный smoke — поведенческие тесты (через БД)
|
||
* пишутся отдельно (Task 14 final regression).
|
||
*
|
||
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||
*/
|
||
class PausedAtWriteSideTest extends TestCase
|
||
{
|
||
public function test_project_service_bulk_pause_resume_writes_paused_at(): void
|
||
{
|
||
$body = $this->methodBody(ProjectService::class, 'bulkPauseResume');
|
||
|
||
$this->assertStringContainsString('paused_at', $body,
|
||
'bulkPauseResume должен явно обновлять paused_at вместе с is_active');
|
||
$this->assertStringContainsString('is_active', $body);
|
||
}
|
||
|
||
public function test_project_service_set_active_writes_paused_at(): void
|
||
{
|
||
$body = $this->methodBody(ProjectService::class, 'setActive');
|
||
|
||
$this->assertStringContainsString('paused_at', $body,
|
||
'setActive должен явно обновлять paused_at вместе с is_active');
|
||
$this->assertStringContainsString('is_active', $body);
|
||
}
|
||
|
||
public function test_bulk_delete_distinguishes_supplier_snapshot_lock_from_has_deals(): void
|
||
{
|
||
$body = $this->methodBody(ProjectService::class, 'bulkDelete');
|
||
|
||
$this->assertStringContainsString('supplier_snapshot_locked', $body,
|
||
'bulkDelete должен помечать пропущенные проекты reason="supplier_snapshot_locked" при guard-блоке');
|
||
$this->assertStringContainsString('has_deals', $body);
|
||
}
|
||
|
||
private function methodBody(string $class, string $method): string
|
||
{
|
||
$rm = new ReflectionMethod($class, $method);
|
||
$lines = file($rm->getFileName());
|
||
$body = array_slice($lines, $rm->getStartLine() - 1, $rm->getEndLine() - $rm->getStartLine() + 1);
|
||
|
||
return implode('', $body);
|
||
}
|
||
}
|