true, 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', 'services.yandex_direct.token' => 'DIRTOKEN', ]); } /** * Кампания на модерации с набором баннеров, у каждого свой номер объявления. * * @param list $adIds * @return array{0: AdCampaign, 1: Collection} */ 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('причину не назвал'); });