Files
portal/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php
T

255 lines
13 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Jobs\SyncCampaignModerationJob;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdCampaignMessage;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\CampaignMessageService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
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);
/**
* Рубильник + реквизиты Директа. Своя функция (не общая с CampaignLauncherTest):
* при пофайловом прогоне (paratest) чужой файл в процесс не подгружается.
*/
function configureYandexForModeration(): void
{
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
}
/**
* Кампания на модерации с набором баннеров, у каждого свой номер объявления.
*
* @param list<int> $adIds
* @return array{0: AdCampaign, 1: Collection<int, AdCampaignBanner>}
*/
function makeModeratedCampaignWithBanners(array $adIds, int $yandexCampaignId = 222): array
{
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'C',
'mode' => AdCampaign::MODE_MANUAL,
'audience_days' => 10,
'client_cpm_rub' => '120.00',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => $yandexCampaignId,
]);
$w = 300;
$banners = collect($adIds)->map(function (int $adId) use ($campaign, &$w) {
$w += 10;
return AdCampaignBanner::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'width' => $w,
'height' => 250,
'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$w}x250.jpg",
'bytes' => 1000,
'included' => true,
'yandex_creative_id' => 4000 + $adId,
'yandex_ad_id' => $adId,
'moderation_status' => AdCampaignBanner::MOD_MODERATION,
]);
});
return [$campaign, $banners];
}
it('keeps the campaign running when at least one banner ad is accepted', function () {
configureYandexForModeration();
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 5551, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => 'Текст на баннере', 'Status' => 'REJECTED'],
]]])]);
[$campaign] = makeModeratedCampaignWithBanners([5551, 5552]);
(new SyncCampaignModerationJob)->handle();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
$fresh = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('yandex_ad_id')->get();
expect($fresh[0]->moderation_status)->toBe(AdCampaignBanner::MOD_ACCEPTED)
->and($fresh[1]->moderation_status)->toBe(AdCampaignBanner::MOD_REJECTED)
->and($fresh[1]->moderation_reason)->toBe('Текст на баннере');
});
it('rejects the campaign only when every banner ad is rejected', function () {
configureYandexForModeration();
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 5551, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'],
['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'],
]]])]);
[$campaign] = makeModeratedCampaignWithBanners([5551, 5552]);
(new SyncCampaignModerationJob)->handle();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_REJECTED);
});
it('waits while at least one banner ad is still under moderation', function () {
configureYandexForModeration();
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 5551, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => null, 'Status' => 'MODERATION'],
]]])]);
[$campaign] = makeModeratedCampaignWithBanners([5551, 5552]);
(new SyncCampaignModerationJob)->handle();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION);
});
/**
* Обход модерации идёт по ВСЕМ кампаниям всех клиентов подряд. Значит любая беда на одной
* кампании обязана остаться внутри неё: сорвётся обход — остальные клиенты не узнают, что
* их реклама принята или отклонена, а деньги за отклонённый набор не вернутся.
*
* Здесь беда приходит не из сети (это уже прикрыто), а из САМОГО ответа Яндекса: объявление
* пришло без статуса. Запись пустого статуса упирается в запрет базы, и обход обрывается
* на середине.
*/
it('объявление без статуса не срывает обход остальных кампаний', function () {
configureYandexForModeration();
Http::fake(function ($request) {
$ids = $request->data()['params']['SelectionCriteria']['Ids'] ?? [];
return Http::response(['result' => ['Ads' => in_array(6001, $ids, true)
? [['Id' => 6001, 'State' => 'ON']] // статуса нет вовсе
: [['Id' => 6002, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED']],
]]);
});
[$broken, $brokenBanners] = makeModeratedCampaignWithBanners([6001], 601);
[$healthy] = makeModeratedCampaignWithBanners([6002], 602);
(new SyncCampaignModerationJob)->handle();
// Кампания без статуса осталась ждать — и не утащила за собой соседнюю.
expect($broken->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION)
->and(AdCampaignBanner::find($brokenBanners[0]->id)->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION)
->and($healthy->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
});
/**
* Причина отказа у Яндекса бывает длинной — там перечисляют все претензии модератора списком.
* В нашей колонке 255 знаков. Длинная причина упирается в базу и обрывает тот же обход.
* Причину показываем клиенту, поэтому храним сколько влезает, а не теряем целиком.
*/
it('слишком длинная причина отказа не срывает обход', function () {
configureYandexForModeration();
$longReason = str_repeat('Текст на баннере не читается. ', 40); // 1200 знаков
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 6011, 'State' => 'OFF', 'StatusClarification' => $longReason, 'Status' => 'REJECTED'],
]]])]);
[$campaign, $banners] = makeModeratedCampaignWithBanners([6011], 611);
(new SyncCampaignModerationJob)->handle();
$reason = AdCampaignBanner::find($banners[0]->id)->moderation_reason;
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_REJECTED)
->and(mb_strlen((string) $reason))->toBe(255)
->and($reason)->toStartWith('Текст на баннере не читается.');
});
// ВЫХОД 2 остаётся ровно один: разморозка зовётся только когда отклонены ВСЕ
// объявления набора. Пока живо хоть одно — деньги остаются замороженными.
it('не снимает заморозку, пока принято хотя бы одно объявление набора', function () {
configureYandexForModeration();
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 5561, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
['Id' => 5562, 'State' => 'OFF', 'StatusClarification' => 'Текст на баннере', 'Status' => 'REJECTED'],
]]])]);
[$campaign] = makeModeratedCampaignWithBanners([5561, 5562], 229);
$svc = app(AdWalletService::class);
$svc->topup((int) $campaign->tenant_id, '3000.00', 'yandex', 'тест');
$svc->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, '1200.00');
(new SyncCampaignModerationJob)->handle();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING)
->and(AdWallet::where('tenant_id', $campaign->tenant_id)->first()->frozen_rub)->toBe('1200.00');
});
// --- лента сообщений по кампании -------------------------------------------------
it('причина отказа попадает в ленту сообщений кампании', function () {
configureYandexForModeration();
$long = str_repeat('Претензия модератора. ', 30); // 660 знаков
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 7001, 'State' => 'OFF', 'StatusClarification' => $long, 'Status' => 'REJECTED'],
]]])]);
[$campaign, $banners] = makeModeratedCampaignWithBanners([7001], 701);
(new SyncCampaignModerationJob)->handle();
$message = AdCampaignMessage::where('campaign_id', $campaign->id)->first();
expect($message)->not->toBeNull()
->and($message->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX)
->and($message->banner_id)->toBe($banners[0]->id)
// 🔑 в ленте текст ЦЕЛИКОМ, а в ярлыке — обрезанный до 255
->and(mb_strlen((string) $message->body))->toBe(mb_strlen(trim($long)))
->and(mb_strlen((string) AdCampaignBanner::find($banners[0]->id)->moderation_reason))->toBe(255);
});
/**
* Лента — вещь второстепенная, вердикт модерации и деньги — нет. Если сервис сообщений
* лёг, обход обязан доработать до конца: записать статус КАЖДОГО баннера, довести
* кампанию до «отклонено» и вернуть клиенту замороженные деньги. Без защиты первое же
* падение ленты обрывает разбор кампании на первом баннере — второй остаётся без
* вердикта, кампания зависает в ожидании, а деньги остаются замороженными молча.
*/
it('беда с лентой не срывает вердикт по остальным баннерам и возврат денег', function () {
configureYandexForModeration();
app()->bind(CampaignMessageService::class, fn () => throw new RuntimeException('лента легла'));
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 7002, 'State' => 'OFF', 'StatusClarification' => 'Не подошло', 'Status' => 'REJECTED'],
['Id' => 7003, 'State' => 'OFF', 'StatusClarification' => 'Тоже не подошло', 'Status' => 'REJECTED'],
]]])]);
[$campaign, $banners] = makeModeratedCampaignWithBanners([7002, 7003], 702);
$svc = app(AdWalletService::class);
$svc->topup((int) $campaign->tenant_id, '3000.00', 'yandex', 'тест');
$svc->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, '1200.00');
(new SyncCampaignModerationJob)->handle();
expect(AdCampaignBanner::find($banners[0]->id)->moderation_status)->toBe('REJECTED')
->and(AdCampaignBanner::find($banners[1]->id)->moderation_status)->toBe('REJECTED')
->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_REJECTED)
->and(AdWallet::where('tenant_id', $campaign->tenant_id)->first()->frozen_rub)->toBe('0.00');
});