Http::response(['result' => ['AddResults' => [['Id' => 777]]]], 200), ]); $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); $id = $client->addCpmBannerCampaign( 'Лидерра медийная #1', '2026-07-26', '2026-08-26', 50_000_000, 1_000_000_000, 3, 7, ); expect($id)->toBe(777); Http::assertSent(function ($req) { $body = $req->data(); $campaign = $body['params']['Campaigns'][0]; $strategy = $campaign['CpmBannerCampaign']['BiddingStrategy']; $cpMax = $strategy['Network']['CpMaximumImpressions']; $freq = $campaign['CpmBannerCampaign']['FrequencyCap']; return $body['method'] === 'add' && $campaign['Name'] === 'Лидерра медийная #1' && $campaign['StartDate'] === '2026-07-26' && $campaign['EndDate'] === '2026-08-26' && $strategy['Search']['BiddingStrategyType'] === 'SERVING_OFF' && $strategy['Network']['BiddingStrategyType'] === 'CP_MAXIMUM_IMPRESSIONS' && $cpMax['AverageCpm'] === 50_000_000 && $cpMax['SpendLimit'] === 1_000_000_000 && $cpMax['StartDate'] === '2026-07-26' && $cpMax['EndDate'] === '2026-08-26' && $cpMax['AutoContinue'] === 'NO' && $freq['Impressions'] === 3 && $freq['PeriodDays'] === 7; }); }); it('sends auto continue yes when requested for the cpm banner campaign', function () { Http::fake([ '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 778]]]], 200), ]); $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); $client->addCpmBannerCampaign( 'Лидерра медийная #2', '2026-07-26', '2026-08-26', 50_000_000, 1_000_000_000, 3, 7, 'YES', ); Http::assertSent(function ($req) { $body = $req->data(); return $body['params']['Campaigns'][0]['CpmBannerCampaign']['BiddingStrategy']['Network']['CpMaximumImpressions']['AutoContinue'] === 'YES'; }); }); it('adds an empty cpm banner ad group with an empty keywords object', function () { Http::fake([ '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]], 200), ]); $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); $id = $client->addCpmBannerAdGroup(777, 'Группа медийная', [1, 213]); expect($id)->toBe(555); Http::assertSent(function ($req) { $body = $req->data(); $group = $body['params']['AdGroups'][0]; $matches = $body['method'] === 'add' && $group['Name'] === 'Группа медийная' && $group['CampaignId'] === 777 && $group['RegionIds'] === [1, 213]; // Пустой JSON-объект {}, а не массив [] $decoded = json_decode($req->body()); $matches = $matches && $decoded->params->AdGroups[0]->CpmBannerKeywordsAdGroup instanceof stdClass; return $matches && str_contains($req->body(), '"CpmBannerKeywordsAdGroup":{}'); }); }); it('adds a media audience target without a context bid', function () { Http::fake([ '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 999]]]], 200), ]); $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); $id = $client->addMediaAudienceTarget(555, 58034825); expect($id)->toBe(999); Http::assertSent(function ($req) { $body = $req->data(); $target = $body['params']['AudienceTargets'][0]; return $body['method'] === 'add' && $target['AdGroupId'] === 555 && $target['RetargetingListId'] === 58034825 && ! array_key_exists('ContextBid', $target); }); }); it('adds a cpm banner ad by creative id', function () { Http::fake([ '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]], 200), ]); $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); $id = $client->addCpmBannerAd(555, 4242, 'https://liderra.ru/promo'); expect($id)->toBe(111); Http::assertSent(function ($req) { $body = $req->data(); $ad = $body['params']['Ads'][0]; return $body['method'] === 'add' && $ad['AdGroupId'] === 555 && $ad['CpmBannerAdBuilderAd']['Creative']['CreativeId'] === 4242 && $ad['CpmBannerAdBuilderAd']['Href'] === 'https://liderra.ru/promo'; }); }); it('parses a creative preview into an array with is_adaptive true when YES', function () { Http::fake([ '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [[ 'Id' => 4242, 'Type' => 'HTML5_CREATIVE', 'PreviewUrl' => 'https://yastatic.net/preview/4242', 'ThumbnailUrl' => 'https://yastatic.net/thumb/4242', 'IsAdaptive' => 'YES', 'Width' => 300, 'Height' => 250, ]]]], 200), ]); $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); $preview = $client->getCreativePreview(4242); expect($preview)->toBe([ 'id' => 4242, 'type' => 'HTML5_CREATIVE', 'preview_url' => 'https://yastatic.net/preview/4242', 'thumbnail_url' => 'https://yastatic.net/thumb/4242', 'is_adaptive' => true, 'width' => 300, 'height' => 250, ]); Http::assertSent(function ($req) { $body = $req->data(); return $body['method'] === 'get' && $body['params']['SelectionCriteria']['Ids'] === [4242] && $body['params']['FieldNames'] === ['Id', 'Type', 'PreviewUrl', 'ThumbnailUrl', 'IsAdaptive', 'Width', 'Height']; }); }); it('returns null from creative preview when the creative is not found', function () { Http::fake([ '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]], 200), ]); $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); $preview = $client->getCreativePreview(9999); expect($preview)->toBeNull(); }); it('lists image creative ids with their sizes and ignores video creatives', function () { Http::fake([ '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ ['Id' => 1163441165, 'Type' => 'VIDEO_EXTENSION_CREATIVE', 'Width' => 1080, 'Height' => 1080], ['Id' => 1163586673, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], ['Id' => 1163586680, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], ]]]), ]); $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); // Картиночные креативы Яндекс хранит типом HTML5_CREATIVE даже для загруженных JPG — // проверено живым запросом 27.07.2026. Видео-креативы в набор попадать не должны. expect($client->listImageCreativeIds())->toBe([ 1163586673 => [300, 250], 1163586680 => [728, 90], ]); }); /** * Слепок креативов — это опись всего аккаунта, и Яндекс отдаёт её страницами: больше * десяти тысяч объектов за раз он не отдаст никогда, а признак «отдал не всё» — поле * `LimitedBy` в ответе. Раньше страницы не листались вовсе: слепок молча обрывался на * первой. Дальше опознание креативов сравнивает «до» и «после» — при оборванном слепке * креатив, лежащий за границей страницы, выглядел бы «новым» на каждом заходе. */ it('walks through all pages of the creatives snapshot', function () { $pages = [ // Первая страница: LimitedBy говорит «отдал не всё, продолжай с этого места». ['result' => [ 'Creatives' => [['Id' => 11, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250]], 'LimitedBy' => 1, ]], // Вторая: LimitedBy больше нет — это конец описи. ['result' => [ 'Creatives' => [['Id' => 22, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90]], ]], ]; $offsets = []; Http::fake(function ($request) use (&$pages, &$offsets) { $offsets[] = $request['params']['Page']['Offset'] ?? null; return Http::response(array_shift($pages) ?? ['result' => ['Creatives' => []]]); }); $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); expect($client->listImageCreativeIds())->toBe([ 11 => [300, 250], 22 => [728, 90], ]); // Ровно два обращения: второе — с продолжением с того места, где Яндекс оборвал первое. expect($offsets)->toBe([0, 1]); }); it('returns an empty snapshot when the account has no creatives', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => []])]); $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); expect($client->listImageCreativeIds())->toBe([]); }); it('удаление объявлений уходит одним запросом со всеми номерами', function () { Http::fake(['*' => Http::response(['result' => ['DeleteResults' => [['Id' => 11], ['Id' => 12]]]])]); $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); $client->deleteAds([11, 12]); Http::assertSent(function ($req) { $body = $req->data(); return str_contains($req->url(), 'v5/ads') && $body['method'] === 'delete' && $body['params']['SelectionCriteria']['Ids'] === [11, 12]; }); }); /** * Пустое дело Яндексу не отправляем: он ответил бы ошибкой, а мы приняли бы её * за настоящую беду и написали в журнал тревогу на ровном месте. */ it('пустой список объявлений в Яндекс не уходит', function () { Http::fake(); (new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'))->deleteAds([]); Http::assertNothingSent(); }); /** * 🔴 Слепок креативов уходит БЕЗ отбора — «дай всё, что есть в кабинете». В JSON это * обязан быть пустой ОБЪЕКТ `{}`, а не пустой массив `[]`. * * Живой Яндекс на `[]` отвечает отказом 8000 «SelectionCriteria cannot contain an array», * и весь путь робота встаёт: портал не может снять слепок, задание роботу не выдаётся, * клиент видит вечное «готовим картинки». Поймано живым запуском 29.07.2026 — до этого * ходили в песочницу, а она отвергала запрос раньше, ещё на входе. * * Прежние тесты слепка проверяли РАЗБОР ОТВЕТА и ни один не смотрел на форму запроса — * дыра была ровно в этом шве. */ it('слепок креативов просит весь кабинет объектом, а не пустым массивом', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); $client->listImageCreativeIds(); Http::assertSent(function ($req) { $syroy = (string) $req->body(); expect($syroy)->toContain('"SelectionCriteria":{}'); expect($syroy)->not->toContain('"SelectionCriteria":[]'); return true; }); }); /** * 🔴 Яндекс отказывает ДВУМЯ способами, и второй мы не смотрели вовсе. * * Первый — общий `error` на весь запрос, его код ловил. Второй — отказ по отдельной * позиции внутри `AddResults`: сам запрос успешен, а нужного нам `Id` в ответе просто нет, * вместо него лежит `Errors` с человеческим объяснением. * * Поймано живым запуском 29.07.2026: `RetargetingLists.add` вернул позицию без `Id`, * и портал упал ошибкой PHP «Undefined array key "Id"» — ни клиенту, ни в журнал * не попало ни слова о том, ЧТО именно сказал Яндекс. */ it('отказ Яндекса по отдельной позиции становится внятной ошибкой, а не «нет ключа Id»', function () { Http::fake(['*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [ ['Errors' => [['Code' => 5005, 'Message' => 'Условие ретаргетинга уже существует', 'Details' => 'дубль по имени']]], ]]])]); $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); expect(fn () => $client->addRetargetingList('Лидерра — кампания 6', 12345)) ->toThrow(RuntimeException::class, 'Условие ретаргетинга уже существует'); }); /** * 🔴 В условии ретаргетинга обязателен срок хранения — сколько дней человек считается * принадлежащим сегменту. Без него Яндекс отказывает: «Required field: Not specified time * for goal or segment of Yandex.Metrica», и следом «Object not found» — то есть весь * довод считается негодным целиком. * * Поймано живым запуском 29.07.2026 на боевом кабинете. */ it('в условие ретаргетинга кладёт срок хранения — без него Яндекс отказывает', function () { Http::fake(['*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 42]]]])]); $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); expect($client->addRetargetingList('Лидерра #6', 58276009, 30))->toBe(42); Http::assertSent(function ($req) { $arg = $req->data()['params']['RetargetingLists'][0]['Rules'][0]['Arguments'][0]; expect($arg['ExternalId'])->toBe(2058276009); expect($arg['MembershipLifeSpan'])->toBe(30); return true; }); }); /** * 🔴 Сегмент Аудиторий в условии ретаргетинга указывается НЕ своим номером, а номером * с приставкой «20»: сегмент 58034825 → ExternalId 2058034825. Без приставки живой Директ * отвечает «Object not found» — он ищет цель Метрики с таким номером и, конечно, не находит. * * Замер 30.07.2026 на боевом кабинете, один и тот же ГОТОВЫЙ сегмент: * ExternalId 58034825 → отказ 8800 «Object not found» * ExternalId 2058034825 → условие создано, № 41678818 * Приставку подсказал сам Яндекс: у уже существующего в кабинете условия на этот сегмент * в `retargetinglists.get` стоит ровно `"ExternalId": 2058034825`. * * 🪤 Прежний тест закреплял поломку: он проверял, что уходит номер БЕЗ приставки. Тест, * написанный по нашему представлению о правде, а не по ответу живой системы, охраняет баг. */ it('сегмент Аудиторий уходит с приставкой 20 — иначе Директ не находит объект', function () { Http::fake(['*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 7]]]])]); $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); expect($client->addRetargetingList('Лидерра #6', 58034825, 30))->toBe(7); Http::assertSent(function ($req) { $arg = $req->data()['params']['RetargetingLists'][0]['Rules'][0]['Arguments'][0]; expect($arg['ExternalId'])->toBe(2058034825); return true; }); });