Files
portal/app/tests/Unit/Advertising/YandexDirectMediaClientTest.php
T

383 lines
17 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Services\Advertising\YandexDirectClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('adds a cpm banner media campaign with cp maximum impressions strategy', function () {
Http::fake([
'*/json/v5/campaigns' => 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;
});
});