9305185748
Третий и последний слой той же поломки, вскрыт живым замером на боевом. После прошлых починок отчёт наконец стал приходить, и в теле оказалось не только число: у кампании #11 — «1» и следом строка-итог, у #6 — «Total rows: 0». Причина: заголовок отключения итоговой строки у нас назывался skipReportSummaryRow, а Директ понимает skipReportSummary. Наш заголовок он просто не знал и приписывал итог. Заодно уточнено, что считать ошибкой. Пустой отчёт при успешном ответе — это законный НОЛЬ показов: у кампании нет ни одной строки. «Отчёт ещё строится» отличается кодом ответа 201, а не пустотой. Прежняя строгая проверка «пусто — значит ошибка» ломала бы списание по каждой новой кампании. Число читаем из первой строки, даже если итог всё же пришёл — на случай, если Яндекс снова поменяет правила. Три новых сторожа: заголовок skipReportSummary в запросе и число из первой строки; пустой отчёт при коде 200 — ноль; неготовый отчёт при коде 201 — ошибка. Прогон: 397 сторожей блока рекламы, статанализ 0, стиль чист. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 lines
7.4 KiB
PHP
146 lines
7.4 KiB
PHP
<?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] ?? '');
|
||
});
|