Files
portal/app/tests/Feature/Advertising/CampaignModerationAndStopTest.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

275 lines
11 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\SyncCampaignModerationJob;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
// Джоб намеренно обходит running/pending-кампании ВСЕХ тенантов — без изоляции
// между кейсами кампания из одного теста осталась бы видна следующему.
uses(RefreshDatabase::class);
// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
// второму соединению до commit'а.
uses(SharesSupplierPdo::class);
// Единица модерации — баннер набора. Один баннер = «отклонены ВСЕ» → кампания rejected.
it('модерация: единственное объявление REJECTED помечает кампанию rejected (passthrough)', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 555, 'Status' => 'REJECTED', 'State' => 'OFF', 'StatusClarification' => 'Нет гарантий'],
]]], 200),
]);
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания на модерации',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => 222,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
'yandex_creative_id' => 4555,
'yandex_ad_id' => 555,
'moderation_status' => AdCampaignBanner::MOD_MODERATION,
]);
app(SyncCampaignModerationJob::class)->handle();
$banner->refresh();
expect($banner->moderation_status)->toBe('REJECTED');
expect($banner->moderation_reason)->toBe('Нет гарантий');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED);
});
it('модерация: все ACCEPTED переводит кампанию в running (passthrough)', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 555, 'Status' => 'ACCEPTED', 'State' => 'ON', 'StatusClarification' => null],
]]], 200),
]);
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания на модерации 2',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => 223,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
'yandex_creative_id' => 4555,
'yandex_ad_id' => 555,
'moderation_status' => AdCampaignBanner::MOD_MODERATION,
]);
app(SyncCampaignModerationJob::class)->handle();
$banner->refresh();
expect($banner->moderation_status)->toBe('ACCEPTED');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
});
it('AdvertisingStopped ставит кампанию тенанта на паузу через реальный слушатель', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => []], 200),
]);
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания в работе',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 224,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
// Без Event::fake — доказываем, что слушатель реально ЗАРЕГИСТРИРОВАН и сработал.
event(new AdvertisingStopped($tenant->id));
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/json/v5/campaigns')
&& ($request->data()['method'] ?? null) === 'suspend';
});
});
it('AdvertisingStopped не трогает кампанию чужого тенанта (явный tenant-фильтр)', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => []], 200),
]);
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$campaignA = AdCampaign::create([
'tenant_id' => $tenantA->id,
'name' => 'Кампания тенанта A',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 225,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
$campaignB = AdCampaign::create([
'tenant_id' => $tenantB->id,
'name' => 'Кампания тенанта B',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 226,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
event(new AdvertisingStopped($tenantA->id));
$campaignA->refresh();
$campaignB->refresh();
expect($campaignA->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
expect($campaignB->status)->toBe(AdCampaign::STATUS_RUNNING);
});
// ВЫХОД 2 — Яндекс отклонил ВЕСЬ набор объявлений. Показов не было, деньги не
// тратились → заморозка возвращается клиенту полностью. Живое объявление в наборе
// разморозку не запускает — это проверяет SyncCampaignModerationJobTest.
it('ВЫХОД 2: отклонение модерацией снимает заморозку кампании', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 557, 'Status' => 'REJECTED', 'State' => 'OFF', 'StatusClarification' => 'Не та тематика'],
]]], 200),
]);
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '3000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания под отказ',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => 227,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
AdCampaignBanner::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
'yandex_creative_id' => 4557,
'yandex_ad_id' => 557,
'moderation_status' => AdCampaignBanner::MOD_MODERATION,
]);
$svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
app(SyncCampaignModerationJob::class)->handle();
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED)
->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00');
});
// ВЫХОД 3 — деньги кончились, вся реклама тенанта заглушена. Держать заморозку
// на мёртвой кампании бессмысленно — она только занижает свободный остаток.
it('ВЫХОД 3: остановка без средств снимает заморозку кампании', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => []], 200),
// 🔴 С 04.08.2026 остановка сперва платит за уже показанное и только потом
// отпускает заморозку (AdStopNoFundsChargesFirstTest). Значит миру теста нужен
// ответ Директа про показы: без него портал НАМЕРЕННО не отпустит деньги —
// отпустить, не заплатив, значит подарить показы. Ноль показов = платить не за что.
'*/json/v5/reports' => Http::response('0', 200),
]);
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '3000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания без денег',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 228,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
$svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
event(new AdvertisingStopped($tenant->id));
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS)
->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00');
});