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

255 lines
13 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\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');
});