39f1d77895
Две мины, обе вскрыты замерами, обе роняли разведку молча.
1. СПИСОК ОБЪЯВЛЕНИЙ ВИРТУАЛИЗОВАН — «объявления нет» бывало ложью.
Вчерашний разбор §7.10 утверждал, что семь объявлений кампании 713175197 пропали
из кабинета. Это неверно. Три замера подряд:
- ads.get боевым ключом вернул ВСЕ 15, одна кампания, одна группа, State=OFF;
- разметка страницы держит 8 строк;
- сам кабинет отвечает своему списку "adsCount": 15, и в этом же ответе лежат
«пропавшие» номера.
Список рисует только видимые строки, и у него СВОЯ полоса прокрутки: колесо мыши
по странице его не двигает — двенадцать оборотов не сдвинули ничего, и это ровно
та проверка, что породила слова «прокрутка их число не меняет».
Лечение — dolistatDoObyavleniya: не дождались строки, крутим ящик списка шагами
по 600 точек, после каждого смотрим снова. Останов — нашли, упёрлись в дно или
кончились 25 шагов. Только после этого доклад «объявления в списке нет» — правда.
Приёмка глазами настоящим readRejection по живому кабинету:
17787641406 и 17787641505 из «пропавших» — принесли настоящую причину;
17787641506 — прежние 760 знаков через окно, старая дорога цела.
2. УПАВШЕЕ ЗАДАНИЕ РАЗВЕДКИ СГОРАЛО НАВСЕГДА.
Защита от дублей смотрела все задания по номеру объявления, не глядя на состояние,
и находила своё же упавшее. Одна осечка навсегда лишала клиента причины отказа,
а поднять разведку можно было только правкой боевой базы руками — так 01.08
и пришлось делать со всеми тринадцатью.
Теперь сбойное дублем не считается: поднимаем ТУ ЖЕ строку обратно в очередь,
счётчик попыток копится, потолок — три попытки. Строки в работе и успешно
закрытые не трогаем.
Сторожа: три новых на постановку заданий, два на долистывание. Третий сторож проверен
вырезанием — покраснел ровно на убранной защите. Статанализ ноль, тесты рекламы 354/354,
робот 88/88, орфография и разметка чисты.
Урок дороже самой починки: доклад робота — не замер. Ложная фраза робота за один шаг
стала ложным выводом о продукте и уехала в разбор и в промт следующей смене.
На боевой НЕ выкатывалось.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
914 lines
48 KiB
PHP
914 lines
48 KiB
PHP
<?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\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
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;
|
|
}
|
|
|
|
/** Сообщение клиента с приложенным документом — то, что возит задание вида `deliver`. */
|
|
function docFor(AdCampaign $campaign): AdCampaignMessage
|
|
{
|
|
return AdCampaignMessage::create([
|
|
'tenant_id' => $campaign->tenant_id,
|
|
'campaign_id' => $campaign->id,
|
|
'author' => AdCampaignMessage::AUTHOR_CLIENT,
|
|
'body' => 'Прикладываю лицензию',
|
|
'file_path' => "ad-docs/{$campaign->tenant_id}/{$campaign->id}/licence.pdf",
|
|
'file_name' => 'licence.pdf',
|
|
'file_size' => 2048,
|
|
'file_mime' => 'application/pdf',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Слепок «до» снимается в момент ВЫДАЧИ задания роботу, а не при постановке в очередь.
|
|
*
|
|
* Пока он снимался при постановке, два задания в очереди получали одинаковый слепок:
|
|
* первый робот заливал креативы, а у второго задания «до» оставался старым — и разница
|
|
* слепков показывала чужие креативы вдобавок к своим. Второе задание падало ВСЕГДА.
|
|
* Плюс постановка ходила в Яндекс прямо из нажатия «запустить»: лёг Яндекс — клиент
|
|
* не мог даже поставить кампанию в очередь.
|
|
*/
|
|
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);
|
|
});
|
|
|
|
/**
|
|
* 🔴 Второй рубеж защиты документа — в коде. Ключи базы (v9.13/v9.15) отказывают уже
|
|
* на записи, но полагаться на исключение из базы как на рабочий путь нельзя: оно приходит
|
|
* поздно, текстом PostgreSQL, и в транзакции с другими правками. Постановка доставки берёт
|
|
* сообщение ТОЛЬКО связью от кампании — чужое просто не находится.
|
|
*
|
|
* Сырой номер из запроса сюда не попадает никогда: это и есть тот путь, которым робот
|
|
* увёз бы чужой документ.
|
|
*/
|
|
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' => 'Чужая лицензия',
|
|
'file_path' => 'ad-docs/9/9/chuzhaya.pdf',
|
|
'file_name' => 'chuzhaya.pdf',
|
|
'file_size' => 10,
|
|
'file_mime' => 'application/pdf',
|
|
]);
|
|
|
|
expect(fn () => app(CreativeJobService::class)->enqueueDelivery($mine, $foreignDoc->id))
|
|
->toThrow(ModelNotFoundException::class);
|
|
|
|
expect(AdCreativeJob::where('kind', AdCreativeJob::KIND_DELIVER)->count())->toBe(0);
|
|
});
|
|
|
|
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' => 'Документ второй кампании',
|
|
'file_path' => 'ad-docs/1/2/vtoraya.pdf',
|
|
'file_name' => 'vtoraya.pdf',
|
|
'file_size' => 10,
|
|
'file_mime' => 'application/pdf',
|
|
]);
|
|
|
|
expect(fn () => app(CreativeJobService::class)->enqueueDelivery($campaign, $otherDoc->id))
|
|
->toThrow(ModelNotFoundException::class);
|
|
});
|
|
|
|
/**
|
|
* Везти нечего — задание не ставим. Иначе робот доедет до кабинета, не найдёт файла
|
|
* и закроет задание сбоем, а клиент увидит в ленте «не смогли отправить» на ровном месте.
|
|
*/
|
|
it('доставка не ставит задание, когда в сообщении нет файла', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
|
|
$noFile = AdCampaignMessage::create([
|
|
'tenant_id' => $campaign->tenant_id,
|
|
'campaign_id' => $campaign->id,
|
|
'author' => AdCampaignMessage::AUTHOR_CLIENT,
|
|
'body' => 'Просто ответ без вложения',
|
|
]);
|
|
|
|
expect(fn () => app(CreativeJobService::class)->enqueueDelivery($campaign, $noFile->id))
|
|
->toThrow(RuntimeException::class);
|
|
|
|
expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0);
|
|
});
|
|
|
|
/** Свой документ — задание ставится, вид и ссылка проставлены. */
|
|
it('доставка ставит задание со своим документом', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$doc = docFor($campaign);
|
|
|
|
$job = app(CreativeJobService::class)->enqueueDelivery($campaign, $doc->id);
|
|
|
|
expect($job->kind)->toBe(AdCreativeJob::KIND_DELIVER)
|
|
->and($job->message_id)->toBe($doc->id)
|
|
->and($job->status)->toBe(AdCreativeJob::STATUS_QUEUED);
|
|
});
|
|
|
|
/** Клиент нажал дважды — второго задания на тот же документ не появляется. */
|
|
it('доставка не плодит задания на один и тот же документ', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$doc = docFor($campaign);
|
|
|
|
$first = app(CreativeJobService::class)->enqueueDelivery($campaign, $doc->id);
|
|
$second = app(CreativeJobService::class)->enqueueDelivery($campaign, $doc->id);
|
|
|
|
expect($second->id)->toBe($first->id)
|
|
->and(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1);
|
|
});
|
|
|
|
/**
|
|
* 🪤 Ловушка, заложенная появлением видов: постановка обычной заливки искала «любое
|
|
* незавершённое задание кампании» и с появлением доставки вернула бы ЕЁ вместо заливки.
|
|
* Запуск кампании решил бы, что креативы уже в очереди, и робот не повёз бы картинки
|
|
* вовсе — молча, при зелёном журнале.
|
|
*/
|
|
it('постановка заливки не путает задание доставки со своим', 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]]);
|
|
$doc = docFor($campaign);
|
|
|
|
$delivery = app(CreativeJobService::class)->enqueueDelivery($campaign, $doc->id);
|
|
$upload = app(CreativeJobService::class)->enqueue($campaign);
|
|
|
|
expect($upload->id)->not->toBe($delivery->id)
|
|
->and($upload->kind)->toBe(AdCreativeJob::KIND_UPLOAD);
|
|
});
|
|
|
|
/**
|
|
* Разведка — единственный способ узнать, за что Яндекс отклонил объявление: программный
|
|
* интерфейс отдаёт только «Отклонено на модерации.» и ни слова о причине (проверено живьём
|
|
* 28.07.2026, см. ModerationReason).
|
|
*/
|
|
it('разведка ставится по отклонённому объявлению', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$banner = $campaign->banners()->first();
|
|
$banner->update(['yandex_ad_id' => 17787055204]);
|
|
|
|
$job = app(CreativeJobService::class)->enqueueInspection($campaign, $banner);
|
|
|
|
expect($job)->not->toBeNull()
|
|
->and($job->kind)->toBe(AdCreativeJob::KIND_INSPECT)
|
|
->and($job->status)->toBe(AdCreativeJob::STATUS_QUEUED)
|
|
->and((int) $job->yandex_ad_id)->toBe(17787055204);
|
|
});
|
|
|
|
/**
|
|
* 🔴 Опрос модерации бежит по расписанию, а отказ никуда не девается — объявление остаётся
|
|
* отклонённым до тех пор, пока клиент его не починит. Без защиты каждый обход ставил бы
|
|
* роботу новую разведку: робот ходил бы в кабинет по кругу, а клиент получал бы один и тот же
|
|
* доклад снова и снова.
|
|
*
|
|
* Проверяем и на ЗАКРЫТОМ задании: дедуп только по «незавершённым» не спас бы — первая
|
|
* разведка закрывается за минуту, а следующий обход поставил бы вторую.
|
|
*/
|
|
it('повторный обход второй разведки по тому же объявлению не заводит', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$banner = $campaign->banners()->first();
|
|
$banner->update(['yandex_ad_id' => 17787055204]);
|
|
|
|
$service = app(CreativeJobService::class);
|
|
$first = $service->enqueueInspection($campaign, $banner);
|
|
$first->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]);
|
|
|
|
$second = $service->enqueueInspection($campaign, $banner);
|
|
|
|
expect($second->id)->toBe($first->id)
|
|
->and(AdCreativeJob::where('campaign_id', $campaign->id)
|
|
->where('kind', AdCreativeJob::KIND_INSPECT)->count())->toBe(1);
|
|
});
|
|
|
|
/**
|
|
* После кнопки «Исправить» отклонённое объявление удаляется из кабинета, а починенное
|
|
* заводится заново и получает НОВЫЙ номер. Новый отказ — это новая беда, и смотреть на неё
|
|
* надо своими глазами: причина может быть совсем другой.
|
|
*/
|
|
it('у нового объявления после починки разведка заводится заново', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$banner = $campaign->banners()->first();
|
|
$banner->update(['yandex_ad_id' => 17787055204]);
|
|
|
|
$service = app(CreativeJobService::class);
|
|
$first = $service->enqueueInspection($campaign, $banner);
|
|
$first->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]);
|
|
|
|
$banner->update(['yandex_ad_id' => 17787102785]);
|
|
$second = $service->enqueueInspection($campaign, $banner);
|
|
|
|
expect($second->id)->not->toBe($first->id)
|
|
->and((int) $second->yandex_ad_id)->toBe(17787102785);
|
|
});
|
|
|
|
/**
|
|
* 🔴 Мина, найденная на бою 01.08.2026: упавшая разведка сгорала НАВСЕГДА.
|
|
*
|
|
* Дедуп искал любое задание по номеру объявления «независимо от состояния» — и находил
|
|
* своё же упавшее. Значит один-единственный сбой (кабинет не открылся, сеть моргнула,
|
|
* робот не дожил до отчёта) навсегда лишал клиента причины отказа: следующий обход видел
|
|
* сбойное задание, считал его дублем и второго не заводил. Вернуть разведку в строй можно
|
|
* было только правкой боевой базы руками — так 01.08 и пришлось делать со всеми 13.
|
|
*
|
|
* Лечение: сбойное задание дублем не считаем и ставим ТУ ЖЕ строку обратно в очередь.
|
|
* Именно ту же, а не новую: счётчик попыток должен копиться, иначе потолок ниже
|
|
* не сработает никогда.
|
|
*/
|
|
it('упавшая разведка встаёт в очередь снова — иначе она сгорает навсегда', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$banner = $campaign->banners()->first();
|
|
$banner->update(['yandex_ad_id' => 17787055204]);
|
|
|
|
$service = app(CreativeJobService::class);
|
|
$first = $service->enqueueInspection($campaign, $banner);
|
|
$first->update([
|
|
'status' => AdCreativeJob::STATUS_FAILED,
|
|
'attempts' => 1,
|
|
'failure_reason' => 'Окно причины не открылось',
|
|
'finished_at' => now(),
|
|
]);
|
|
|
|
$second = $service->enqueueInspection($campaign, $banner);
|
|
|
|
expect($second->id)->toBe($first->id)
|
|
->and($second->status)->toBe(AdCreativeJob::STATUS_QUEUED)
|
|
->and($second->failure_reason)->toBeNull()
|
|
->and($second->finished_at)->toBeNull()
|
|
->and((int) $second->attempts)->toBe(1)
|
|
->and(AdCreativeJob::where('campaign_id', $campaign->id)
|
|
->where('kind', AdCreativeJob::KIND_INSPECT)->count())->toBe(1);
|
|
});
|
|
|
|
/**
|
|
* 🔴 Обратная сторона той же правки. Обход модерации бежит по расписанию, и объявление,
|
|
* которого в кабинете нет вовсе (хвост неудачных перезаливок 29.07), будет падать всегда.
|
|
* Без потолка робот полез бы в живой кабинет Яндекса за ним каждый обход, вечно.
|
|
*
|
|
* Потолок считается по накопленным попыткам самой строки — их наращивает выдача задания
|
|
* роботу (takeNext). Упёрлись в потолок — задание остаётся сбойным и в очередь не встаёт.
|
|
*/
|
|
it('после потолка попыток упавшая разведка больше не встаёт', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$banner = $campaign->banners()->first();
|
|
$banner->update(['yandex_ad_id' => 17787055204]);
|
|
|
|
$service = app(CreativeJobService::class);
|
|
$first = $service->enqueueInspection($campaign, $banner);
|
|
$first->update([
|
|
'status' => AdCreativeJob::STATUS_FAILED,
|
|
'attempts' => CreativeJobService::MAX_INSPECT_ATTEMPTS,
|
|
'failure_reason' => 'Объявления в списке кабинета нет',
|
|
'finished_at' => now(),
|
|
]);
|
|
|
|
$second = $service->enqueueInspection($campaign, $banner);
|
|
|
|
expect($second->id)->toBe($first->id)
|
|
->and($second->status)->toBe(AdCreativeJob::STATUS_FAILED)
|
|
->and($second->failure_reason)->toBe('Объявления в списке кабинета нет')
|
|
->and(AdCreativeJob::where('campaign_id', $campaign->id)
|
|
->where('kind', AdCreativeJob::KIND_INSPECT)->count())->toBe(1);
|
|
});
|
|
|
|
/**
|
|
* 🪤 Сторож на то, чтобы лечение не стало новой бедой. Задание, которое робот прямо сейчас
|
|
* выполняет, сбрасывать в очередь нельзя: выдача пускает в работу строго одно задание, и
|
|
* сброшенное посреди работы вернулось бы вторым — робот отчитался бы по заданию, которое
|
|
* уже не «в работе», и отчёт бы отвергли.
|
|
*/
|
|
it('разведку в работе повторный обход в очередь не сбрасывает', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$banner = $campaign->banners()->first();
|
|
$banner->update(['yandex_ad_id' => 17787055204]);
|
|
|
|
$service = app(CreativeJobService::class);
|
|
$first = $service->enqueueInspection($campaign, $banner);
|
|
$first->update(['status' => AdCreativeJob::STATUS_TAKEN, 'attempts' => 1, 'taken_at' => now()]);
|
|
|
|
$second = $service->enqueueInspection($campaign, $banner);
|
|
|
|
expect($second->id)->toBe($first->id)
|
|
->and($second->status)->toBe(AdCreativeJob::STATUS_TAKEN);
|
|
});
|
|
|
|
/** Объявления в кабинете нет — смотреть не на что, робота гонять незачем. */
|
|
it('без номера объявления разведка не ставится', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$banner = $campaign->banners()->first();
|
|
|
|
expect(app(CreativeJobService::class)->enqueueInspection($campaign, $banner))->toBeNull()
|
|
->and(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0);
|
|
});
|
|
|
|
/**
|
|
* 🪤 Та же ловушка, что была с доставкой: постановка обычной заливки ищет незавершённое
|
|
* задание кампании. Появись разведка в этой выборке — запуск решил бы, что креативы уже
|
|
* в очереди, и робот не повёз бы картинки вовсе, молча.
|
|
*/
|
|
it('постановка заливки не путает разведку со своим заданием', 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]]);
|
|
$banner = $campaign->banners()->first();
|
|
$banner->update(['yandex_ad_id' => 17787055204]);
|
|
|
|
$inspection = app(CreativeJobService::class)->enqueueInspection($campaign, $banner);
|
|
$upload = app(CreativeJobService::class)->enqueue($campaign);
|
|
|
|
expect($upload->id)->not->toBe($inspection->id)
|
|
->and($upload->kind)->toBe(AdCreativeJob::KIND_UPLOAD);
|
|
});
|
|
|
|
/**
|
|
* 🔴 Сторож на боевую мину, которую тесты поймать НЕ МОГУТ обычным способом.
|
|
*
|
|
* Разведку ставит джоб опроса модерации: он бежит в очереди под ролью `crm_app_user`
|
|
* (НЕ BYPASSRLS) на дефолтном соединении и БЕЗ tenant-контекста. Без своего `SET LOCAL`
|
|
* на бою поиск дубля возвращал бы ноль ВСЕГДА (политика при пустом контексте даёт
|
|
* tenant_id = NULL), а запись падала бы «new row violates row-level security policy» —
|
|
* молча, в Log::warning, при зелёных тестах.
|
|
*
|
|
* Проверить это «по-честному» в тестах нельзя: они ходят суперпользователем `postgres`,
|
|
* которому права и RLS не проверяются. Поэтому сторожим САМ МЕХАНИЗМ — что контекст
|
|
* выставляется, и выставляется на нужного клиента.
|
|
*/
|
|
it('разведка выставляет свой tenant-контекст — иначе на бою она молча не сработает', function () {
|
|
$campaign = makeCampaignWithBanners([[300, 250]]);
|
|
$banner = $campaign->banners()->first();
|
|
$banner->update(['yandex_ad_id' => 17787055204]);
|
|
|
|
$statements = [];
|
|
DB::listen(function ($query) use (&$statements) {
|
|
$statements[] = $query->sql;
|
|
});
|
|
|
|
app(CreativeJobService::class)->enqueueInspection($campaign, $banner);
|
|
|
|
$expected = 'SET LOCAL app.current_tenant_id = '.$campaign->tenant_id;
|
|
|
|
expect($statements)->toContain($expected);
|
|
});
|