Files
portal/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php
T
Дмитрий 0548fc0d8f fix реклама за показы: слепок креативов при выдаче, сверка размера перед объявлением, один баннер на слот
Хвосты портала П1-П8 из приёмочного листа v12.

П1 обрыв постановки задания больше не даёт клиенту голый 500 — 503 с человеческим
текстом и записью в журнал. Таймаут у HTTP-клиента Laravel уже был, эта половина
находки не подтвердилась.

П2 и П6 роботу отдаются только баннеры без номера креатива — тот же список
сопоставляется при отчёте. Раньше стороны расходились и опознание падало на
безупречной работе робота, плодя дубли в кабинете. Плюс постраничный обход описи
креативов: слепок обрывался на первой странице.

П3 слепок «до» снимается при выдаче задания, а не при постановке, и в той же
транзакции, что и перевод в работу. Два задания в очереди получали одинаковый
слепок, второе падало всегда. Постановка перестала зависеть от живости Яндекса.

П4 перед созданием объявлений сверяется настоящий размер каждого креатива одним
запросом. Не сошлось или креатива нет — запуск не идёт.

П5 уникальный индекс uq_ad_campaign_banner_slot, запись v9.09 в CHANGELOG схемы,
rls-reviewer GO.

П7 замок на правку расширен на сегмент Аудиторий — он создаётся раньше кампании.
Смежная находка: перезаливка картинки теперь обнуляет номер креатива.

П8 и Р-х5 файл отдаётся под настоящим расширением и типом содержимого, имя от
номера баннера; робот берёт расширение из ответа портала.

Портал 287/287, робот 43/43. Денежных выходов снятия заморозки по-прежнему четыре.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 05:37:56 +03:00

389 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\BannerSizes;
use App\Services\Advertising\BannerUploadPolicy;
use Illuminate\Database\QueryException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
function bannerCampaign(): array
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false,
]);
return [$tenant, $user, $campaign];
}
/**
* Слот «кампания + размер» обязан быть один. Загрузка идёт через updateOrCreate, а он без
* уникального индекса не атомарен: два одновременных нажатия «загрузить» на один и тот же
* размер (медленная сеть, двойной клик, повтор с телефона) обе проверки «такой строки нет»
* проходят — и в базе оказываются ДВЕ строки одного слота.
*
* Дальше беда тихая: список слотов клиенту собирается через keyBy по размеру и одну строку
* молча теряет — в портале виден один баннер. А запуск идёт по ВСЕМ строкам и создаёт ДВА
* одинаковых объявления, которые крутятся за деньги клиента.
*
* Проверку в коде тут не поставить: она не блокирует, а гарантию даёт только база.
*/
it('база не даёт завести второй баннер того же размера в одной кампании', function () {
[$tenant, , $campaign] = bannerCampaign();
$slot = [
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'bytes' => 1000,
'included' => true,
];
AdCampaignBanner::create($slot + ['path' => 'ad-banners/a/300x250.jpg']);
expect(fn () => AdCampaignBanner::create($slot + ['path' => 'ad-banners/b/300x250.jpg']))
->toThrow(QueryException::class);
expect(AdCampaignBanner::where('campaign_id', $campaign->id)->count())->toBe(1);
});
it('список слотов без загрузок содержит все 15 размеров, все не загружены', function () {
[, $user, $campaign] = bannerCampaign();
$res = $this->actingAs($user)->getJson("/api/advertising/campaigns/{$campaign->id}/banners");
$res->assertOk();
$res->assertJsonPath('approved_at', null);
$res->assertJsonPath('max_bytes', BannerUploadPolicy::MAX_BYTES);
expect($res->json('formats'))->toBe(BannerUploadPolicy::FORMATS);
$slots = $res->json('slots');
expect($slots)->toHaveCount(15);
foreach ($slots as $slot) {
expect($slot['uploaded'])->toBeFalse();
expect($slot['banner_id'])->toBeNull();
expect($slot['preview_url'])->toBeNull();
}
});
it('загрузка баннера точного размера создаёт слот', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.jpg', 728, 90),
]);
$res->assertCreated();
expect($res->json('slot.uploaded'))->toBeTrue();
expect($res->json('slot.width'))->toBe(728);
expect($res->json('slot.height'))->toBe(90);
expect($res->json('slot.included'))->toBeTrue();
$banner = AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->where('height', 90)->first();
expect($banner)->not->toBeNull();
Storage::disk('local')->assertExists($banner->path);
expect($campaign->fresh()->banners_approved_at)->toBeNull();
});
it('картинка не того размера — 422 с понятным сообщением', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.jpg', 800, 100),
]);
$res->assertStatus(422);
expect($res->json('message'))->toContain('Нужен ровно 728×90');
});
it('неизвестный размер — 422', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 999, 'height' => 999,
'file' => UploadedFile::fake()->image('b.jpg', 999, 999),
]);
$res->assertStatus(422);
expect($res->json('message'))->toBe('Неизвестный размер баннера.');
});
it('не-картинка отклоняется валидатором', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->create('b.txt', 5, 'text/plain'),
]);
$res->assertStatus(422);
});
it('повторная загрузка того же размера заменяет строку, а не дублирует', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b1.jpg', 728, 90),
])->assertCreated();
$campaign->update(['banners_approved_at' => now()]);
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b2.png', 728, 90),
]);
$res->assertCreated();
expect(AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->where('height', 90)->count())->toBe(1);
expect($campaign->fresh()->banners_approved_at)->toBeNull();
});
it('переключает included баннера', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.jpg', 728, 90),
])->assertCreated();
$bannerId = $up->json('slot.banner_id');
$res = $this->actingAs($user)->patchJson("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}", [
'included' => false,
]);
$res->assertOk();
expect($res->json('slot.included'))->toBeFalse();
$banner = AdCampaignBanner::find($bannerId);
expect($banner->included)->toBeFalse();
});
/**
* Робот успел отвезти креативы в кабинет (номер проставлен), клиент передумал и перезалил
* картинку. Кампании в Директе ещё нет, поэтому замок на баннерах не срабатывает — и это
* правильно, менять набор до запуска можно. Но номер креатива остаётся от СТАРОЙ картинки:
* в портале клиент видит новую, а запуск создаст объявление по прежней, и клиент заплатит
* за показы того, что уже забраковал. Номер обязан обнулиться вместе с картинкой.
*/
it('перезаливка картинки сбрасывает номер креатива Яндекса', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.jpg', 728, 90),
])->assertCreated();
$bannerId = $up->json('slot.banner_id');
// Робот отработал: номер креатива у баннера есть.
AdCampaignBanner::where('id', $bannerId)->update(['yandex_creative_id' => 4242]);
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('new.jpg', 728, 90),
])->assertCreated();
expect(AdCampaignBanner::find($bannerId)->yandex_creative_id)->toBeNull();
});
it('удаляет баннер и сбрасывает утверждение', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.jpg', 728, 90),
])->assertCreated();
$bannerId = $up->json('slot.banner_id');
$campaign->update(['banners_approved_at' => now()]);
$res = $this->actingAs($user)->deleteJson("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}");
$res->assertStatus(204);
expect(AdCampaignBanner::find($bannerId))->toBeNull();
expect($campaign->fresh()->banners_approved_at)->toBeNull();
});
it('утверждает при хотя бы одном included баннере', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.jpg', 728, 90),
])->assertCreated();
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners/approve");
$res->assertOk();
expect($campaign->fresh()->banners_approved_at)->not->toBeNull();
});
it('не утверждает, если все included=false', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.jpg', 728, 90),
])->assertCreated();
$bannerId = $up->json('slot.banner_id');
$this->actingAs($user)->patchJson("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}", ['included' => false])->assertOk();
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners/approve");
$res->assertStatus(422);
expect($res->json('message'))->toBe('Отметьте хотя бы один баннер для показа.');
});
it('нельзя утвердить пустой набор', function () {
[, $user, $campaign] = bannerCampaign();
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners/approve")->assertStatus(422);
});
it('превью работает и отдаёт content-type по расширению', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.png', 728, 90),
])->assertCreated();
$bannerId = $up->json('slot.banner_id');
$preview = $this->actingAs($user)->get("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}/preview");
$preview->assertOk();
expect($preview->headers->get('content-type'))->toContain('image/png');
});
it('чужой тенант не видит превью баннера (404)', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('b.jpg', 728, 90),
])->assertCreated();
$bannerId = $up->json('slot.banner_id');
$otherTenant = Tenant::factory()->create();
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
$this->actingAs($otherUser)->get("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}/preview")->assertNotFound();
});
it('размеры вне BannerSizes перечислены целиком и по порядку', function () {
[, $user, $campaign] = bannerCampaign();
$res = $this->actingAs($user)->getJson("/api/advertising/campaigns/{$campaign->id}/banners");
$slots = $res->json('slots');
foreach (BannerSizes::all() as $i => [$w, $h]) {
expect($slots[$i]['width'])->toBe($w);
expect($slots[$i]['height'])->toBe($h);
}
});
/*
* ЗАМОК НА БАННЕРАХ ПОСЛЕ ЗАВЕДЕНИЯ КАМПАНИИ В ЯНДЕКСЕ.
*
* Тот же замок, что уже стоит на правке параметров кампании, и по тому же признаку —
* `yandex_campaign_id !== null`. Не по статусу: оборвавшийся запуск оставляет статус `draft`,
* а кампания, группа и часть объявлений в кабинете уже созданы, и по статусу такую кампанию
* от нетронутого черновика не отличить.
*
* Что ломалось без замка:
* - перезаливка картинки: строка баннера обновляется, а `yandex_creative_id`/`yandex_ad_id`
* остаются от старого креатива — в портале новая картинка, в Яндексе крутится старая, молча;
* - удаление и повторная заливка: строка с `yandex_ad_id` уничтожается, и возобновляемый
* запуск видит «номера объявления нет» и создаёт ВТОРОЕ объявление того же размера.
* Старое остаётся крутиться — это живые деньги клиента.
*/
function launchedBannerCampaign(): array
{
[$tenant, $user, $campaign] = bannerCampaign();
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 728, 'height' => 90,
'path' => "ad-banners/{$tenant->id}/{$campaign->id}/728x90.jpg",
'bytes' => 1000, 'included' => true,
]);
$campaign->update(['yandex_campaign_id' => 713051718]);
return [$user, $campaign, $banner];
}
it('перезалить баннер после заведения кампании в Яндексе нельзя', function () {
Storage::fake('local');
[$user, $campaign, $banner] = launchedBannerCampaign();
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => UploadedFile::fake()->image('new.jpg', 728, 90),
]);
$res->assertStatus(409);
expect($res->json('message'))->toContain('уже заведена в Яндексе');
expect($banner->fresh()->path)->toBe("ad-banners/{$campaign->tenant_id}/{$campaign->id}/728x90.jpg");
});
it('включить или выключить баннер после заведения кампании в Яндексе нельзя', function () {
[$user, $campaign, $banner] = launchedBannerCampaign();
$res = $this->actingAs($user)->patchJson(
"/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}",
['included' => false],
);
$res->assertStatus(409);
expect($banner->fresh()->included)->toBeTrue();
});
it('удалить баннер после заведения кампании в Яндексе нельзя', function () {
Storage::fake('local');
[$user, $campaign, $banner] = launchedBannerCampaign();
$this->actingAs($user)
->deleteJson("/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}")
->assertStatus(409);
expect(AdCampaignBanner::find($banner->id))->not->toBeNull();
});
/**
* Отдельная, более жёсткая защита: у баннера есть номер объявления в Яндексе — значит
* объявление создано и крутится. Удалять такую строку нельзя НИКОГДА, даже если номер
* кампании почему-то пуст: без строки портал забудет про живое объявление, а возобновление
* заведёт второе.
*/
it('баннер с номером объявления в Яндексе не удаляется даже без номера кампании', function () {
Storage::fake('local');
[$tenant, $user, $campaign] = bannerCampaign();
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 728, 'height' => 90,
'path' => "ad-banners/{$tenant->id}/{$campaign->id}/728x90.jpg",
'bytes' => 1000, 'included' => true, 'yandex_ad_id' => 42424242,
]);
$this->actingAs($user)
->deleteJson("/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}")
->assertStatus(409);
expect(AdCampaignBanner::find($banner->id))->not->toBeNull();
});