16091204e0
У объявления два независимых признака: вердикт модерации (Status) и идёт ли показ (State). Созданное программой объявление рождается выключенным, и включение кампании его не поднимает — команды поднять сами объявления в коде не было вообще. Живая приёмка 31.07.2026, кампания 713175197: принята и включена, Яндекс на уровне кампании пишет «Идут показы», а внутри все 15 объявлений выключены. Показов ноль при замороженных у клиента 3333.36 руб. Опросчик теперь чинит по СОСТОЯНИЮ из ответа Яндекса, а не по переходу статуса: собирает объявления со статусом «принято» и выключенным показом и зовёт ads.resume. Так самовосстанавливается и уже запущенная кампания — по переходу она осталась бы выключенной навсегда, потому что «принято» у нас записано давно. Состояние Яндекс присылал в каждом ответе и раньше, опросчик его просто выбрасывал. Проверено вырезанием: без починки новый тест краснеет. Реклама 349 из 349.
583 lines
30 KiB
PHP
583 lines
30 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');
|
||
});
|