create([ 'tenant_id' => $tenantId, 'status' => Campaign::STATUS_MODERATING, 'mts_campaign_id' => $mtsId, 'ad_text' => 'Мод-стак-тест', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => '1000.00', 'audience_kind' => Campaign::AUDIENCE_LIST, 'planned_count' => 0, 'estimated_cost_rub' => '0.00', 'created_by' => 1, ]); if ($ageHours > 0) { DB::table('client_tg_campaigns')->where('id', $c->id) ->update(['updated_at' => now()->subHours($ageHours)]); } return $c; } function modStuckWallet(int $tenantId, int $campaignId, string $balance, string $freeze): void { AdWallet::query()->create(['tenant_id' => $tenantId, 'balance_rub' => $balance, 'frozen_rub' => '0.00']); app(AdWalletService::class)->freeze($tenantId, 'telegram', 'campaign', $campaignId, $freeze); } it('moderating допускает переход в needs_review', function () { $t = Tenant::factory()->create(); $c = modStuckCampaign($t->id, '2231160', 0); expect($c->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW))->toBeTrue(); }); it('moderating дольше окна → needs_review, бронь НЕ возвращается', function () { config()->set('client_tg.sandbox', false); config()->set('client_tg.moderation_stuck_hours', 24); $t = Tenant::factory()->create(); $c = modStuckCampaign($t->id, '2231161', ageHours: 25); modStuckWallet($t->id, $c->id, '5000.00', '1000.00'); SweepStuckTelegramCampaignsJob::dispatchSync(); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW) ->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00') ->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_ACTIVE); }); it('свежая moderating (в пределах окна) не трогается', function () { config()->set('client_tg.sandbox', false); config()->set('client_tg.moderation_stuck_hours', 24); $t = Tenant::factory()->create(); $c = modStuckCampaign($t->id, '2231162', ageHours: 2); // 2ч < 24ч SweepStuckTelegramCampaignsJob::dispatchSync(); expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING); });