Files
portal/app/tests/Feature/Advertising/CreativeJobServiceTest.php
T
Дмитрий 542b7d2d22 fix реклама за показы: очередь робота больше не встаёт колом, доклад об успехе не врёт, тихий ноль ловится
Р6 — задание, брошенное «в работе», держало очередь для всех клиентов навсегда.
Причина сбоя обрезается до 900 знаков, иначе портал отвергал отчёт о самом частом
виде поломки. Провал отчёта больше не глотается молча — едет в письмо человеку.
Приём отчёта ловит любую ошибку и закрывает задание сбойным вместо 500 роботу.
Новый сторож creative-jobs:reap каждые 10 минут разбирает пробку.

Р7 — обрыв на докладе «готово» объявлял провалом уже сделанную работу. Доклад
вынесен из-под обработчика сбоя, повторяется трижды, при неудаче только письмо.
Осечка ухода со страницы после «Создать» больше не считается провалом загрузки.

Р8 — пустой список размеров считался успехом, и робот заливал одни и те же файлы
по кругу, оставляя мусор в живом кабинете. Теперь громкий отказ до похода в Яндекс.

Портал 274/274, робот 41/41. Все защиты проверены вырезанием.
Выходов снятия заморозки денег по-прежнему четыре.
2026-07-27 20:42:23 +03:00

211 lines
10 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\CreativeIdMatcher;
use App\Services\Advertising\CreativeJobService;
use Illuminate\Database\QueryException;
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');
});
/**
* Пустой список ожидаемых размеров — это НЕ «всё сошлось», это тихий ноль.
*
* Раньше `match()` с пустым списком просто не входил в цикл и молча возвращал пустоту.
* А пустой список получается сам собой: если после выката не перезапустить
* `db/03_service_bypass_policies.sql`, служебная роль не увидит ни одного баннера. Дальше
* задание помечается «готово» с нулём проставленных номеров, запуск снова видит баннеры
* без креативов, ставит новое задание — и робот заливает те же файлы по кругу, оставляя
* каждый раз пачку мусорных креативов в живом кабинете. В журнале при этом всё зелёное.
*/
it('refuses to call an empty match a success', function () {
expect(fn () => app(CreativeIdMatcher::class)->match([], [], []))
->toThrow(CreativeMatchFailedException::class);
});
it('fails a done report for a campaign without banners without asking Yandex at all', 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();
// Мимо enqueue(): он сам сходил бы в Яндекс за слепком и испортил проверку «не ходили».
$campaign = makeCampaignWithBanners([]);
$job = AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'status' => AdCreativeJob::STATUS_TAKEN,
'snapshot_before' => [],
'taken_at' => now(),
]);
expect(fn () => app(CreativeJobService::class)->complete($job))
->toThrow(CreativeMatchFailedException::class);
Http::assertNothingSent();
expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED);
});
/**
* «Задание в работе всегда не больше одного» — инвариант, на котором держится ВСЁ
* опознание креативов: слепки `creatives.get` до/после снимаются по аккаунту целиком,
* и два задания одновременно перемешают их между кампаниями. Размеры IAB у всех
* клиентов одинаковые (300×250, 728×90), поэтому итог — не громкий отказ, а тихая
* привязка ЧУЖОГО номера креатива к баннеру.
*
* Проверки `exists()` в коде мало: она не блокирует строку, и две одновременные
* транзакции обе видят «в работе никого». Гарантию даёт база — частичный уникальный
* индекс.
*/
it('lets the database refuse a second job in flight', 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);
$second = app(CreativeJobService::class)->enqueue($b);
app(CreativeJobService::class)->takeNext(); // первое ушло в работу
// Обход сервиса — прямая запись в базу, как сделала бы вторая параллельная транзакция.
expect(fn () => DB::transaction(fn () => AdCreativeJob::where('id', $second->id)
->update(['status' => AdCreativeJob::STATUS_TAKEN])))
->toThrow(QueryException::class);
expect(AdCreativeJob::where('status', AdCreativeJob::STATUS_TAKEN)->count())->toBe(1);
});