true]); config(['services.yandex_direct.token' => 'DIRTOKEN']); config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); } /** TSV: накопительное число показов за всё время кампании (ALL_TIME). */ function fakeYandexImpressionsReport(string $tsv = '2500'): void { Http::fake(['*/json/v5/reports' => Http::response($tsv, 200)]); } function makeRunningCampaign(int $tenantId): AdCampaign { return AdCampaign::create([ 'tenant_id' => $tenantId, 'name' => 'Кампания списания', 'status' => AdCampaign::STATUS_RUNNING, 'yandex_campaign_id' => 222, 'audience_days' => 10, 'use_uploaded_list' => true, 'frequency' => 15, 'estimated_impressions' => 10000, 'paid_impressions' => 10000, ]); } it('charges the client for delivered impressions at the flat CPM (client_cpm_rub not set → default 120.00 ₽/1000)', function () { configureYandexDirectForSpend(); fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); $campaign = makeRunningCampaign($tenant->id); app(ChargeCampaignSpendJob::class)->handle(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); // 2500 показов × 120.00 ₽/1000 = 300.00 ₽ клиенту. expect($wallet->balance_rub)->toBe('700.00'); // 1000.00 − 300.00 $tx = AdWalletTransaction::where('tenant_id', $tenant->id) ->where('external_key', "yandex-imp:{$campaign->id}:2500") ->first(); expect($tx)->not->toBeNull() ->and($tx->type)->toBe(AdWalletTransaction::TYPE_CHARGE) ->and($tx->amount_rub)->toBe('-300.00'); }); it('does not double-charge on a second run (idempotent by external_key)', function () { configureYandexDirectForSpend(); fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); makeRunningCampaign($tenant->id); app(ChargeCampaignSpendJob::class)->handle(); $balanceAfterFirst = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub; app(ChargeCampaignSpendJob::class)->handle(); $balanceAfterSecond = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub; expect($balanceAfterSecond)->toBe($balanceAfterFirst) ->and($balanceAfterSecond)->toBe('700.00'); }); it('stops all advertising when the charge leaves the tenant insolvent', function () { Event::fake([AdvertisingStopped::class]); configureYandexDirectForSpend(); fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); // Заморозка ТАЕТ вместе со списанием по своей кампании, поэтому нехватку денег // создаёт ЧУЖОЙ резерв: у клиента запущена вторая кампания с холдом 200.00 ₽, // а первая съедает 300.00 ₽ из 400.00 ₽ → на вторую денег уже нет (100 < 200) // → стоп всей рекламы тенанта. app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест'); $campaign = makeRunningCampaign($tenant->id); app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00'); app(ChargeCampaignSpendJob::class)->handle(); Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === $tenant->id); }); it('does nothing and sends no HTTP when the yandex_direct switch is off', function () { config(['services.yandex_direct.enabled' => false]); fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); makeRunningCampaign($tenant->id); app(ChargeCampaignSpendJob::class)->handle(); Http::assertNothingSent(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->balance_rub)->toBe('1000.00'); });