Files
portal/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
T
Дмитрий 80987996ed fix(реклама): мастер не врёт про показы, остановка без денег платит за показанное
Три починки, каждая сперва увидена красной.

1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету:
   чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов
   и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера
   это потолок, до которого кампания почти наверняка не дотянет. Стало
   «не больше 8 150» плюс объяснение: упрётся в список, а не в деньги;
   за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты
   и в сводке перед отправкой.

2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное.
   Списание делает часовая задача, а она берёт только кампании со статусом
   «крутится» — остановленную пропускала навсегда. Показы последнего часа
   уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили
   в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай;
   не узнал число показов — не отпускай вовсе.

3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это
   ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов.
   Вынесено наружу: денежная операция закрывается, и только потом остановка.

Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб
за каждый календарный день и отвечает по-английски на последнем шаге, когда
клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает
заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал:
ею пользуются и запуск, и мастер — две копии однажды разошлись бы.

Сторожа: 7 новых на бэкенде и фронте, каждый принят красным.
Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
2026-08-05 04:26:27 +03:00

199 lines
9.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Events\AdvertisingStopped;
use App\Jobs\ChargeCampaignSpendJob;
use App\Models\AdCampaign;
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
// второму соединению до commit'а. RefreshDatabase раньше не был подключён в
// этом файле — подключаем оба вместе (см. общее правило других Advertising-тестов).
uses(RefreshDatabase::class);
uses(SharesSupplierPdo::class);
function configureYandexDirectForSpend(): void
{
config(['services.yandex_direct.enabled' => 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);
});
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');
});