Files
portal/app/tests/Feature/Advertising/CreativeRobotEndpointTest.php
T
Дмитрий 542b7d2d22 fix реклама за показы: очередь робота больше не встаёт колом, доклад об успехе не врёт, тихий ноль ловится
Р6 — задание, брошенное «в работе», держало очередь для всех клиентов навсегда.
Причина сбоя обрезается до 900 знаков, иначе портал отвергал отчёт о самом частом
виде поломки. Провал отчёта больше не глотается молча — едет в письмо человеку.
Приём отчёта ловит любую ошибку и закрывает задание сбойным вместо 500 роботу.
Новый сторож creative-jobs:reap каждые 10 минут разбирает пробку.

Р7 — обрыв на докладе «готово» объявлял провалом уже сделанную работу. Доклад
вынесен из-под обработчика сбоя, повторяется трижды, при неудаче только письмо.
Осечка ухода со страницы после «Создать» больше не считается провалом загрузки.

Р8 — пустой список размеров считался успехом, и робот заливал одни и те же файлы
по кругу, оставляя мусор в живом кабинете. Теперь громкий отказ до похода в Яндекс.

Портал 274/274, робот 41/41. Все защиты проверены вырезанием.
Выходов снятия заморозки денег по-прежнему четыре.
2026-07-27 20:42:23 +03:00

320 lines
14 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdCreativeJob;
use App\Models\Tenant;
use App\Services\Advertising\CreativeJobService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
beforeEach(function () {
config(['services.creative_robot.token' => 'ROBOTSECRET']);
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.token' => 'T']);
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
});
function makeRobotCampaign(array $sizes = [[300, 250]]): array
{
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL,
'audience_days' => 10, 'client_cpm_rub' => '120.00',
]);
$banners = [];
foreach ($sizes as [$w, $h]) {
$banners[] = AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => $w, 'height' => $h,
'path' => "ad-banners/{$tenant->id}/{$campaign->id}/{$w}x{$h}.jpg",
'bytes' => 1000, 'included' => true,
]);
}
return [$campaign, $banners];
}
it('rejects a request without the robot token', function () {
$this->getJson('/api/creative-robot/next')->assertStatus(401);
});
it('rejects a request with a wrong robot token', function () {
$this->withHeader('X-Creative-Robot-Token', 'nope')
->getJson('/api/creative-robot/next')
->assertStatus(401);
});
it('closes the channel when no token is configured', function () {
config(['services.creative_robot.token' => '']);
$this->withHeader('X-Creative-Robot-Token', '')
->getJson('/api/creative-robot/next')
->assertStatus(401);
});
it('returns nothing to do when the queue is empty', function () {
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->getJson('/api/creative-robot/next')
->assertOk()
->assertJson(['job' => null]);
});
it('hands out a job with the list of banner files to upload', function () {
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
[$campaign] = makeRobotCampaign([[300, 250]]);
app(CreativeJobService::class)->enqueue($campaign);
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->getJson('/api/creative-robot/next')
->assertOk()
->assertJsonPath('job.campaign_id', $campaign->id)
->assertJsonPath('job.banners.0.width', 300)
->assertJsonPath('job.banners.0.height', 250)
->assertJsonStructure(['job' => ['id', 'campaign_id', 'banners' => [['banner_id', 'width', 'height', 'file_url']]]]);
});
/**
* Адрес файла содержит номер задания.
*
* Раньше адрес был «баннер такой-то», а задание подбиралось как «какое-нибудь в работе».
* Пока в работе строго одно задание, это совпадало; но защита выдачи чужих картинок
* держалась на честном слове, а не на самом запросе.
*/
it('binds the file address to the job the robot was given', function () {
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
[$campaign] = makeRobotCampaign([[300, 250]]);
app(CreativeJobService::class)->enqueue($campaign);
$res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->getJson('/api/creative-robot/next')
->assertOk();
$jobId = $res->json('job.id');
$bannerId = $res->json('job.banners.0.banner_id');
expect($res->json('job.banners.0.file_url'))
->toBe(url("/api/creative-robot/jobs/{$jobId}/banners/{$bannerId}/file"));
});
it('serves a banner file of the job in flight', function () {
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
Storage::fake('local');
[$campaign, $banners] = makeRobotCampaign([[300, 250]]);
Storage::disk('local')->put($banners[0]->path, 'BINARY');
$job = app(CreativeJobService::class)->enqueue($campaign);
app(CreativeJobService::class)->takeNext();
// 🪤 Проверяем СОДЕРЖИМОЕ, а не только «200». Незнакомый адрес перехватывает страница
// сайта и тоже отвечает 200 — на голом assertOk() тест зеленел бы без маршрута вовсе.
$res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->get("/api/creative-robot/jobs/{$job->id}/banners/{$banners[0]->id}/file")
->assertOk();
expect($res->streamedContent())->toBe('BINARY');
});
it('refuses a banner that does not belong to the job in flight', function () {
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
Storage::fake('local');
[$mine, $mineBanners] = makeRobotCampaign([[300, 250]]);
[, $strangerBanners] = makeRobotCampaign([[728, 90]]);
Storage::disk('local')->put($mineBanners[0]->path, 'BINARY');
Storage::disk('local')->put($strangerBanners[0]->path, 'BINARY');
$job = app(CreativeJobService::class)->enqueue($mine);
app(CreativeJobService::class)->takeNext();
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->get("/api/creative-robot/jobs/{$job->id}/banners/{$strangerBanners[0]->id}/file")
->assertStatus(404);
});
/**
* Файл отдаётся только под номером задания, которое реально в работе.
*
* Берём заведомо трудный случай: номер в адресе — от задания, которое НЕ в работе, а сам
* баннер принадлежит той же кампании, чьё задание сейчас в работе. Пока адрес не был привязан
* к заданию, портал молча подставлял «какое-нибудь задание в работе» и отдавал файл.
*/
it('refuses a file request made under a job that is not in flight', function () {
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
Storage::fake('local');
[$campaign, $banners] = makeRobotCampaign([[300, 250]]);
Storage::disk('local')->put($banners[0]->path, 'BINARY');
app(CreativeJobService::class)->enqueue($campaign);
app(CreativeJobService::class)->takeNext();
// Второе задание той же кампании, мимо сервиса: он бы вернул уже существующее.
$stale = AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'status' => AdCreativeJob::STATUS_QUEUED,
'snapshot_before' => [],
]);
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->get("/api/creative-robot/jobs/{$stale->id}/banners/{$banners[0]->id}/file")
->assertStatus(404);
});
it('accepts the done report and writes creative numbers', function () {
Http::fake(['*/json/v5/creatives' => Http::sequence()
->push(['result' => ['Creatives' => []]])
->push(['result' => ['Creatives' => [
['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250],
]]]),
]);
[$campaign, $banners] = makeRobotCampaign([[300, 250]]);
$job = app(CreativeJobService::class)->enqueue($campaign);
app(CreativeJobService::class)->takeNext();
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true])
->assertOk();
expect($banners[0]->fresh()->yandex_creative_id)->toBe(555)
->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE);
});
it('accepts a failure report from the robot', function () {
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
[$campaign] = makeRobotCampaign([[300, 250]]);
$job = app(CreativeJobService::class)->enqueue($campaign);
app(CreativeJobService::class)->takeNext();
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'вход слетел'])
->assertOk();
expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED)
->and($job->fresh()->failure_reason)->toBe('вход слетел');
});
/**
* Своя беда портала не должна вешать очередь.
*
* Приём отчёта «готово» ходит в живой Яндекс за слепком креативов. Любая ошибка API
* (недоступен, лимит, отвалилась сеть) вылетала наружу необработанной: робот получал 500,
* задание навсегда оставалось «в работе», а выдача заданий при живом «в работе» отвечает
* «работы нет» ВСЕМ — ни одна кампания больше не стартовала бы.
*/
it('does not leave the job in flight when the portal itself fails on the done report', function () {
Http::fake(['*/json/v5/creatives' => Http::sequence()
->push(['result' => ['Creatives' => []]])
->push(['error' => ['error_string' => 'Сервис временно недоступен']], 500),
]);
[$campaign, $banners] = makeRobotCampaign([[300, 250]]);
$job = app(CreativeJobService::class)->enqueue($campaign);
app(CreativeJobService::class)->takeNext();
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true])
->assertOk()
->assertJsonPath('status', AdCreativeJob::STATUS_FAILED);
expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED)
->and($job->fresh()->failure_reason)->toContain('Сервис временно недоступен')
->and($banners[0]->fresh()->yandex_creative_id)->toBeNull();
});
/**
* Отчёт принимается ТОЛЬКО по заданию, которое сейчас в работе.
*
* Без этой проверки номер задания брался из адреса как есть: `{ok:true}` по чужому
* `queued`-заданию разложил бы номера креативов ЧУЖОЙ кампании по её баннерам —
* картинка одного клиента уехала бы в объявление другого. А `{ok:false}` по уже
* закрытому заданию переписал бы правильный результат на «сбой».
*/
it('refuses a done report for a job that has not been taken yet', function () {
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
[$campaign, $banners] = makeRobotCampaign([[300, 250]]);
$job = app(CreativeJobService::class)->enqueue($campaign); // остаётся queued
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true])
->assertStatus(409);
expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_QUEUED)
->and($banners[0]->fresh()->yandex_creative_id)->toBeNull();
});
it('refuses a second done report for a job already finished', function () {
Http::fake(['*/json/v5/creatives' => Http::sequence()
->push(['result' => ['Creatives' => []]])
->push(['result' => ['Creatives' => [
['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250],
]]]),
]);
[$campaign, $banners] = makeRobotCampaign([[300, 250]]);
$job = app(CreativeJobService::class)->enqueue($campaign);
app(CreativeJobService::class)->takeNext();
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true])
->assertOk();
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true])
->assertStatus(409);
expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE)
->and($banners[0]->fresh()->yandex_creative_id)->toBe(555);
});
it('refuses a failure report that would overwrite a finished job', function () {
Http::fake(['*/json/v5/creatives' => Http::sequence()
->push(['result' => ['Creatives' => []]])
->push(['result' => ['Creatives' => [
['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250],
]]]),
]);
[$campaign] = makeRobotCampaign([[300, 250]]);
$job = app(CreativeJobService::class)->enqueue($campaign);
app(CreativeJobService::class)->takeNext();
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true])
->assertOk();
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'обрыв'])
->assertStatus(409);
expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE);
});
it('refuses a report for a job already marked failed', function () {
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
[$campaign] = makeRobotCampaign([[300, 250]]);
$job = app(CreativeJobService::class)->enqueue($campaign);
app(CreativeJobService::class)->takeNext();
app(CreativeJobService::class)->fail($job->fresh(), 'первый сбой');
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'второй сбой'])
->assertStatus(409);
expect($job->fresh()->failure_reason)->toBe('первый сбой');
});