0548fc0d8f
Хвосты портала П1-П8 из приёмочного листа v12. П1 обрыв постановки задания больше не даёт клиенту голый 500 — 503 с человеческим текстом и записью в журнал. Таймаут у HTTP-клиента Laravel уже был, эта половина находки не подтвердилась. П2 и П6 роботу отдаются только баннеры без номера креатива — тот же список сопоставляется при отчёте. Раньше стороны расходились и опознание падало на безупречной работе робота, плодя дубли в кабинете. Плюс постраничный обход описи креативов: слепок обрывался на первой странице. П3 слепок «до» снимается при выдаче задания, а не при постановке, и в той же транзакции, что и перевод в работу. Два задания в очереди получали одинаковый слепок, второе падало всегда. Постановка перестала зависеть от живости Яндекса. П4 перед созданием объявлений сверяется настоящий размер каждого креатива одним запросом. Не сошлось или креатива нет — запуск не идёт. П5 уникальный индекс uq_ad_campaign_banner_slot, запись v9.09 в CHANGELOG схемы, rls-reviewer GO. П7 замок на правку расширен на сегмент Аудиторий — он создаётся раньше кампании. Смежная находка: перезаливка картинки теперь обнуляет номер креатива. П8 и Р-х5 файл отдаётся под настоящим расширением и типом содержимого, имя от номера баннера; робот берёт расширение из ответа портала. Портал 287/287, робот 43/43. Денежных выходов снятия заморозки по-прежнему четыре. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
325 lines
17 KiB
PHP
325 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Exceptions\Advertising\CreativeMatchFailedException;
|
|
use App\Models\AdCampaign;
|
|
use App\Models\AdCampaignBanner;
|
|
use App\Models\AdCreativeJob;
|
|
use App\Models\Tenant;
|
|
use App\Services\Advertising\CreativeIdMatcher;
|
|
use App\Services\Advertising\CreativeJobService;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
function makeCampaignWithBanners(array $sizes): AdCampaign
|
|
{
|
|
$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',
|
|
]);
|
|
|
|
foreach ($sizes as [$w, $h]) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Слепок «до» снимается в момент ВЫДАЧИ задания роботу, а не при постановке в очередь.
|
|
*
|
|
* Пока он снимался при постановке, два задания в очереди получали одинаковый слепок:
|
|
* первый робот заливал креативы, а у второго задания «до» оставался старым — и разница
|
|
* слепков показывала чужие креативы вдобавок к своим. Второе задание падало ВСЕГДА.
|
|
* Плюс постановка ходила в Яндекс прямо из нажатия «запустить»: лёг Яндекс — клиент
|
|
* не мог даже поставить кампанию в очередь.
|
|
*/
|
|
it('enqueues a job without touching Yandex at all', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake();
|
|
|
|
$campaign = makeCampaignWithBanners([[300, 250], [728, 90]]);
|
|
|
|
$job = app(CreativeJobService::class)->enqueue($campaign);
|
|
|
|
expect($job->status)->toBe(AdCreativeJob::STATUS_QUEUED)
|
|
->and($job->snapshot_before)->toBeNull();
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('takes the creatives snapshot at hand-out time', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [
|
|
['Id' => 111, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250],
|
|
]]])]);
|
|
|
|
$campaign = makeCampaignWithBanners([[300, 250], [728, 90]]);
|
|
$job = app(CreativeJobService::class)->enqueue($campaign);
|
|
|
|
// Ключевая половина проверки: пока задание ждёт в очереди, слепка у него НЕТ.
|
|
// Без неё тест был бы зелёным и при снятии слепка на постановке.
|
|
expect($job->snapshot_before)->toBeNull();
|
|
|
|
$taken = app(CreativeJobService::class)->takeNext();
|
|
|
|
expect($taken->status)->toBe(AdCreativeJob::STATUS_TAKEN)
|
|
->and($taken->snapshot_before)->toBe(['111' => [300, 250]]);
|
|
});
|
|
|
|
/**
|
|
* Яндекс лёг ровно в момент выдачи — задание обязано остаться в очереди, а не сгореть.
|
|
* Робот придёт через минуту и заберёт его снова. Всё держится на том, что слепок
|
|
* снимается в ТОЙ ЖЕ транзакции, что и перевод задания в работу: не снялся слепок —
|
|
* откатился и захват.
|
|
*/
|
|
it('leaves the job queued when Yandex is down at hand-out time', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::response(['error' => ['error_string' => 'nope']], 503)]);
|
|
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$job = app(CreativeJobService::class)->enqueue($campaign);
|
|
|
|
expect(fn () => app(CreativeJobService::class)->takeNext())->toThrow(RuntimeException::class);
|
|
|
|
$fresh = $job->fresh();
|
|
expect($fresh->status)->toBe(AdCreativeJob::STATUS_QUEUED)
|
|
->and($fresh->taken_at)->toBeNull();
|
|
});
|
|
|
|
it('does not enqueue a second job while one is already waiting', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
|
|
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
|
|
$first = app(CreativeJobService::class)->enqueue($campaign);
|
|
$second = app(CreativeJobService::class)->enqueue($campaign);
|
|
|
|
expect($second->id)->toBe($first->id)
|
|
->and(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1);
|
|
});
|
|
|
|
it('gives the robot one job at a time and marks it taken', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
|
|
|
|
$a = makeCampaignWithBanners([[300, 250]]);
|
|
$b = makeCampaignWithBanners([[728, 90]]);
|
|
app(CreativeJobService::class)->enqueue($a);
|
|
app(CreativeJobService::class)->enqueue($b);
|
|
|
|
$taken = app(CreativeJobService::class)->takeNext();
|
|
expect($taken->status)->toBe(AdCreativeJob::STATUS_TAKEN)
|
|
->and($taken->attempts)->toBe(1);
|
|
|
|
// Пока первое задание не завершено, второе не выдаётся — робот работает по одному.
|
|
expect(app(CreativeJobService::class)->takeNext())->toBeNull();
|
|
});
|
|
|
|
it('writes creative numbers onto banners when the robot reports done', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::sequence()
|
|
->push(['result' => ['Creatives' => []]]) // слепок «до»
|
|
->push(['result' => ['Creatives' => [ // слепок «после»
|
|
['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250],
|
|
['Id' => 556, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90],
|
|
]]]),
|
|
]);
|
|
|
|
$campaign = makeCampaignWithBanners([[300, 250], [728, 90]]);
|
|
$job = app(CreativeJobService::class)->enqueue($campaign);
|
|
app(CreativeJobService::class)->takeNext();
|
|
|
|
app(CreativeJobService::class)->complete($job->fresh());
|
|
|
|
$banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get();
|
|
expect($banners[0]->yandex_creative_id)->toBe(555)
|
|
->and($banners[1]->yandex_creative_id)->toBe(556)
|
|
->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE);
|
|
});
|
|
|
|
it('fails the job and touches no banner when the snapshot does not add up', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::sequence()
|
|
->push(['result' => ['Creatives' => []]])
|
|
->push(['result' => ['Creatives' => [
|
|
['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250],
|
|
]]]),
|
|
]);
|
|
|
|
$campaign = makeCampaignWithBanners([[300, 250], [728, 90]]);
|
|
$job = app(CreativeJobService::class)->enqueue($campaign);
|
|
app(CreativeJobService::class)->takeNext();
|
|
|
|
expect(fn () => app(CreativeJobService::class)->complete($job->fresh()))
|
|
->toThrow(CreativeMatchFailedException::class);
|
|
|
|
$banners = AdCampaignBanner::where('campaign_id', $campaign->id)->get();
|
|
expect($banners->pluck('yandex_creative_id')->filter())->toBeEmpty()
|
|
->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED)
|
|
->and($job->fresh()->failure_reason)->toContain('728x90');
|
|
});
|
|
|
|
/**
|
|
* Пустой список ожидаемых размеров — это НЕ «всё сошлось», это тихий ноль.
|
|
*
|
|
* Раньше `match()` с пустым списком просто не входил в цикл и молча возвращал пустоту.
|
|
* А пустой список получается сам собой: если после выката не перезапустить
|
|
* `db/03_service_bypass_policies.sql`, служебная роль не увидит ни одного баннера. Дальше
|
|
* задание помечается «готово» с нулём проставленных номеров, запуск снова видит баннеры
|
|
* без креативов, ставит новое задание — и робот заливает те же файлы по кругу, оставляя
|
|
* каждый раз пачку мусорных креативов в живом кабинете. В журнале при этом всё зелёное.
|
|
*/
|
|
it('refuses to call an empty match a success', function () {
|
|
expect(fn () => app(CreativeIdMatcher::class)->match([], [], []))
|
|
->toThrow(CreativeMatchFailedException::class);
|
|
});
|
|
|
|
it('fails a done report for a campaign without banners without asking Yandex at all', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake();
|
|
|
|
// Мимо enqueue(): он сам сходил бы в Яндекс за слепком и испортил проверку «не ходили».
|
|
$campaign = makeCampaignWithBanners([]);
|
|
$job = AdCreativeJob::create([
|
|
'tenant_id' => $campaign->tenant_id,
|
|
'campaign_id' => $campaign->id,
|
|
'status' => AdCreativeJob::STATUS_TAKEN,
|
|
'snapshot_before' => [],
|
|
'taken_at' => now(),
|
|
]);
|
|
|
|
expect(fn () => app(CreativeJobService::class)->complete($job))
|
|
->toThrow(CreativeMatchFailedException::class);
|
|
|
|
Http::assertNothingSent();
|
|
expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED);
|
|
});
|
|
|
|
/**
|
|
* Робот должен возить только то, чего в кабинете ещё нет.
|
|
*
|
|
* Раньше в задание попадали ВСЕ включённые баннеры, даже те, чей креатив уже лежит
|
|
* в кабинете. Отсюда два круга беды. Первый: каждая неудачная попытка оставляла в кабинете
|
|
* ещё пятнадцать креативов навсегда — почистить их можно только руками. Второй, злее:
|
|
* после сбоя задание ставится заново, слепок «до» снимается свежий, в нём уже видны
|
|
* прошлые креативы — и опознание падает «не появился креатив размера X» на каждом заходе,
|
|
* плодя дубли по кругу.
|
|
*/
|
|
it('sends the robot only the banners that still have no creative', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
|
|
|
|
$campaign = makeCampaignWithBanners([[300, 250], [728, 90]]);
|
|
// Креатив для 300×250 робот уже привозил в прошлый заход.
|
|
AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 300)
|
|
->update(['yandex_creative_id' => 111]);
|
|
|
|
$job = app(CreativeJobService::class)->enqueue($campaign);
|
|
app(CreativeJobService::class)->takeNext();
|
|
|
|
$banners = app(CreativeJobService::class)->bannersToUpload($job->fresh());
|
|
|
|
expect($banners)->toHaveCount(1)
|
|
->and((int) $banners->first()->width)->toBe(728);
|
|
});
|
|
|
|
/**
|
|
* Обратная сторона того же: при отчёте сопоставлять надо ровно тот набор, который робот
|
|
* вёз. Иначе баннер с уже проставленным номером попал бы в список ожидаемых размеров,
|
|
* нового креатива такого размера в слепке не появилось бы — и опознание падало бы на
|
|
* ровном месте, хотя робот отработал безупречно.
|
|
*/
|
|
it('matches only the banners the robot was actually asked to upload', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::sequence()
|
|
->push(['result' => ['Creatives' => []]]) // слепок «до»
|
|
->push(['result' => ['Creatives' => [ // слепок «после»
|
|
['Id' => 777, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90],
|
|
]]]),
|
|
]);
|
|
|
|
$campaign = makeCampaignWithBanners([[300, 250], [728, 90]]);
|
|
AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 300)
|
|
->update(['yandex_creative_id' => 111]);
|
|
|
|
$job = app(CreativeJobService::class)->enqueue($campaign);
|
|
app(CreativeJobService::class)->takeNext();
|
|
|
|
app(CreativeJobService::class)->complete($job->fresh());
|
|
|
|
$banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get();
|
|
expect($banners[0]->yandex_creative_id)->toBe(111) // прежний номер не тронут
|
|
->and($banners[1]->yandex_creative_id)->toBe(777)
|
|
->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE);
|
|
});
|
|
|
|
/**
|
|
* «Задание в работе всегда не больше одного» — инвариант, на котором держится ВСЁ
|
|
* опознание креативов: слепки `creatives.get` до/после снимаются по аккаунту целиком,
|
|
* и два задания одновременно перемешают их между кампаниями. Размеры IAB у всех
|
|
* клиентов одинаковые (300×250, 728×90), поэтому итог — не громкий отказ, а тихая
|
|
* привязка ЧУЖОГО номера креатива к баннеру.
|
|
*
|
|
* Проверки `exists()` в коде мало: она не блокирует строку, и две одновременные
|
|
* транзакции обе видят «в работе никого». Гарантию даёт база — частичный уникальный
|
|
* индекс.
|
|
*/
|
|
it('lets the database refuse a second job in flight', function () {
|
|
config(['services.yandex_direct.enabled' => true]);
|
|
config(['services.yandex_direct.token' => 'T']);
|
|
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
|
|
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
|
|
|
|
$a = makeCampaignWithBanners([[300, 250]]);
|
|
$b = makeCampaignWithBanners([[728, 90]]);
|
|
app(CreativeJobService::class)->enqueue($a);
|
|
$second = app(CreativeJobService::class)->enqueue($b);
|
|
|
|
app(CreativeJobService::class)->takeNext(); // первое ушло в работу
|
|
|
|
// Обход сервиса — прямая запись в базу, как сделала бы вторая параллельная транзакция.
|
|
expect(fn () => DB::transaction(fn () => AdCreativeJob::where('id', $second->id)
|
|
->update(['status' => AdCreativeJob::STATUS_TAKEN])))
|
|
->toThrow(QueryException::class);
|
|
|
|
expect(AdCreativeJob::where('status', AdCreativeJob::STATUS_TAKEN)->count())->toBe(1);
|
|
});
|