Files
portal/app/tests/Feature/Advertising/YandexImpressionsReportTest.php
T
Дмитрий 9305185748 fix(реклама): заголовок итоговой строки назывался неверно, число показов не читалось
Третий и последний слой той же поломки, вскрыт живым замером на боевом.

После прошлых починок отчёт наконец стал приходить, и в теле оказалось не
только число: у кампании #11 — «1» и следом строка-итог, у #6 — «Total rows: 0».
Причина: заголовок отключения итоговой строки у нас назывался
skipReportSummaryRow, а Директ понимает skipReportSummary. Наш заголовок он
просто не знал и приписывал итог.

Заодно уточнено, что считать ошибкой. Пустой отчёт при успешном ответе — это
законный НОЛЬ показов: у кампании нет ни одной строки. «Отчёт ещё строится»
отличается кодом ответа 201, а не пустотой. Прежняя строгая проверка «пусто —
значит ошибка» ломала бы списание по каждой новой кампании.

Число читаем из первой строки, даже если итог всё же пришёл — на случай, если
Яндекс снова поменяет правила.

Три новых сторожа: заголовок skipReportSummary в запросе и число из первой
строки; пустой отчёт при коде 200 — ноль; неготовый отчёт при коде 201 — ошибка.

Прогон: 397 сторожей блока рекламы, статанализ 0, стиль чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:14:13 +03:00

146 lines
7.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Services\Advertising\YandexDirectClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* Запрос «сколько показов сделано» к Директу — единственный источник числа «Показано»
* на карточке клиента И основание для списания денег за рекламу.
*
* 🔴 Живой замер 04.08.2026. Яндекс отвечал на этот запрос ОТКАЗОМ:
*
* код 400 — «В params отсутствует обязательное поле IncludeVAT»
*
* а портал ответ не проверял: брал тело, переводил в число и получал НОЛЬ. То есть
* «показов не было» — не факт, а проглоченная ошибка. Кампания #6 неделю стояла с
* «Показано 0», #11 — с одним показом в кабинете и нулём на карточке, и НИ ЗА ОДИН
* показ клиенту не выставили счёт: реклама шла за наш счёт.
*
* Отсюда три сторожа: поле IncludeVAT обязано быть в запросе, режим ожидания обязан
* стоять (иначе Яндекс отвечает «принято, зайдите позже» пустым телом), а любой
* непонятный ответ обязан быть ГРОМКОЙ ошибкой, а не тихим нулём.
*/
function klientDirekta(): YandexDirectClient
{
return new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'DIRTOKEN');
}
it('спрашивает показы так, как Яндекс требует: с IncludeVAT и режимом ожидания', function () {
Http::fake(['*/json/v5/reports' => Http::response('1234', 200)]);
expect(klientDirekta()->getCampaignImpressions(777))->toBe(1234);
Http::assertSent(function ($request) {
// Без IncludeVAT Яндекс отвечает 400 — проверено живьём на боевом кабинете.
$vat = $request['params']['IncludeVAT'] ?? null;
// Без режима ожидания отчёт строится в фоне: приходит пустое тело, и портал
// принимал его за «ноль показов».
return $vat !== null && ($request->header('processingMode')[0] ?? null) === 'auto';
});
});
it('отказ Яндекса — это ошибка, а не ноль показов', function () {
Http::fake(['*/json/v5/reports' => Http::response(
['error' => ['error_detail' => 'В params отсутствует обязательное поле IncludeVAT']],
400,
)]);
expect(fn () => klientDirekta()->getCampaignImpressions(777))
->toThrow(RuntimeException::class);
});
/**
* 🔴 Живой замер 04.08.2026, второй заход. Даже с режимом ожидания Директ на отчёт
* «за всё время» отвечает кодом 201 — «принял, строю» — и пустым телом. Готовый отчёт
* надо ЗАБРАТЬ повторным запросом, и обязательно ПОД ТЕМ ЖЕ ИМЕНЕМ: имя — это и есть
* ссылка на заказанный отчёт. Новое имя каждый раз заказывало бы новый отчёт, и портал
* не дождался бы никогда.
*/
it('отчёт строится — портал дожидается и забирает его тем же именем', function () {
Http::fakeSequence()
->push('', 201) // принял, строю
->push('', 201) // всё ещё строю
->push('742', 200); // готово
expect(klientDirekta()->getCampaignImpressions(777))->toBe(742);
$imena = [];
Http::assertSent(function ($request) use (&$imena) {
$imena[] = (string) ($request['params']['ReportName'] ?? '');
return true;
});
expect($imena)->toHaveCount(3)
->and($imena[1])->toBe($imena[0])
->and($imena[2])->toBe($imena[0]);
});
it('отчёт так и не построился — это ошибка, а не ноль', function () {
Http::fake(['*/json/v5/reports' => Http::response('', 201)]);
expect(fn () => klientDirekta()->getCampaignImpressions(777))
->toThrow(RuntimeException::class);
});
it('непонятный ответ — ошибка: ноль показов придумывать нельзя', function () {
Http::fake(['*/json/v5/reports' => Http::response('Total rows: 0', 200)]);
expect(fn () => klientDirekta()->getCampaignImpressions(777))
->toThrow(RuntimeException::class);
});
/**
* 🪤 Пустой отчёт при коде 200 — это НЕ поломка: у кампании просто нет ни одной строки,
* то есть показов не было. Строгая проверка «пусто = ошибка» ломала бы списание по всем
* новым кампаниям. «Отчёт ещё строится» отличается кодом ответа, а не пустотой.
*/
it('пустой отчёт при успешном ответе — законный ноль показов', function () {
Http::fake(['*/json/v5/reports' => Http::response('', 200)]);
expect(klientDirekta()->getCampaignImpressions(777))->toBe(0);
});
/**
* 🔴 Живой замер 04.08.2026: заголовок отключения итоговой строки у нас назывался
* `skipReportSummaryRow`, а Директ понимает `skipReportSummary`. Из-за опечатки в теле
* приходила ещё и строка-итог «Total rows: 1» под самим числом, и строгая проверка
* отвергала настоящее число как непонятный ответ.
*/
it('просит Директ не приписывать итоговую строку — и читает число, если она всё же пришла', function () {
Http::fake(['*/json/v5/reports' => Http::response('742
Total rows: 1', 200)]);
expect(klientDirekta()->getCampaignImpressions(777))->toBe(742);
Http::assertSent(fn ($request) => ($request->header('skipReportSummary')[0] ?? null) === 'true');
});
it('ноль показов из Яндекса остаётся нулём — новая кампания ещё ничего не показала', function () {
Http::fake(['*/json/v5/reports' => Http::response('0', 200)]);
expect(klientDirekta()->getCampaignImpressions(777))->toBe(0);
});
it('имя отчёта у каждого запроса своё — иначе Яндекс отдаст вчерашний кэш', function () {
Http::fake(['*/json/v5/reports' => Http::response('5', 200)]);
$client = klientDirekta();
$client->getCampaignImpressions(777);
$client->getCampaignImpressions(777);
$imena = [];
Http::assertSent(function ($request) use (&$imena) {
$imena[] = (string) ($request['params']['ReportName'] ?? '');
return true;
});
expect(Str::of($imena[0] ?? '')->isNotEmpty())->toBeTrue()
->and($imena[0] ?? '')->not->toBe($imena[1] ?? '');
});