2026-07-27 17:13:02 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Models\AdCampaign;
|
|
|
|
|
use App\Models\AdCampaignBanner;
|
2026-07-28 19:28:31 +03:00
|
|
|
use App\Models\AdCampaignMessage;
|
2026-07-27 17:13:02 +03:00
|
|
|
use App\Models\AdCreativeJob;
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
use App\Services\Advertising\CreativeJobService;
|
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
2026-07-28 19:28:31 +03:00
|
|
|
use Illuminate\Http\UploadedFile;
|
2026-07-27 17:13:02 +03:00
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
|
|
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
config(['services.creative_robot.token' => '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']]]]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-27 20:06:57 +03:00
|
|
|
/**
|
|
|
|
|
* Адрес файла содержит номер задания.
|
|
|
|
|
*
|
|
|
|
|
* Раньше адрес был «баннер такой-то», а задание подбиралось как «какое-нибудь в работе».
|
|
|
|
|
* Пока в работе строго одно задание, это совпадало; но защита выдачи чужих картинок
|
|
|
|
|
* держалась на честном слове, а не на самом запросе.
|
|
|
|
|
*/
|
|
|
|
|
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"));
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-27 17:13:02 +03:00
|
|
|
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');
|
|
|
|
|
|
2026-07-27 20:06:57 +03:00
|
|
|
$job = app(CreativeJobService::class)->enqueue($campaign);
|
2026-07-27 17:13:02 +03:00
|
|
|
app(CreativeJobService::class)->takeNext();
|
|
|
|
|
|
2026-07-27 20:06:57 +03:00
|
|
|
// 🪤 Проверяем СОДЕРЖИМОЕ, а не только «200». Незнакомый адрес перехватывает страница
|
|
|
|
|
// сайта и тоже отвечает 200 — на голом assertOk() тест зеленел бы без маршрута вовсе.
|
|
|
|
|
$res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
|
|
|
|
|
->get("/api/creative-robot/jobs/{$job->id}/banners/{$banners[0]->id}/file")
|
2026-07-27 17:13:02 +03:00
|
|
|
->assertOk();
|
2026-07-27 20:06:57 +03:00
|
|
|
|
|
|
|
|
expect($res->streamedContent())->toBe('BINARY');
|
2026-07-27 17:13:02 +03:00
|
|
|
});
|
|
|
|
|
|
2026-07-28 05:37:56 +03:00
|
|
|
/**
|
|
|
|
|
* Клиенту разрешены 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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-27 17:13:02 +03:00
|
|
|
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');
|
|
|
|
|
|
2026-07-27 20:06:57 +03:00
|
|
|
$job = app(CreativeJobService::class)->enqueue($mine);
|
2026-07-27 17:13:02 +03:00
|
|
|
app(CreativeJobService::class)->takeNext();
|
|
|
|
|
|
|
|
|
|
$this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET')
|
2026-07-27 20:06:57 +03:00
|
|
|
->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")
|
2026-07-27 17:13:02 +03:00
|
|
|
->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('вход слетел');
|
|
|
|
|
});
|
2026-07-27 19:36:03 +03:00
|
|
|
|
2026-07-27 20:42:23 +03:00
|
|
|
/**
|
|
|
|
|
* Своя беда портала не должна вешать очередь.
|
|
|
|
|
*
|
|
|
|
|
* Приём отчёта «готово» ходит в живой Яндекс за слепком креативов. Любая ошибка 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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-27 19:36:03 +03:00
|
|
|
/**
|
|
|
|
|
* Отчёт принимается ТОЛЬКО по заданию, которое сейчас в работе.
|
|
|
|
|
*
|
|
|
|
|
* Без этой проверки номер задания брался из адреса как есть: `{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('первый сбой');
|
|
|
|
|
});
|
2026-07-28 07:21:02 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Токен проверяем ПЕРВЫМ, а уже потом переключаем соединение с базой на служебную роль.
|
|
|
|
|
* Порядок был обратный: запрос без токена сначала переключал соединение и только затем
|
|
|
|
|
* получал отказ. Запросов к базе при этом не делалось, поэтому поведение сегодня не меняется —
|
|
|
|
|
* это порядок «сначала пропуск, потом ключи от служебного входа», ровно как у админского
|
|
|
|
|
* канала (см. комментарий в 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']);
|
|
|
|
|
});
|
2026-07-28 19:28:31 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Разведка — это «сходить посмотреть», а не «отвезти картинки». Роботу нужен номер
|
|
|
|
|
* объявления в кабинете и больше ничего.
|
|
|
|
|
*/
|
|
|
|
|
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);
|
|
|
|
|
});
|