d26716ed90
Р1. Кампания, не добравшая смету показов, оставалась running навсегда, а заморозка денег клиента - ACTIVE навсегда: единственным переходом в completed было delivered >= paid_impressions, а задачи, закрывающей кампанию по истечении срока показа, не существовало вовсе. Для медийки по списку телефонов недокрут сметы - типовой исход, а не редкий случай. Новая колонка ad_campaigns.shows_until хранит последний день показа - ровно тот, что уходит в Директ параметром EndDate. Пишется вместе с yandex_campaign_id, то есть в момент, когда дату начинает держать Яндекс; возобновляемый запуск переиспользует уже записанную дату, чтобы портал и Директ считали срок одинаково. У выхода 1 в CampaignImpressionCharger появилось второе условие - новых мест вызова AdWalletService::release не добавилось, их по-прежнему ровно четыре. Запись v9.07 в журнале схемы, rls-reviewer GO. Р2. Отчёт робота принимался по любому заданию в любом состоянии: номер брался из адреса как есть. Готово по чужому ещё не выданному заданию разложило бы номера креативов чужой кампании по её баннерам - картинка одного клиента уехала бы в объявление другого; сбой по уже закрытому заданию переписал бы правильный результат на failed. Теперь done принимает отчёт только по заданию в статусе taken - 409 в остальных случаях, та же проверка продублирована в сервисе. Р3, первая половина. Проверка «в работе никого» в takeNext не блокировала строку: две одновременные выдачи обе её проходили и уносили разные задания. Слепки creatives.get перемешивались, а размеры блоков у всех клиентов одинаковые, поэтому итог - тихая привязка чужого номера креатива. Гарантию даёт частичный уникальный индекс uq_creative_job_single_taken, плюс advisory-замок первой строкой транзакции, чтобы штатный путь спокойно отвечал роботу «работы нет». Осталось по Р3: привязать выдачу файла к номеру задания, rls-reviewer по индексу, запись v9.08 в журнал схемы. Ход работы - в файле PROGRESS рядом с промтом v12. Тесты, прогнаны в одиночку: портал 256/256, робот 35/35. Все девять новых тестов были красными до правок, каждая защита проверена вырезанием. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
9.4 KiB
PHP
234 lines
9.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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;
|
|
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']]]]);
|
|
});
|
|
|
|
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('первый сбой');
|
|
});
|