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'); }); /** * Кампания в суточном списании берётся ПОД ЗАМКОМ строки. * * Идемпотентность списания держится на ключе `yandex-imp:{кампания}:{показы}`, а число * показов приходит из отчёта Директа. Два прогона, начавшихся одновременно (ручной запуск * поверх расписания, повтор упавшей задачи), получат чуть разные числа показов — значит * разные ключи, и уникальный индекс по ключу дубль уже не остановит: клиент будет списан * дважды. Замок строки выстраивает прогоны в очередь: второй увидит уже обновлённый * `charged_client_rub` и спишет только настоящую дельту. * * Настоящую гонку в тесте не поставить, поэтому смотрим на сам запрос: он обязан быть * блокирующим. Уберите `lockForUpdate()` — тест покраснеет. */ it('reads the campaign under a row lock while charging', function () { configureYandexDirectForSpend(); fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); $campaign = makeRunningCampaign($tenant->id); $locked = false; DB::listen(function ($query) use (&$locked, $campaign) { if (str_contains($query->sql, 'ad_campaigns') && str_contains(strtolower($query->sql), 'for update') && in_array($campaign->id, $query->bindings, true)) { $locked = true; } }); app(ChargeCampaignSpendJob::class)->handle(); expect($locked)->toBeTrue(); }); 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'); });