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 with the current creatives snapshot', 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->status)->toBe(AdCreativeJob::STATUS_QUEUED) ->and($job->snapshot_before)->toBe(['111' => [300, 250]]); }); 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); }); /** * «Задание в работе всегда не больше одного» — инвариант, на котором держится ВСЁ * опознание креативов: слепки `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); });