9234a9c2bc
Портал ставит роботу задание, когда у баннеров ещё нет номеров креативов: вместо ошибки клиент видит «готовим картинки», кампания остаётся черновиком, деньги не морозятся. Робот берёт задания строго по одному — иначе слепки креативов до и после перемешаются, и опознать их будет нельзя. Канал робота закрыт своим сервис-токеном, внесён в исключения проверки CSRF и отдаёт файл только того задания, которое сейчас в работе. Постановка задания стоит внутри проверки рубильника Директа — при выключенном рубильнике портал в Яндекс не ходит. Права на новую таблицу выданы роли crm_admin_user: канал идёт через посредник admin-db, подменяющий подключение. Нумератор выдан crm_app_user — он единственный вставляет строки. Журнал схемы — запись v9.06. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
5.7 KiB
PHP
139 lines
5.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Exceptions\Advertising\CreativeMatchFailedException;
|
|
use App\Models\AdCampaign;
|
|
use App\Models\AdCampaignBanner;
|
|
use App\Models\AdCreativeJob;
|
|
use App\Models\Tenant;
|
|
use App\Services\Advertising\CreativeJobService;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
function makeCampaignWithBanners(array $sizes): AdCampaign
|
|
{
|
|
$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',
|
|
]);
|
|
|
|
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');
|
|
});
|