b7bfe5a46f
Кампания 713175197 третьи сутки не показывалась при замороженных у клиента деньгах, а программный интерфейс на всех уровнях рапортовал «Идут показы». Правду отдала только подпись на экране кабинета: «Для показа в заданных регионах предоставьте документы» — 13 объявлений придержаны, 2 отклонены. 1. Яндекс отвечает машине ПО-АНГЛИЙСКИ, если не попросить русский. Замер боевым ключом, два одинаковых запроса подряд: без заголовка «Rejected at moderation.», с Accept-Language ru «Отклонено на модерации.». Английская строка уезжала клиенту в переписку и письмом как пояснение модератора, честная заглушка «причину выясняем» не срабатывала никогда, а на следующем обходе английская строка ложилась ПОВЕРХ доклада разведчика — проверено по боевой ленте: 30.07 в 15:02 робот принёс полную причину, в 17:00 её накрыло. Лечение: спрашиваем язык явно плюс второй заслон — английские отписки узнаются как отписки. 2. Отказ включения Яндекс кладёт в Warnings, а код смотрел только в Errors. Ответ целиком: ResumeResults с Warnings 10201 «Объявление не остановлено» при пустом Errors. Исключения нет, портал считал, что справился: двое суток обход каждые два часа поднимал 13 объявлений, ноль записей в журнале. Лечение: resumeAds возвращает, кого включить не дали. 3. Новый исход «принято, но придержано» порталу не был известен вовсе. Вердикт ACCEPTED, ярлыка «Отклонено» нет, разведчик не ходил — клиент видел «Идут показы» при нулевых показах. Теперь по отказу включения клиенту идёт сообщение «Яндекс принял объявление, но пока не показывает его. Причину выясняем» — текст выбран владельцем — и туда же едет разведка. Ловушка, обойденная по дороге: включение спрашиваем ДО разбора объявлений. Наоборот — сообщение о придержке легло бы поверх доклада разведчика, и обход начал бы чередовать их по кругу: защита от дублей смотрит на последнее сообщение. Замеры. Восемь новых сторожей, все написаны ДО починки и падали именно на живых данных. Отдельный сторож на противоположный случай — включённому объявлению разведку не заводим — был зелёным с самого начала. Портал 4083 теста, 4043 прошло, 16 упало: те же шесть давних классов и ровно те же числа, что до работы, было 4069/4029/16. Плюс 14 тестов — ровно новые. Pint и Larastan по изменённым файлам чистые. Главный урок записан в cabinet-flow.md §7.9: в §7.7 лежит правдивый РУССКИЙ ответ Яндекса, снятый ДРУГИМ инструментом, который язык просил. Разбор отписок построили по показаниям прибора, которым продукт не пользуется. Мерить надо той же дорогой, по которой ходит боевой код. На боевой НЕ выкатывалось. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
650 lines
34 KiB
PHP
650 lines
34 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\SyncCampaignModerationJob;
|
||
use App\Models\AdCampaign;
|
||
use App\Models\AdCampaignBanner;
|
||
use App\Models\AdCampaignMessage;
|
||
use App\Models\AdCreativeJob;
|
||
use App\Models\AdWallet;
|
||
use App\Models\Tenant;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use App\Services\Advertising\CampaignMessageService;
|
||
use App\Services\Advertising\CreativeJobService;
|
||
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');
|
||
});
|
||
|
||
/*
|
||
* Живой ответ Яндекса на отклонённое медийное объявление — снят 28.07.2026 боевым ключом:
|
||
*
|
||
* { "Status": "REJECTED", "StatusClarification": "\nОтклонено на модерации." }
|
||
*
|
||
* Причину Яндекс машине НЕ говорит: на экране кабинета в этот же момент висит
|
||
* «Нет предупреждения: финансовые услуги» и абзац пояснения. Тесты ниже написаны
|
||
* на настоящем ответе, а не на выдуманном: именно на выдуманном дефект и прятался.
|
||
*/
|
||
|
||
it('немой ответ Яндекса не оставляет клиента без объяснения', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
|
||
['Id' => 7001, 'State' => 'OFF', 'StatusClarification' => "\nОтклонено на модерации.", 'Status' => 'REJECTED'],
|
||
]]])]);
|
||
|
||
[$campaign, $banners] = makeModeratedCampaignWithBanners([7001]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
$reason = (string) AdCampaignBanner::find($banners[0]->id)->moderation_reason;
|
||
|
||
// Подпись под ярлыком «Отклонено» берётся как ПЕРВАЯ строка причины. У настоящего
|
||
// ответа Яндекса первый знак — перенос строки, поэтому клиент видел пустоту.
|
||
expect($reason)->not->toStartWith("\n")
|
||
->and(explode("\n", $reason)[0])->not->toBe('')
|
||
->and($reason)->toContain('Яндекс отклонил');
|
||
});
|
||
|
||
it('в ленту вместо отписки Яндекса ложится понятное клиенту сообщение', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
|
||
['Id' => 7002, 'State' => 'OFF', 'StatusClarification' => "\nОтклонено на модерации.", 'Status' => 'REJECTED'],
|
||
]]])]);
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([7002]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
$body = (string) AdCampaignMessage::where('campaign_id', $campaign->id)
|
||
->where('author', AdCampaignMessage::AUTHOR_YANDEX)->value('body');
|
||
|
||
expect($body)->not->toBe('Отклонено на модерации.')
|
||
->and($body)->toContain('причину');
|
||
});
|
||
|
||
it('содержательную причину Яндекса не подменяем своими словами', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
|
||
['Id' => 7003, 'State' => 'OFF', 'StatusClarification' => 'Нет предупреждения: финансовые услуги', 'Status' => 'REJECTED'],
|
||
]]])]);
|
||
|
||
[$campaign, $banners] = makeModeratedCampaignWithBanners([7003]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
expect(AdCampaignBanner::find($banners[0]->id)->moderation_reason)
|
||
->toBe('Нет предупреждения: финансовые услуги');
|
||
|
||
$body = (string) AdCampaignMessage::where('campaign_id', $campaign->id)
|
||
->where('author', AdCampaignMessage::AUTHOR_YANDEX)->value('body');
|
||
expect($body)->toBe('Нет предупреждения: финансовые услуги');
|
||
});
|
||
|
||
it('лишние переносы у настоящей причины срезаются, текст остаётся целым', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
|
||
['Id' => 7004, 'State' => 'OFF', 'StatusClarification' => "\n Текст на баннере не читается.\nДоработайте макет.\n ", 'Status' => 'REJECTED'],
|
||
]]])]);
|
||
|
||
[$campaign, $banners] = makeModeratedCampaignWithBanners([7004]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
expect(AdCampaignBanner::find($banners[0]->id)->moderation_reason)
|
||
->toBe("Текст на баннере не читается.\nДоработайте макет.");
|
||
});
|
||
|
||
it('принятому объявлению не приписываем отказ, даже когда Яндекс молчит', function () {
|
||
// 🪤 Ловушка подмены: «Яндекс причины не назвал» относится ТОЛЬКО к отказу.
|
||
// У принятого объявления пустое пояснение — норма, и придумывать там нечего.
|
||
configureYandexForModeration();
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
|
||
['Id' => 7005, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
|
||
]]])]);
|
||
|
||
[$campaign, $banners] = makeModeratedCampaignWithBanners([7005]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
expect(AdCampaignBanner::find($banners[0]->id)->moderation_reason)->toBeNull()
|
||
->and(AdCampaignMessage::where('campaign_id', $campaign->id)
|
||
->where('author', AdCampaignMessage::AUTHOR_YANDEX)->count())->toBe(0);
|
||
});
|
||
|
||
/**
|
||
* 🔑 Разведка — единственный способ узнать причину. Программный интерфейс Яндекса на отказ
|
||
* отдаёт «Отклонено на модерации.» и ни слова больше: проверено живьём боевым ключом
|
||
* 28.07.2026. Пока робот не сходил в кабинет, портал знает только ФАКТ отказа.
|
||
*/
|
||
it('отказ ставит роботу задание разведки по этому объявлению', function () {
|
||
configureYandexForModeration();
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([9101]);
|
||
|
||
Http::fake(['*' => Http::response(['result' => ['Ads' => [[
|
||
'Id' => 9101, 'State' => 'OFF', 'Status' => 'REJECTED',
|
||
'StatusClarification' => "\nОтклонено на модерации.",
|
||
]]]])]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
$job = AdCreativeJob::where('campaign_id', $campaign->id)
|
||
->where('kind', AdCreativeJob::KIND_INSPECT)->first();
|
||
|
||
expect($job)->not->toBeNull()
|
||
->and((int) $job->yandex_ad_id)->toBe(9101)
|
||
->and($job->status)->toBe(AdCreativeJob::STATUS_QUEUED);
|
||
});
|
||
|
||
/**
|
||
* Обход бежит по расписанию, а отказ никуда не девается — объявление остаётся отклонённым,
|
||
* пока клиент его не починит. Без защиты робот ходил бы в кабинет по кругу.
|
||
*/
|
||
it('второй обход второй разведки не заводит', function () {
|
||
configureYandexForModeration();
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([9102]);
|
||
|
||
Http::fake(['*' => Http::response(['result' => ['Ads' => [[
|
||
'Id' => 9102, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => 'Отклонено',
|
||
]]]])]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
expect(AdCreativeJob::where('campaign_id', $campaign->id)
|
||
->where('kind', AdCreativeJob::KIND_INSPECT)->count())->toBe(1);
|
||
});
|
||
|
||
/** Приняли — смотреть не на что. Робот в кабинет не идёт, кампания крутится. */
|
||
it('принятому объявлению разведку не заводим', function () {
|
||
configureYandexForModeration();
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([9103]);
|
||
|
||
Http::fake(['*' => Http::response(['result' => ['Ads' => [[
|
||
'Id' => 9103, 'State' => 'ON', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято',
|
||
]]]])]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0);
|
||
});
|
||
|
||
/**
|
||
* Постановка разведки — вещь второстепенная, а вердикт модерации и возврат денег нет.
|
||
* Беда с очередью заданий не должна стоить клиенту незаписанного отказа и незамороженных
|
||
* обратно денег.
|
||
*/
|
||
it('беда с постановкой разведки не срывает запись вердикта', function () {
|
||
configureYandexForModeration();
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([9104]);
|
||
|
||
$this->app->bind(CreativeJobService::class, fn () => throw new RuntimeException('очередь легла'));
|
||
|
||
Http::fake(['*' => Http::response(['result' => ['Ads' => [[
|
||
'Id' => 9104, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => 'Отклонено',
|
||
]]]])]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
expect($campaign->banners()->first()->moderation_status)->toBe(AdCampaignBanner::MOD_REJECTED)
|
||
->and($campaign->refresh()->status)->toBe(AdCampaign::STATUS_REJECTED);
|
||
});
|
||
|
||
/**
|
||
* Рубильник Директа выключен — в Яндекс не ходим и роботу ничего не поручаем.
|
||
* Проверяем ОБА: без второго условия выключенный рубильник всё равно наплодил бы заданий,
|
||
* которые робот потом понёс бы в живой кабинет.
|
||
*/
|
||
it('рубильник выключен — ни обращения к Яндексу, ни задания роботу', function () {
|
||
config(['services.yandex_direct.enabled' => false]);
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([9105]);
|
||
|
||
Http::fake(['*' => Http::response(['result' => ['Ads' => [[
|
||
'Id' => 9105, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => 'Отклонено',
|
||
]]]])]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
Http::assertNothingSent();
|
||
expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0)
|
||
->and($campaign->banners()->first()->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION);
|
||
});
|
||
|
||
/**
|
||
* 🪤 Ловушка, вскрытая разведкой. Порядок в жизни такой: обход кладёт клиенту «Яндекс
|
||
* причину не назвал, выясняем» → робот приносит НАСТОЯЩУЮ причину → следующий обход
|
||
* снова кладёт заглушку, ведь последним в ленте лежит уже доклад робота.
|
||
*
|
||
* Клиент, только что прочитавший настоящую причину, получил бы поверх неё «причину
|
||
* не назвали» и ещё одно письмо. Заглушку показываем ОДИН раз — пока сказать нечего.
|
||
*
|
||
* 🔴 Кампания тут с ДВУМЯ объявлениями: одно приняли, одно отклонили. Иначе сценария
|
||
* не существует вовсе — при отказе ВСЕХ объявлений кампания уходит в «отклонена»,
|
||
* и обход её больше не берёт. Первая версия этого теста была пустышкой ровно поэтому:
|
||
* вырезание защиты её не роняло.
|
||
*/
|
||
it('после доклада разведки заглушка «причину выясняем» в ленту не возвращается', function () {
|
||
configureYandexForModeration();
|
||
|
||
[$campaign, $banners] = makeModeratedCampaignWithBanners([9106, 9108]);
|
||
$otkloneny = $banners->first();
|
||
|
||
$otvet = ['result' => ['Ads' => [
|
||
['Id' => 9106, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => '
|
||
Отклонено на модерации.'],
|
||
['Id' => 9108, 'State' => 'ON', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято'],
|
||
]]];
|
||
|
||
Http::fake(['*' => Http::response($otvet)]);
|
||
|
||
// Первый обход — заглушка. Кампания остаётся работающей: одно объявление принято.
|
||
(new SyncCampaignModerationJob)->handle();
|
||
expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
|
||
|
||
// Робот сходил и принёс настоящую причину.
|
||
app(CampaignMessageService::class)->postFromRobot(
|
||
$campaign, (int) $otkloneny->id, 'Нет предупреждения: финансовые услуги',
|
||
);
|
||
|
||
// Второй обход — заглушке в ленте больше не место.
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
$bodies = AdCampaignMessage::where('campaign_id', $campaign->id)
|
||
->where('banner_id', $otkloneny->id)
|
||
->orderBy('id')->pluck('body')->all();
|
||
|
||
expect($bodies)->toHaveCount(2)
|
||
->and($bodies[1])->toContain('финансовые услуги');
|
||
});
|
||
|
||
/**
|
||
* Обратная сторона: пока Яндекс молчит, а робот ещё не сходил, заглушка нужна —
|
||
* иначе под ярлыком «Отклонено» у клиента будет пустота.
|
||
*/
|
||
it('пока разведка не сходила, заглушка клиенту всё-таки показывается', function () {
|
||
configureYandexForModeration();
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([9107]);
|
||
|
||
Http::fake(['*' => Http::response(['result' => ['Ads' => [[
|
||
'Id' => 9107, 'State' => 'OFF', 'Status' => 'REJECTED',
|
||
'StatusClarification' => '
|
||
Отклонено на модерации.',
|
||
]]]])]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
$first = AdCampaignMessage::where('campaign_id', $campaign->id)->first();
|
||
|
||
expect($first)->not->toBeNull()
|
||
->and($first->body)->toContain('причину не назвал');
|
||
});
|
||
|
||
/**
|
||
* 🔴 Модерация приняла — это ещё НЕ показ. У объявления два независимых признака:
|
||
* вердикт (Status) и включён ли показ (State). Созданное программой объявление
|
||
* рождается выключенным, и включение кампании его не поднимает.
|
||
*
|
||
* Живая приёмка 31.07.2026: кампания принята и включена, Яндекс пишет «Идут показы»,
|
||
* а внутри все 15 объявлений выключены — показов ноль при замороженных деньгах клиента.
|
||
*/
|
||
it('включает принятые объявления, которые Яндекс держит выключенными', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
|
||
['Id' => 7701, 'State' => 'OFF', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
|
||
['Id' => 7702, 'State' => 'OFF', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
|
||
]]])]);
|
||
|
||
makeModeratedCampaignWithBanners([7701, 7702]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
Http::assertSent(function ($request) {
|
||
$data = $request->data();
|
||
|
||
return ($data['method'] ?? null) === 'resume'
|
||
&& ($data['params']['SelectionCriteria']['Ids'] ?? []) === [7701, 7702];
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Обратная сторона: лишний раз дёргать Яндекс нельзя. Объявление, которое уже
|
||
* показывается, включать не за чем — иначе на каждом обходе улетал бы холостой запрос.
|
||
*/
|
||
it('не дёргает включение у объявления, которое и так показывается', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
|
||
['Id' => 7703, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
|
||
]]])]);
|
||
|
||
makeModeratedCampaignWithBanners([7703]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
Http::assertNotSent(fn ($request) => ($request->data()['method'] ?? null) === 'resume');
|
||
});
|
||
|
||
/** Отклонённое объявление включать нельзя: показывать его Яндекс всё равно не будет. */
|
||
it('не включает отклонённое объявление', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
|
||
['Id' => 7704, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'],
|
||
]]])]);
|
||
|
||
makeModeratedCampaignWithBanners([7704]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
Http::assertNotSent(fn ($request) => ($request->data()['method'] ?? null) === 'resume');
|
||
});
|
||
|
||
/**
|
||
* 🔴 Слепое пятно, вскрытое заходом в кабинет 01.08.2026: Яндекс объявление ПРИНЯЛ,
|
||
* но показывать не даёт. Программе он про это не говорит ни слова — `ads.get` отвечает
|
||
* бодрым `ACCEPTED`, и портал считает, что всё хорошо. Правда живёт только подписью
|
||
* на экране кабинета: «Для показа в заданных регионах предоставьте документы».
|
||
*
|
||
* Единственный признак, доступный машине, — отказ на команду включения, и приходит он
|
||
* «предупреждением», не «ошибкой»:
|
||
*
|
||
* {"ResumeResults":[{"Id":...,"Warnings":[{"Code":10201,
|
||
* "Message":"Объявление не остановлено"}],"Errors":[]}]}
|
||
*
|
||
* На бою это шло двое суток: обход каждые два часа, тринадцать объявлений, ноль показов,
|
||
* ноль слов клиенту — а в списке кампаний у него «Идут показы».
|
||
*/
|
||
it('придержанное объявление не выдаёт за работающее', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(function ($request) {
|
||
return ($request->data()['method'] ?? null) === 'resume'
|
||
? Http::response(['result' => ['ResumeResults' => [
|
||
['Id' => 9201, 'Warnings' => [['Code' => 10201, 'Message' => 'Объявление не остановлено']], 'Errors' => []],
|
||
]]])
|
||
: Http::response(['result' => ['Ads' => [
|
||
['Id' => 9201, 'State' => 'OFF', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято на модерации.'],
|
||
]]]);
|
||
});
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([9201]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
// Клиенту сказали правду сразу, не дожидаясь разведчика.
|
||
$body = (string) AdCampaignMessage::where('campaign_id', $campaign->id)
|
||
->where('author', AdCampaignMessage::AUTHOR_YANDEX)->orderByDesc('id')->first()?->body;
|
||
|
||
expect($body)->toContain('не показывает')
|
||
->and($body)->not->toContain('Принято на модерации');
|
||
|
||
// И послали робота выяснить настоящую причину — своими словами Яндекс её не отдаёт.
|
||
expect(AdCreativeJob::where('campaign_id', $campaign->id)
|
||
->where('kind', AdCreativeJob::KIND_INSPECT)
|
||
->where('yandex_ad_id', 9201)->count())->toBe(1);
|
||
});
|
||
|
||
/**
|
||
* Обратная сторона того же сторожа: включили — значит работает, и пугать клиента нечем.
|
||
* Без этой проверки «чинить» можно было бы объявлением любого исхода.
|
||
*/
|
||
it('включённому объявлению разведку не заводит и клиента не пугает', function () {
|
||
configureYandexForModeration();
|
||
Http::fake(function ($request) {
|
||
return ($request->data()['method'] ?? null) === 'resume'
|
||
? Http::response(['result' => ['ResumeResults' => [['Id' => 9202, 'Warnings' => [], 'Errors' => []]]]])
|
||
: Http::response(['result' => ['Ads' => [
|
||
['Id' => 9202, 'State' => 'OFF', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято на модерации.'],
|
||
]]]);
|
||
});
|
||
|
||
[$campaign] = makeModeratedCampaignWithBanners([9202]);
|
||
|
||
(new SyncCampaignModerationJob)->handle();
|
||
|
||
expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0)
|
||
->and((string) AdCampaignBanner::where('campaign_id', $campaign->id)->first()?->moderation_reason)
|
||
->not->toContain('не показывает');
|
||
});
|