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>
91 lines
5.0 KiB
PHP
91 lines
5.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Services\Advertising\YandexDirectClient;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Tests\TestCase;
|
||
|
||
uses(TestCase::class);
|
||
|
||
it('adds a retargeting list referencing an audience segment', function () {
|
||
Http::fake([
|
||
'*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 777]]]], 200),
|
||
]);
|
||
|
||
$client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN');
|
||
$id = $client->addRetargetingList('Лидерра кампания #5', 58034825);
|
||
|
||
expect($id)->toBe(777);
|
||
Http::assertSent(function ($req) {
|
||
$body = $req->data();
|
||
|
||
return str_ends_with($req->url(), '/json/v5/retargetinglists')
|
||
&& $req->hasHeader('Authorization', 'Bearer TESTTOKEN')
|
||
&& $body['method'] === 'add'
|
||
&& $body['params']['RetargetingLists'][0]['Type'] === 'AUDIENCE'
|
||
&& $body['params']['RetargetingLists'][0]['Rules'][0]['Operator'] === 'ALL'
|
||
// Сегмент Аудиторий указывается с приставкой «20» — см. addRetargetingList().
|
||
&& $body['params']['RetargetingLists'][0]['Rules'][0]['Arguments'][0]['ExternalId'] === 2058034825;
|
||
});
|
||
});
|
||
|
||
it('surfaces an API error as RuntimeException', function () {
|
||
Http::fake(['*/json/v5/campaigns' => Http::response(['error' => ['error_string' => 'Нет доступа к API', 'error_code' => 53]], 200)]);
|
||
$client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T');
|
||
expect(fn () => $client->addCpmBannerCampaign('C', '2026-08-01', '2026-08-14', 72000000, 90000000, 3, 14))->toThrow(RuntimeException::class);
|
||
});
|
||
|
||
/**
|
||
* 🔴 Язык ответа спрашиваем явно. Без заголовка Яндекс отвечает ПО-АНГЛИЙСКИ, и эти слова
|
||
* уезжают клиенту в переписку и письмом: «Rejected at moderation.», «Accepted at moderation.».
|
||
*
|
||
* Замер 01.08.2026, боевой ключ, два одинаковых запроса подряд по объявлению 17787641507:
|
||
*
|
||
* без заголовка → StatusClarification = "\nRejected at moderation."
|
||
* Accept-Language: ru → StatusClarification = "\nОтклонено на модерации."
|
||
*
|
||
* 🪤 В `cabinet-flow.md` §7.7 записан русский ответ — и запись не врёт: тот замер делали
|
||
* ДРУГИМ инструментом, который язык просил. Разбор отписок построили по показаниям прибора,
|
||
* которым боевой код не пользуется. Мерить надо той же дорогой, по которой ходит продукт.
|
||
*/
|
||
it('asks Yandex to answer in Russian', function () {
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => []]], 200)]);
|
||
|
||
(new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'))->getAdsModeration([1]);
|
||
|
||
Http::assertSent(fn ($req) => $req->hasHeader('Accept-Language', 'ru'));
|
||
});
|
||
|
||
/**
|
||
* 🔴 Отказ включения приходит в «предупреждениях», а не в «ошибках» — и молча теряется.
|
||
*
|
||
* Замер 01.08.2026, боевой ключ, объявление 17787641513, ответ целиком:
|
||
*
|
||
* {"result":{"ResumeResults":[{"Id":17787641513,
|
||
* "Warnings":[{"Code":10201,"Message":"Объявление не остановлено"}],"Errors":[]}]}}
|
||
*
|
||
* `Errors` пуст, значит проверка по ошибкам молчит и портал считает, что справился.
|
||
* На бою это шло двое суток: обход каждые два часа, около двадцати попыток включить
|
||
* 13 объявлений, ноль записей в журнале, показов ноль. Клиент при этом видит «Идут показы».
|
||
*/
|
||
it('reports ads Yandex refused to resume instead of failing silently', function () {
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['ResumeResults' => [
|
||
['Id' => 111, 'Warnings' => [['Code' => 10201, 'Message' => 'Объявление не остановлено']], 'Errors' => []],
|
||
['Id' => 222, 'Warnings' => [], 'Errors' => []],
|
||
]]], 200)]);
|
||
|
||
$otkazy = (new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'))->resumeAds([111, 222]);
|
||
|
||
expect($otkazy)->toBe([111 => 'Объявление не остановлено']);
|
||
});
|
||
|
||
/** Всех включили — отказов нет, и это не путается с «мы не спросили». */
|
||
it('returns no refusals when every ad resumed', function () {
|
||
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['ResumeResults' => [
|
||
['Id' => 111, 'Warnings' => [], 'Errors' => []],
|
||
]]], 200)]);
|
||
|
||
expect((new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'))->resumeAds([111]))->toBe([]);
|
||
});
|