diff --git a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php index 0406d540..3798dda6 100644 --- a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php +++ b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php @@ -151,8 +151,10 @@ class RunTelegramCampaignJob implements ShouldQueue } $fresh->matched_count = $result->matched; - // launched=true бывает только в живом режиме (Сессия 6); черновик → draft_ready. - $fresh->transitionTo($result->launched ? Campaign::STATUS_LAUNCHED : Campaign::STATUS_DRAFT_READY); + // launched=true (живой режим) = кампания УШЛА НА МОДЕРАЦИЮ МТС, а не + // «запущена»: ставим `moderating`, одобрение переведёт в `launched` + // опросчик вердикта (задача 3.4). Песочница (черновик) → `draft_ready`. + $fresh->transitionTo($result->launched ? Campaign::STATUS_MODERATING : Campaign::STATUS_DRAFT_READY); }); // Задача 2.3: при отказе робота возвращаем бронь кошелька (frozen), иначе она diff --git a/app/app/Models/ClientTg/Campaign.php b/app/app/Models/ClientTg/Campaign.php index 8933d9b1..ecc8a193 100644 --- a/app/app/Models/ClientTg/Campaign.php +++ b/app/app/Models/ClientTg/Campaign.php @@ -33,6 +33,8 @@ class Campaign extends Model public const STATUS_DRAFT_READY = 'draft_ready'; + public const STATUS_MODERATING = 'moderating'; + public const STATUS_LAUNCHED = 'launched'; public const STATUS_FAILED = 'failed'; @@ -48,13 +50,14 @@ class Campaign extends Model public const AUDIENCE_LIST = 'list'; /** - * Разрешённые переходы статуса (план §Сессия 2, задача 2.1): - * draft → queued → running → (draft_ready | launched | failed | rejected). - * Отмена (cancelled) разрешена только ДО запуска — из draft и из queued; - * из running отмену не пускаем (запуск уже пошёл, отмена там — по другой - * дороге в задаче 2.3). Терминальные статусы (draft_ready/launched/failed/ - * rejected/cancelled) — без исходящих переходов на этом этапе; повторная - * отправка (resubmit) появится в Сессии 5. + * Разрешённые переходы статуса (план §Сессия 2 задача 2.1 + Этап 3 задача 3.2): + * draft → queued → running → (draft_ready | moderating | launched | failed | rejected). + * Живая отправка робота ставит `moderating` («на модерации МТС»), а не сразу + * `launched`: одобрение придёт от опросчика вердикта (задача 3.4), тогда + * moderating → launched; отказ модерации — moderating → rejected. Отклонённую + * кампанию можно пересдать: rejected → queued (задача 3.6). Отмена (cancelled) + * разрешена только ДО запуска — из draft и из queued; из running отмену не + * пускаем. Терминальные — draft_ready/launched/failed/cancelled. * * @var array> */ @@ -63,14 +66,16 @@ class Campaign extends Model self::STATUS_QUEUED => [self::STATUS_RUNNING, self::STATUS_CANCELLED], self::STATUS_RUNNING => [ self::STATUS_DRAFT_READY, + self::STATUS_MODERATING, self::STATUS_LAUNCHED, self::STATUS_FAILED, self::STATUS_REJECTED, ], + self::STATUS_MODERATING => [self::STATUS_LAUNCHED, self::STATUS_REJECTED], self::STATUS_DRAFT_READY => [], self::STATUS_LAUNCHED => [], self::STATUS_FAILED => [], - self::STATUS_REJECTED => [], + self::STATUS_REJECTED => [self::STATUS_QUEUED], self::STATUS_CANCELLED => [], ]; diff --git a/app/tests/Feature/ClientTg/ModeratingStatusTest.php b/app/tests/Feature/ClientTg/ModeratingStatusTest.php new file mode 100644 index 00000000..b8a19eba --- /dev/null +++ b/app/tests/Feature/ClientTg/ModeratingStatusTest.php @@ -0,0 +1,135 @@ +create(); + + return Campaign::query()->create([ + 'tenant_id' => $tenant->id, + 'status' => $status, + 'ad_text' => 'Тест модерации', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '1000.00', + 'audience_kind' => Campaign::AUDIENCE_DEALS, + 'planned_count' => 0, + 'estimated_cost_rub' => '0.00', + 'created_by' => 1, + ]); +} + +/** Queued-кампания-список с фейковыми номерами для прогона джоба. */ +function moderatingQueuedCampaign(int $tenantId): Campaign +{ + $campaign = Campaign::query()->create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_QUEUED, + 'ad_text' => 'Приходите к нам', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '1500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '0.00', + 'created_by' => 1, + ]); + + foreach (['79990000001', '79990000002'] as $phone) { + CampaignPhone::query()->create([ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaign->id, + 'phone' => $phone, + ]); + } + + return $campaign; +} + +function moderatingMockRobot(RobotResult $result): void +{ + $mock = Mockery::mock(TelegramRobotRunner::class); + $mock->shouldReceive('run')->andReturn($result); + app()->instance(TelegramRobotRunner::class, $mock); +} + +it('константа STATUS_MODERATING существует и равна moderating', function () { + expect(Campaign::STATUS_MODERATING)->toBe('moderating'); +}); + +it('из running допускает moderating', function () { + $c = moderatingCampaign(Campaign::STATUS_RUNNING); + expect($c->canTransitionTo(Campaign::STATUS_MODERATING))->toBeTrue(); + + $c->transitionTo(Campaign::STATUS_MODERATING); + expect($c->status)->toBe(Campaign::STATUS_MODERATING); + expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING); +}); + +it('из moderating допускает launched и rejected', function (string $to) { + $c = moderatingCampaign(Campaign::STATUS_MODERATING); + $c->transitionTo($to); + expect($c->status)->toBe($to); +})->with([ + Campaign::STATUS_LAUNCHED, + Campaign::STATUS_REJECTED, +]); + +it('rejected → queued разрешён (пересдача)', function () { + $c = moderatingCampaign(Campaign::STATUS_REJECTED); + expect($c->canTransitionTo(Campaign::STATUS_QUEUED))->toBeTrue(); + + $c->transitionTo(Campaign::STATUS_QUEUED); + expect($c->status)->toBe(Campaign::STATUS_QUEUED); +}); + +it('moderating не пускает назад в running', function () { + $c = moderatingCampaign(Campaign::STATUS_MODERATING); + expect(fn () => $c->transitionTo(Campaign::STATUS_RUNNING)) + ->toThrow(DomainException::class); + expect($c->status)->toBe(Campaign::STATUS_MODERATING); +}); + +it('живой finalize (launched=true) → moderating, а НЕ launched', function () { + config()->set('client_tg.sandbox', false); + $tenant = Tenant::factory()->create(); + $campaign = moderatingQueuedCampaign($tenant->id); + moderatingMockRobot(new RobotResult(ok: true, matched: 437, launched: true, campaignId: '2231140')); + + RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); + + $fresh = Campaign::find($campaign->id); + expect($fresh->status)->toBe(Campaign::STATUS_MODERATING) + ->and($fresh->matched_count)->toBe(437) + ->and($fresh->mts_campaign_id)->toBe('2231140'); +}); + +it('песочница (draft) остаётся draft_ready, не moderating', function () { + config()->set('client_tg.sandbox', true); + $tenant = Tenant::factory()->create(); + $campaign = moderatingQueuedCampaign($tenant->id); + moderatingMockRobot(new RobotResult(ok: true, matched: 400, launched: false, campaignId: 'draft')); + + RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); + + expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY); +}); diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index ea072043..8f0a5001 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -8,6 +8,20 @@ use Illuminate\Support\Facades\DB; abstract class TestCase extends BaseTestCase { + /** + * RefreshDatabase's migrate:fresh drops tables but NOT Postgres types by + * default. Our initial schema is loaded raw from db/schema.sql, whose + * `CREATE TABLE`s implicitly define composite row-types; when a table drop + * is blocked by a lingering dependency, its row-type survives db:wipe and + * the next schema.sql reload aborts on a duplicate-type error + * («legal_entities … уже существует»), leaving a PARTIAL schema — the + * long-standing intermittent «migrate:fresh иногда прерывается» flake + * (site_events/client_tg_tariffs randomly missing). Dropping types on every + * refresh clears those leftovers and makes migrate:fresh deterministic. + * Testing-only (APP_ENV=testing) — no production impact. + */ + protected $dropTypes = true; + protected function setUp(): void { parent::setUp();