80987996ed
Три починки, каждая сперва увидена красной. 1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету: чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера это потолок, до которого кампания почти наверняка не дотянет. Стало «не больше 8 150» плюс объяснение: упрётся в список, а не в деньги; за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты и в сводке перед отправкой. 2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное. Списание делает часовая задача, а она берёт только кампании со статусом «крутится» — остановленную пропускала навсегда. Показы последнего часа уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай; не узнал число показов — не отпускай вовсе. 3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов. Вынесено наружу: денежная операция закрывается, и только потом остановка. Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб за каждый календарный день и отвечает по-английски на последнем шаге, когда клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал: ею пользуются и запуск, и мастер — две копии однажды разошлись бы. Сторожа: 7 новых на бэкенде и фронте, каждый принят красным. Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
275 lines
11 KiB
PHP
275 lines
11 KiB
PHP
<?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');
|
||
});
|