'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('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'); app(CreativeJobService::class)->enqueue($campaign); app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->get("/api/creative-robot/banners/{$banners[0]->id}/file") ->assertOk(); }); 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'); app(CreativeJobService::class)->enqueue($mine); app(CreativeJobService::class)->takeNext(); $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') ->get("/api/creative-robot/banners/{$strangerBanners[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('вход слетел'); }); /** * Отчёт принимается ТОЛЬКО по заданию, которое сейчас в работе. * * Без этой проверки номер задания брался из адреса как есть: `{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('первый сбой'); });