'ROBOTSECRET']); config(['services.yandex_direct.enabled' => true]); config(['services.yandex_direct.token' => 'T']); config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); }); function makeRobotCampaign(array $sizes = [[300, 250]]): array { $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', ]); $banners = []; foreach ($sizes as [$w, $h]) { $banners[] = 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, $banners]; } it('rejects a request without the robot token', function () { $this->getJson('/api/creative-robot/next')->assertStatus(401); }); it('rejects a request with a wrong robot token', function () { $this->withHeader('X-Creative-Robot-Token', 'nope') ->getJson('/api/creative-robot/next') ->assertStatus(401); }); it('closes the channel when no token is configured', function () { config(['services.creative_robot.token' => '']); $this->withHeader('X-Creative-Robot-Token', '') ->getJson('/api/creative-robot/next') ->assertStatus(401); }); it('returns nothing to do when the queue is empty', function () { $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->getJson('/api/creative-robot/next') ->assertOk() ->assertJson(['job' => null]); }); it('hands out a job with the list of banner files to upload', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); [$campaign] = makeRobotCampaign([[300, 250]]); app(CreativeJobService::class)->enqueue($campaign); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->getJson('/api/creative-robot/next') ->assertOk() ->assertJsonPath('job.campaign_id', $campaign->id) ->assertJsonPath('job.banners.0.width', 300) ->assertJsonPath('job.banners.0.height', 250) ->assertJsonStructure(['job' => ['id', 'campaign_id', 'banners' => [['banner_id', 'width', 'height', 'file_url']]]]); }); /** * Адрес файла содержит номер задания. * * Раньше адрес был «баннер такой-то», а задание подбиралось как «какое-нибудь в работе». * Пока в работе строго одно задание, это совпадало; но защита выдачи чужих картинок * держалась на честном слове, а не на самом запросе. */ it('binds the file address to the job the robot was given', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); [$campaign] = makeRobotCampaign([[300, 250]]); app(CreativeJobService::class)->enqueue($campaign); $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->getJson('/api/creative-robot/next') ->assertOk(); $jobId = $res->json('job.id'); $bannerId = $res->json('job.banners.0.banner_id'); expect($res->json('job.banners.0.file_url')) ->toBe(url("/api/creative-robot/jobs/{$jobId}/banners/{$bannerId}/file")); }); it('serves a banner file of the job in flight', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); Storage::fake('local'); [$campaign, $banners] = makeRobotCampaign([[300, 250]]); Storage::disk('local')->put($banners[0]->path, 'BINARY'); $job = app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); // 🪤 Проверяем СОДЕРЖИМОЕ, а не только «200». Незнакомый адрес перехватывает страница // сайта и тоже отвечает 200 — на голом assertOk() тест зеленел бы без маршрута вовсе. $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->get("/api/creative-robot/jobs/{$job->id}/banners/{$banners[0]->id}/file") ->assertOk(); expect($res->streamedContent())->toBe('BINARY'); }); /** * Клиенту разрешены jpg, png и gif, а портал отдавал роботу любой файл под именем «.jpg». * Робот сохраняет его на диск под этим именем и таким же скармливает кабинету Яндекса — * то есть PNG приезжает туда как «картинка.jpg». Кабинет либо отвергнет файл, либо примет * с искажением; разбираться придётся человеку по письму «не смог загрузить». * * Отдаём настоящее расширение и настоящий тип содержимого — робот на них и опирается. */ it('serves the banner file under its real extension and content type', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); Storage::fake('local'); [$campaign, $banners] = makeRobotCampaign([[300, 250]]); $banners[0]->update(['path' => "ad-banners/x/{$campaign->id}/300x250.png"]); Storage::disk('local')->put($banners[0]->fresh()->path, 'PNGBINARY'); $job = app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->get("/api/creative-robot/jobs/{$job->id}/banners/{$banners[0]->id}/file") ->assertOk(); expect($res->headers->get('Content-Type'))->toBe('image/png') ->and($res->headers->get('Content-Disposition'))->toContain('.png'); }); it('refuses a banner that does not belong to the job in flight', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); Storage::fake('local'); [$mine, $mineBanners] = makeRobotCampaign([[300, 250]]); [, $strangerBanners] = makeRobotCampaign([[728, 90]]); Storage::disk('local')->put($mineBanners[0]->path, 'BINARY'); Storage::disk('local')->put($strangerBanners[0]->path, 'BINARY'); $job = app(CreativeJobService::class)->enqueue($mine); app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->get("/api/creative-robot/jobs/{$job->id}/banners/{$strangerBanners[0]->id}/file") ->assertStatus(404); }); /** * Файл отдаётся только под номером задания, которое реально в работе. * * Берём заведомо трудный случай: номер в адресе — от задания, которое НЕ в работе, а сам * баннер принадлежит той же кампании, чьё задание сейчас в работе. Пока адрес не был привязан * к заданию, портал молча подставлял «какое-нибудь задание в работе» и отдавал файл. */ it('refuses a file request made under a job that is not in flight', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); Storage::fake('local'); [$campaign, $banners] = makeRobotCampaign([[300, 250]]); Storage::disk('local')->put($banners[0]->path, 'BINARY'); app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); // Второе задание той же кампании, мимо сервиса: он бы вернул уже существующее. $stale = AdCreativeJob::create([ 'tenant_id' => $campaign->tenant_id, 'campaign_id' => $campaign->id, 'status' => AdCreativeJob::STATUS_QUEUED, 'snapshot_before' => [], ]); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->get("/api/creative-robot/jobs/{$stale->id}/banners/{$banners[0]->id}/file") ->assertStatus(404); }); it('accepts the done report and writes creative numbers', function () { Http::fake(['*/json/v5/creatives' => Http::sequence() ->push(['result' => ['Creatives' => []]]) ->push(['result' => ['Creatives' => [ ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], ]]]), ]); [$campaign, $banners] = makeRobotCampaign([[300, 250]]); $job = app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) ->assertOk(); expect($banners[0]->fresh()->yandex_creative_id)->toBe(555) ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); }); it('accepts a failure report from the robot', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); [$campaign] = makeRobotCampaign([[300, 250]]); $job = app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'вход слетел']) ->assertOk(); expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) ->and($job->fresh()->failure_reason)->toBe('вход слетел'); }); /** * Своя беда портала не должна вешать очередь. * * Приём отчёта «готово» ходит в живой Яндекс за слепком креативов. Любая ошибка API * (недоступен, лимит, отвалилась сеть) вылетала наружу необработанной: робот получал 500, * задание навсегда оставалось «в работе», а выдача заданий при живом «в работе» отвечает * «работы нет» ВСЕМ — ни одна кампания больше не стартовала бы. */ it('does not leave the job in flight when the portal itself fails on the done report', function () { Http::fake(['*/json/v5/creatives' => Http::sequence() ->push(['result' => ['Creatives' => []]]) ->push(['error' => ['error_string' => 'Сервис временно недоступен']], 500), ]); [$campaign, $banners] = makeRobotCampaign([[300, 250]]); $job = app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) ->assertOk() ->assertJsonPath('status', AdCreativeJob::STATUS_FAILED); expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) ->and($job->fresh()->failure_reason)->toContain('Сервис временно недоступен') ->and($banners[0]->fresh()->yandex_creative_id)->toBeNull(); }); /** * Отчёт принимается ТОЛЬКО по заданию, которое сейчас в работе. * * Без этой проверки номер задания брался из адреса как есть: `{ok:true}` по чужому * `queued`-заданию разложил бы номера креативов ЧУЖОЙ кампании по её баннерам — * картинка одного клиента уехала бы в объявление другого. А `{ok:false}` по уже * закрытому заданию переписал бы правильный результат на «сбой». */ it('refuses a done report for a job that has not been taken yet', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); [$campaign, $banners] = makeRobotCampaign([[300, 250]]); $job = app(CreativeJobService::class)->enqueue($campaign); // остаётся queued $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) ->assertStatus(409); expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_QUEUED) ->and($banners[0]->fresh()->yandex_creative_id)->toBeNull(); }); it('refuses a second done report for a job already finished', function () { Http::fake(['*/json/v5/creatives' => Http::sequence() ->push(['result' => ['Creatives' => []]]) ->push(['result' => ['Creatives' => [ ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], ]]]), ]); [$campaign, $banners] = makeRobotCampaign([[300, 250]]); $job = app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) ->assertOk(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) ->assertStatus(409); expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE) ->and($banners[0]->fresh()->yandex_creative_id)->toBe(555); }); it('refuses a failure report that would overwrite a finished job', function () { Http::fake(['*/json/v5/creatives' => Http::sequence() ->push(['result' => ['Creatives' => []]]) ->push(['result' => ['Creatives' => [ ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], ]]]), ]); [$campaign] = makeRobotCampaign([[300, 250]]); $job = app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) ->assertOk(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'обрыв']) ->assertStatus(409); expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); }); it('refuses a report for a job already marked failed', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); [$campaign] = makeRobotCampaign([[300, 250]]); $job = app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); app(CreativeJobService::class)->fail($job->fresh(), 'первый сбой'); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'второй сбой']) ->assertStatus(409); expect($job->fresh()->failure_reason)->toBe('первый сбой'); }); /** * Токен проверяем ПЕРВЫМ, а уже потом переключаем соединение с базой на служебную роль. * Порядок был обратный: запрос без токена сначала переключал соединение и только затем * получал отказ. Запросов к базе при этом не делалось, поэтому поведение сегодня не меняется — * это порядок «сначала пропуск, потом ключи от служебного входа», ровно как у админского * канала (см. комментарий в UseAdminConnection). * * Проверить это можно только порядком посредников: наблюдаемой разницы в ответе нет. * Поэтому тест ничего не гарантирует про поведение — он держит порядок от обратной правки. */ it('токен служебного канала проверяется раньше переключения соединения', function () { $route = collect(app('router')->getRoutes()) ->first(fn ($r) => $r->uri() === 'api/creative-robot/next'); expect($route)->not->toBeNull() ->and($route->middleware())->toBe(['web', 'creative-robot', 'admin-db']); }); /** * Разведка — это «сходить посмотреть», а не «отвезти картинки». Роботу нужен номер * объявления в кабинете и больше ничего. */ it('разведку выдаём с номером объявления и без списка картинок', function () { [$campaign, $banners] = makeRobotCampaign(); $campaign->update(['yandex_campaign_id' => 713110757]); $banners[0]->update(['yandex_ad_id' => 17787055204]); app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->getJson('/api/creative-robot/next') ->assertOk(); expect($res->json('job.kind'))->toBe(AdCreativeJob::KIND_INSPECT) ->and((int) $res->json('job.yandex_ad_id'))->toBe(17787055204) // Список объявлений в кабинете открывается только по номеру кампании В ЯНДЕКСЕ — // наш внутренний номер кабинету ничего не говорит. ->and((int) $res->json('job.yandex_campaign_id'))->toBe(713110757) ->and($res->json('job.banners'))->toBe([]); }); /** * 🪤 Слепок креативов снимался при выдаче ЛЮБОГО задания — сетевой вызов в живой Яндекс * внутри открытой транзакции. Разведке он не нужен вовсе: она ничего не заливает, * опознавать нечего. Лишний поход — лишний повод очереди встать, когда Яндекс икнул. */ it('за слепком креативов ради разведки в Яндекс не ходим', function () { Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); [$campaign, $banners] = makeRobotCampaign(); $banners[0]->update(['yandex_ad_id' => 17787055204]); app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->getJson('/api/creative-robot/next') ->assertOk(); Http::assertNothingSent(); }); /** Рубильник выключен — робот не получает ни одного задания, в кабинет идти не с чем. */ it('рубильник выключен — разведку роботу не выдаём', function () { config(['services.yandex_direct.enabled' => false]); Http::fake(); [$campaign, $banners] = makeRobotCampaign(); $banners[0]->update(['yandex_ad_id' => 17787055204]); app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->getJson('/api/creative-robot/next') ->assertStatus(500); Http::assertNothingSent(); expect(AdCreativeJob::where('campaign_id', $campaign->id)->first()->status) ->toBe(AdCreativeJob::STATUS_QUEUED); }); /** Заводит разведку, взятую роботом в работу. */ function takenInspection(int $adId = 17787055204): array { [$campaign, $banners] = makeRobotCampaign(); $banners[0]->update(['yandex_ad_id' => $adId]); app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); $job = app(CreativeJobService::class)->takeNext(); return [$campaign, $banners[0], $job]; } /** * Ради этого разведка и затевалась: причину отказа Яндекс машине не говорит, её видит * только робот на экране кабинета. Доклад ложится в ленту СЛОВАМИ ЯНДЕКСА — портал ничего * не переписывает и не толкует. */ it('доклад разведки ложится в ленту от имени Яндекса со снимком экрана', function () { Storage::fake('local'); [$campaign, $banner, $job] = takenInspection(); $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->post("/api/creative-robot/jobs/{$job->id}/inspection", [ 'report' => "Нет предупреждения: финансовые услуги\n\nОбъявление о финансовых услугах должно содержать наименование юридического лица.", 'screenshot' => UploadedFile::fake()->image('okno.png'), ]); $res->assertOk(); $message = AdCampaignMessage::where('campaign_id', $campaign->id)->latest('id')->first(); expect($message)->not->toBeNull() ->and($message->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX) ->and((int) $message->banner_id)->toBe((int) $banner->id) ->and($message->body)->toContain('Нет предупреждения: финансовые услуги') ->and($message->file_name)->toBe('okno.png') ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); Storage::disk('local')->assertExists($message->file_path); }); /** Снимок — дело хорошее, но не обязательное: причина важнее картинки. */ it('доклад разведки принимается и без снимка', function () { Storage::fake('local'); [$campaign, , $job] = takenInspection(17787102785); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->post("/api/creative-robot/jobs/{$job->id}/inspection", [ 'report' => 'Нет предупреждений: медицина', ])->assertOk(); expect(AdCampaignMessage::where('campaign_id', $campaign->id)->latest('id')->first()->body) ->toContain('медицина'); }); /** * 🔴 Робот не понял, что видит — задание сбойное, и в ленту клиенту НИЧЕГО не сочиняем. * Придуманная причина хуже молчания: по ней клиент пойдёт переделывать не то. */ it('робот растерялся — задание сбойное, а в ленту клиенту ничего не попадает', function () { Storage::fake('local'); [$campaign, , $job] = takenInspection(17787055205); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/done", [ 'ok' => false, 'reason' => 'разметка не совпала: окна BannerStatusCell.Popup нет', ])->assertOk(); expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) ->and($job->fresh()->failure_reason)->toContain('разметка не совпала') ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); }); /** Пустой доклад — это «ничего не выяснил», а не причина отказа. В ленту не пускаем. */ it('пустой доклад разведки не принимается', function () { Storage::fake('local'); [$campaign, , $job] = takenInspection(17787055206); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/inspection", ['report' => ' ']) ->assertStatus(422); expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0) ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_TAKEN); }); /** * Доклад разведки по заданию заливки означает, что стороны разошлись в том, какую работу * робот делал. Принять такой доклад — значит положить клиенту в ленту неизвестно что. */ it('доклад разведки по заданию заливки не принимается', function () { Storage::fake('local'); Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); [$campaign] = makeRobotCampaign(); app(CreativeJobService::class)->enqueue($campaign); $job = app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/inspection", ['report' => 'что-то']) ->assertStatus(409); expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); }); /** Задание не в работе — отчёт по нему не принимается, как и у заливки. */ it('доклад разведки по невыданному заданию не принимается', function () { Storage::fake('local'); [$campaign, $banners] = makeRobotCampaign(); $banners[0]->update(['yandex_ad_id' => 17787055207]); $job = app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->postJson("/api/creative-robot/jobs/{$job->id}/inspection", ['report' => 'что-то']) ->assertStatus(409); expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); });