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); });