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('останавливает рекламу ВНЕ денежной транзакции — замок не висит на сетевом запросе', function () { configureYandexDirectForSpend(); Http::fake([ '*/json/v5/reports' => Http::response('2500', 200), '*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 222]]]]), ]); $tenant = Tenant::factory()->create(); // 400 ₽ на счету, 200 ₽ заперты под ЧУЖУЮ кампанию; списание 300 ₽ уводит // свободный остаток в минус → стоп всей рекламы тенанта. 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'); $urovenVMomentOstanovki = null; Event::listen(AdvertisingStopped::class, function () use (&$urovenVMomentOstanovki): void { $urovenVMomentOstanovki = DB::transactionLevel(); }); $urovenDo = DB::transactionLevel(); app(ChargeCampaignSpendJob::class)->handle(); expect($urovenVMomentOstanovki)->not->toBeNull() ->and($urovenVMomentOstanovki)->toBe($urovenDo); }); /** * Я-Б3 приёмочного листа — деньги кончились на ходу, вся реклама встаёт. * * Сторож выше проверяет, что часовой обход ВЫПУСКАЕТ событие «стоп рекламе», и на этом * останавливается: событие там подменено (`Event::fake`), дальше него ничего не идёт. * А клиенту важно не событие, а последствия — встала ли кампания на самом деле и узнал * ли он об этом. * * 🔴 Между «событие выпущено» и «кампания встала» лежит вся цепочка: слушатель, поход в * Яндекс за числом показов, плата за показанное, возврат остатка и письмо владельцу. * Порвись она в любом месте — сторож с подменённым событием остался бы зелёным, а * кампания продолжала бы крутиться за деньги, которых у клиента нет. * * Здесь событие НЕ подменяется: проверяется конец цепочки — статус «остановлено, нет * денег» на самой кампании и письмо, поставленное в очередь владельцу. */ it('Я-Б3: деньги кончились — кампания РЕАЛЬНО встала и владельцу ушло письмо', function () { Mail::fake(); configureYandexDirectForSpend(); // Часовой обход спрашивает показы, и остановка спрашивает их же — оба ответа отсюда. // 3000 показов × 120 ₽ / 1000 = 360 ₽ списания. fakeYandexImpressionsReport('3000'); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'приёмка'); $campaign = makeRunningCampaign($tenant->id); // Чужой резерв: 200 ₽ заперты под вторую кампанию клиента. После списания 360 ₽ на // счету останется 40 ₽ — меньше запертого, и клиент неплатёжеспособен. app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00'); app(ChargeCampaignSpendJob::class)->handle(); // Конец цепочки: кампания встала именно с причиной «нет денег». expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS); // И клиент об этом узнал — письмо поставлено в очередь. Mail::assertQueued(AdWalletLowBalanceNotification::class); }); 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'); });