id). Гейт !sandbox везде — в песочнице * заморозки не было, release бросил бы firstOrFail. Списание по факту — задача 2.4. * * Все тесты по умолчанию в БОЕВОМ режиме (sandbox=false) — проверяем денежный путь. * Синтетические номера 7999… — реальные НИКОГДА. */ beforeEach(function () { config()->set('client_tg.sandbox', false); }); /** Кампания-список заданного статуса с двумя фейковыми номерами. */ function refundCampaign(int $tenantId, string $status, string $budgetCap = '500.00'): Campaign { $campaign = Campaign::query()->create([ 'tenant_id' => $tenantId, 'status' => $status, 'ad_text' => 'Приходите к нам за услугой', 'ad_link' => 'https://example.test/promo', 'ord_category' => 'Размещение рекламы', 'budget_cap_rub' => $budgetCap, '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; } /** Подменяет раннер моком, возвращающим $result. */ function refundMockRobot(RobotResult $result): void { $mock = Mockery::mock(TelegramRobotRunner::class); $mock->shouldReceive('run')->andReturn($result); app()->instance(TelegramRobotRunner::class, $mock); } it('отказ робота возвращает бронь (frozen→0, balance цел)', function () { $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение'); $campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '500.00'); app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00'); refundMockRobot(RobotResult::failed('Канал недоступен', 'upload')); RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); $fresh = Campaign::find($campaign->id); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($fresh->status)->toBe(Campaign::STATUS_FAILED) ->and((string) $wallet->frozen_rub)->toBe('0.00') ->and((string) $wallet->balance_rub)->toBe('1000.00'); }); it('в песочнице отказ робота НЕ трогает кошелёк (бронь не возвращается — её и нет)', function () { config()->set('client_tg.sandbox', true); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение'); $campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '500.00'); app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00'); refundMockRobot(RobotResult::failed('Канал недоступен', 'upload')); RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect((string) $wallet->frozen_rub)->toBe('500.00'); }); it('cancel из draft возвращает бронь и ставит cancelled', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $this->actingAs($user); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение'); $campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00'); app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '300.00'); $this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel") ->assertOk() ->assertJsonPath('status', Campaign::STATUS_CANCELLED); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect((string) $wallet->frozen_rub)->toBe('0.00') ->and((string) $wallet->balance_rub)->toBe('1000.00'); }); it('cancel из running запрещён 422, статус остаётся running', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $this->actingAs($user); $campaign = refundCampaign($tenant->id, Campaign::STATUS_RUNNING, '300.00'); $this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")->assertStatus(422); expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_RUNNING); }); it('cancel в песочнице не трогает кошелёк (frozen сохраняется)', function () { config()->set('client_tg.sandbox', true); $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $this->actingAs($user); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение'); $campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00'); app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '300.00'); $this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel") ->assertOk() ->assertJsonPath('status', Campaign::STATUS_CANCELLED); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect((string) $wallet->frozen_rub)->toBe('300.00'); });