Files
portal/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php
T
Дмитрий 2590c01477 test: половина грязи рекламных тестов убрана — остаток в базе со 116 до 44
Восьми файлам рекламной папки поставлен откат после теста. Проверено остатком
в базе снаружи: папка оставляла 116 записей арендаторов, теперь 44. Папка
целиком зелёная - 351 из 351. Полный прогон после уборки: 4111 тестов,
4107 зелёных, 4 пропущено, ноль падений - значит на эту грязь никто не
опирался. Побочно прогон стал БЫСТРЕЕ: 12,9 минуты против 16,5.

Один файл намеренно оставлен как был, причина записана прямо в нём:
CampaignBannerEndpointsTest нарочно ловит отказ базы, а Postgres после отказа
внутри транзакции глушит все следующие команды. С откатом файл падает -
чинить надо сам тест, а не обёртку.

Датчик пришлось расшифровывать, и это стоит помнить. Числа сперва выходили
с минусами: файл, который базы не касается вовсе, «уносил» девять записей.
Прямым замером выяснилось, что КАЖДЫЙ прогон начинается с пересборки базы -
было 6 записей, стало 0 после теста про расписание. Значит разница «до/после»
при переборе файлов = оставил этот минус оставил предыдущий. Расшифровал
цепочкой, сумма сошлась со 116 ровно - на этом расшифровка и проверяется.
Поимённый расклад по файлам перенесён в промт.

Грабля на будущее, тоже в промте: uses() обязан стоять НИЖЕ подключений
классов. Подключение действует с места объявления и ниже, поэтому вызов
выше падает «класс не найден» - и падает сразу вся папка. Полное имя
с ведущей чертой не спасает: pint САМ превращает его в подключение внизу
и ломает работавший файл.

Остаток долга измерен и НЕ закрыт: ~20 файлов оставляют по одной-две записи,
среди них миграционные, которым откат может быть противопоказан по сути.
По всей папке Feature без отката 96 файлов из 580 - корень общий,
в app/tests/Pest.php строка RefreshDatabase закомментирована.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:46:14 +03:00

464 lines
22 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);
// 🪤 Откат транзакцией этому файлу НЕ подходит, хотя он и оставляет записи в базе.
// Тест «база не даёт завести второй баннер того же размера» нарочно ловит отказ
// базы, а Postgres после отказа внутри транзакции глушит ВСЕ следующие команды
// («текущая транзакция прервана»). Проверено 01.08.2026: с откатом файл падает.
// Чинить надо не обёрткой, а самим тестом — отдельной работой.
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 App\Services\Advertising\CampaignReviveService;
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('Неизвестный размер баннера.');
});
/**
* Предел веса картинки — 512 000 байт (BannerUploadPolicy::MAX_BYTES), это потолок Яндекса
* для графического креатива. Тяжелее — кабинет файл не примет, и узнаем мы об этом только
* тогда, когда робот уже стоит перед окном загрузки на боевом: он не умеет ни ужать картинку,
* ни спросить клиента. Задание уйдёт в сбой, кампания застрянет.
*
* 🪤 Прежние две проверки про этот предел были пустышками: одна сравнивала константу саму
* с собой, вторая — что то же число приезжает клиенту в JSON. Вырезание правила `max:`
* из валидации оставляло обе зелёными. Этот тест грузит настоящий перевес и проверен
* вырезанием: без правила `max:` он краснеет.
*/
it('картинка тяжелее предела отклоняется', function () {
Storage::fake('local');
[, $user, $campaign] = bannerCampaign();
$tooHeavy = UploadedFile::fake()->image('b.jpg', 728, 90)->size((int) (BannerUploadPolicy::MAX_BYTES / 1024) + 1);
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [
'width' => 728, 'height' => 90,
'file' => $tooHeavy,
]);
$res->assertStatus(422);
$res->assertJsonValidationErrors('file');
expect(AdCampaignBanner::where('campaign_id', $campaign->id)->count())->toBe(0);
});
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();
});
/**
* Узкое исключение из замка: у отклонённой кампании показов нет и денег на ней нет,
* поэтому картинку клиент обязан иметь возможность переделать. Соседний тест
* «перезалить баннер после заведения кампании в Яндексе нельзя» держит вторую половину
* правила: у НЕотклонённой кампании замок остаётся на месте.
*/
it('у отклонённой кампании картинку перезалить можно', function () {
Storage::fake('local');
[$user, $campaign, $banner] = launchedBannerCampaign();
$campaign->update(['status' => AdCampaign::STATUS_REJECTED]);
$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(201);
expect($banner->fresh()->bytes)->not->toBe(1000);
});
/**
* Вторая половина той же живой находки: после «Исправить» клиенту нужна новая картинка,
* а замок на баннерах смотрел на номер кампании и запирал перезаливку насовсем.
*/
it('после оживления картинку перезалить можно', function () {
Storage::fake('local');
[$user, $campaign, $banner] = launchedBannerCampaign();
$campaign->update(['status' => AdCampaign::STATUS_REJECTED]);
$banner->update(['moderation_status' => AdCampaignBanner::MOD_REJECTED, 'yandex_ad_id' => 200]);
config(['services.yandex_direct.enabled' => false]);
app(CampaignReviveService::class)->revive($campaign);
$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(201);
});