Files
portal/app/tests/Feature/Advertising/CreativeJobServiceTest.php
T

560 lines
29 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Exceptions\Advertising\CreativeMatchFailedException;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdCampaignMessage;
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);
});
/**
* Рубильник Директа обязан держать и служебный канал робота.
*
* Очередь заданий строила клиента Директа безусловно: выдача задания и приём отчёта ходили
* в живой Яндекс мимо рубильника. То есть при выключенном рубильнике портал всё равно
* стучался в боевой кабинет — ровно то, от чего рубильник и защищает.
*/
it('does not touch Yandex from the robot channel when the Direct switch is off', function () {
config(['services.yandex_direct.enabled' => false]);
Http::fake();
$campaign = makeCampaignWithBanners([[300, 250]]);
app(CreativeJobService::class)->enqueue($campaign);
expect(fn () => app(CreativeJobService::class)->takeNext())
->toThrow(RuntimeException::class, 'выключен');
Http::assertNothingSent();
});
/**
* Робот должен возить только то, чего в кабинете ещё нет.
*
* Раньше в задание попадали ВСЕ включённые баннеры, даже те, чей креатив уже лежит
* в кабинете. Отсюда два круга беды. Первый: каждая неудачная попытка оставляла в кабинете
* ещё пятнадцать креативов навсегда — почистить их можно только руками. Второй, злее:
* после сбоя задание ставится заново, слепок «до» снимается свежий, в нём уже видны
* прошлые креативы — и опознание падает «не появился креатив размера 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);
});
/**
* Вид задания. До сих пор робот умел ровно одно — отвезти картинки, и очередь молчаливо
* означала именно это. Теперь он будет ещё ходить смотреть, что кабинет говорит про
* объявление, и возить документ клиента. Вид — явная пометка на задании, а не догадка
* по содержимому.
*
* Умолчание `upload` обязательно: старые задания в очереди на момент выката вида не имеют,
* и без умолчания робот получил бы пустое поле и не понял, что делать. Тот же приём,
* что у `status` в исходной таблице.
*/
it('старому заданию без указания вида достаётся вид «отвезти картинки»', function () {
$campaign = makeCampaignWithBanners([[300, 250]]);
$job = AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'status' => AdCreativeJob::STATUS_QUEUED,
]);
expect($job->refresh()->kind)->toBe(AdCreativeJob::KIND_UPLOAD);
});
/**
* Задание «отвезти документ» обязано помнить, КАКОЙ документ везёт: ссылка на сообщение
* ленты. Без неё робот у кабинета знает только кампанию — а документов у неё может быть
* несколько, и он повезёт не тот.
*/
it('задание может нести вид и ссылку на сообщение с документом', function () {
$campaign = makeCampaignWithBanners([[300, 250]]);
$message = AdCampaignMessage::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'author' => AdCampaignMessage::AUTHOR_CLIENT,
'body' => 'Прикладываю лицензию',
'file_path' => 'ad-docs/1/1/licence.pdf',
'file_name' => 'licence.pdf',
'file_size' => 2048,
'file_mime' => 'application/pdf',
]);
$job = AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'status' => AdCreativeJob::STATUS_QUEUED,
'kind' => AdCreativeJob::KIND_DELIVER,
'message_id' => $message->id,
]);
$job->refresh();
expect($job->kind)->toBe(AdCreativeJob::KIND_DELIVER)
->and($job->message_id)->toBe($message->id)
->and($job->message->file_name)->toBe('licence.pdf');
});
/**
* Сообщение удалили, а задание осталось — робот не должен падать на битой ссылке.
* `nullOnDelete` гасит ссылку, задание остаётся в очереди и честно провалится по
* «документа нет», а не уронит всю очередь ошибкой целостности.
*/
it('удаление сообщения гасит ссылку у задания, а не сносит задание', function () {
$campaign = makeCampaignWithBanners([[300, 250]]);
$message = AdCampaignMessage::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'author' => AdCampaignMessage::AUTHOR_CLIENT,
'body' => 'Документ',
]);
$job = AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'status' => AdCreativeJob::STATUS_QUEUED,
'kind' => AdCreativeJob::KIND_DELIVER,
'message_id' => $message->id,
]);
$message->delete();
expect(AdCreativeJob::find($job->id))->not->toBeNull()
->and(AdCreativeJob::find($job->id)->message_id)->toBeNull();
});
/**
* 🔴 Документ, который везёт робот, обязан принадлежать той же кампании.
*
* Обычный внешний ключ этого НЕ давал: проверки целостности в PostgreSQL идут в обход RLS
* и подтверждают только, что строка существует. В портале дыра не выстрелила бы — там RLS,
* чужого сообщения клиент не увидит. Но робот ходит под `crm_admin_user`, которому
* `db/03_service_bypass_policies.sql` даёт кросс-тенантный доступ: он молча увёз бы документ
* ОДНОГО клиента в модерацию кампании ДРУГОГО. Утечка персональных данных без единой ошибки
* в журнале. Найдено проверкой прав доступа, тесты этого увидеть не могли.
*
* Закрыто составным внешним ключом `(message_id, campaign_id)` — теперь отказывает сама база,
* а не наша дисциплина в коде.
*/
it('база не даёт заданию увезти документ чужого клиента', function () {
$mine = makeCampaignWithBanners([[300, 250]]);
$foreign = makeCampaignWithBanners([[300, 250]]); // свой тенант, чужой клиент
$foreignDoc = AdCampaignMessage::create([
'tenant_id' => $foreign->tenant_id,
'campaign_id' => $foreign->id,
'author' => AdCampaignMessage::AUTHOR_CLIENT,
'body' => 'Чужая лицензия',
]);
expect(fn () => AdCreativeJob::create([
'tenant_id' => $mine->tenant_id,
'campaign_id' => $mine->id,
'status' => AdCreativeJob::STATUS_QUEUED,
'kind' => AdCreativeJob::KIND_DELIVER,
'message_id' => $foreignDoc->id,
]))->toThrow(QueryException::class);
});
/**
* Тот же клиент, но другая его кампания — робот тоже повезёт не тот документ, и внешний
* ключ по одному тенанту этого бы не поймал. Поэтому ключ составлен по кампании: она
* принадлежит тенанту, значит проверка по кампании закрывает оба случая разом.
*/
it('база не даёт заданию увезти документ другой кампании того же клиента', function () {
$campaign = makeCampaignWithBanners([[300, 250]]);
$other = AdCampaign::create([
'tenant_id' => $campaign->tenant_id,
'name' => 'Вторая кампания того же клиента',
'mode' => AdCampaign::MODE_MANUAL,
'audience_days' => 10,
'client_cpm_rub' => '120.00',
]);
$otherDoc = AdCampaignMessage::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $other->id,
'author' => AdCampaignMessage::AUTHOR_CLIENT,
'body' => 'Документ второй кампании',
]);
expect(fn () => AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'status' => AdCreativeJob::STATUS_QUEUED,
'kind' => AdCreativeJob::KIND_DELIVER,
'message_id' => $otherDoc->id,
]))->toThrow(QueryException::class);
});
/**
* Своя кампания — задание заводится. Проверка, что защита не заперла нужный случай:
* запрет без этого теста мог бы оказаться запретом на всё.
*/
it('задание со своим документом заводится нормально', function () {
$campaign = makeCampaignWithBanners([[300, 250]]);
$doc = AdCampaignMessage::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'author' => AdCampaignMessage::AUTHOR_CLIENT,
'body' => 'Своя лицензия',
]);
$job = AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'status' => AdCreativeJob::STATUS_QUEUED,
'kind' => AdCreativeJob::KIND_DELIVER,
'message_id' => $doc->id,
]);
expect($job->refresh()->message_id)->toBe($doc->id);
});
/**
* 🔴 Остаток той же дыры: ключ по кампании не проверял, что КЛИЕНТ задания совпадает
* с клиентом кампании.
*
* Пока `campaign_id` ссылался на кампанию без клиента, база спокойно принимала строку,
* где задание числится за клиентом A, а кампания принадлежит клиенту B. Живого пути
* к такой строке сегодня нет — постановка берёт оба поля из одной кампании, — но
* задача 16 заводит задания на служебном канале, где RLS не отфильтрует, и допущение
* держалось бы только на аккуратности кода. Найдено повторной проверкой прав доступа:
* первая правка закрыла главное, а это осталось и было воспроизведено вживую.
*/
it('база не даёт завести задание за одного клиента на кампанию другого', function () {
$mine = makeCampaignWithBanners([[300, 250]]);
$foreign = makeCampaignWithBanners([[300, 250]]);
expect(fn () => AdCreativeJob::create([
'tenant_id' => $mine->tenant_id,
'campaign_id' => $foreign->id, // кампания чужого клиента
'status' => AdCreativeJob::STATUS_QUEUED,
]))->toThrow(QueryException::class);
});
/**
* То же для ленты сообщений: сообщение не может числиться за одним клиентом, а лежать
* в кампании другого. Иначе документ попадал бы в чужую переписку, а ключ «документ —
* своей кампании» проверял бы принадлежность к кампании, у которой другой владелец.
*/
it('база не даёт положить сообщение одного клиента в кампанию другого', function () {
$mine = makeCampaignWithBanners([[300, 250]]);
$foreign = makeCampaignWithBanners([[300, 250]]);
expect(fn () => AdCampaignMessage::create([
'tenant_id' => $mine->tenant_id,
'campaign_id' => $foreign->id,
'author' => AdCampaignMessage::AUTHOR_CLIENT,
'body' => 'Не туда',
]))->toThrow(QueryException::class);
});