Files
portal/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
T
Дмитрий 652b708203 fix(реклама): остановка рекламы делалась дважды; добраны все бесплатные карточки приёмки
Из 29 карточек приёмки Яндекса закрыты 27. Остались две, и обе ждут не работы,
а события: живого отказа всех объявлений разом и платных комбинаций Я1-Я9.

Нашлась вторая за день тихая беда. Слушатель остановки рекламы был подписан на
событие ДВАЖДЫ: Laravel 11 сам находит слушателей в app/Listeners по типу
параметра handle(), а в провайдере он вдобавок прописан руками. Остановка не
бесплатная: по каждой кампании клиента она идёт в Яндекс за числом показов,
платит за показанное и глушит кампанию, - и всё это делалось по два раза.
Деньги уцелели по случайности: списание идемпотентно по ключу показов, снятие
заморозки тоже, а второй заход не находил кампаний, первый уже увёл их из
работы. Ручная подписка убрана.

Найдено не глазами, а вырезанием: новый сквозной сторож Я-Б3 не покраснел,
когда подписку отключили. Если бы регистрация была одна, он обязан был.

Замерено на живой кампании #13, без единого рубля сверху:
- Я-Д3: показано 21, списано 10,50 руб = 21 x 500/1000; по смете было бы 850;
- Я-М2: кампания running, заморозка 839,50 = 850 - 10,50, показы идут;
- Я-Х4: задача из очереди под боевой ролью без контекста клиента видит
  1 кампанию служебным путём и 0 обычным - тот самый молчаливый ноль,
  ради которого служебный путь и заведён.

Новые сторожа, каждый принят красным:
- Я-Б2: денег ровно на смету - запуск проходит, вторая кампания уже нет;
- Я-Б3: деньги кончились - кампания РЕАЛЬНО встала и владельцу ушло письмо;
- Я-М4: пока вердикта нет, не двигаются ни заморозка, ни списание;
- Я-М5: после «Исправить» и повторного запуска заморозка ОДНА;
- Я-Х2: десять подделок отбиты, чужой номер клиента ничего не меняет;
- Я-Д1: заморозка равна смете копейка в копейку, включая некруглую;
- Я-Д2: сплошной обход всех семи читающих путей - наценка не видна нигде
  (прежний сторож проверял один путь из семи);
- остановка рекламы подписана ровно один раз.

Прогоны: 424 теста рекламы зелёные, статанализ 0, формат чистый.

Журнал результатов приёмки дописан разделом на 05.08 - что закрыто, чем
закрыто и чего ждём.
2026-08-05 09:52:10 +03:00

241 lines
12 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\Mail\AdWalletLowBalanceNotification;
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 Illuminate\Support\Facades\Mail;
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);
});
/**
* Я-Б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');
});