Files
portal/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php
T
Дмитрий a5c278adaf test: рекламная папка и журнал вебхука перестали оставлять записи — остаток 44 → 0
Прогон рекламной папки оставлял в базе 44 записи. Теперь ноль, папка целиком
зелёная — 351 из 351. Полная tests/Feature: 3282 теста, 0 падений, и после неё
в базе не остаётся НИЧЕГО, кроме справочников, которые кладутся при сборке.

Два места прошлая смена считала неизлечимыми — оба вылечились, потому что
диагноз был не перепроверен:

  1. CampaignBannerEndpointsTest оставлял 24 записи, больше половины всей грязи.
     Он нарочно ловит отказ базы, а после отказа внутри транзакции Postgres
     глушит все следующие команды. Лечение — ловить отказ в ОТДЕЛЬНОЙ точке
     сохранения: DB::transaction внутри уже открытой ставит savepoint и
     откатывает только его. Приёмка вырезанием: убрал уникальный ключ в
     миграции — тест покраснел ровно там, где должен; вернул — зелёный.

  2. AdWalletUnderRealRoleTest. В промте: «откат противопоказан, он ходит
     настоящей ролью базы». Оказалось — не противопоказан, смена роли идёт по
     ТОМУ ЖЕ соединению и незакоммиченные записи видны. Тут была реальная
     опасность, что откат обезвредит самого сторожа, поэтому вырезание делал
     отдельно: убрал у службы установку контекста клиента — оба теста
     покраснели. Значит сторож сторожит по-прежнему.

Журнал вебхука оставлял 4 строки: он пишется ВТОРЫМ соединением к базе, куда
обычный откат не дотягивается. Двум файлам добавлена общая связка соединений
(SharesSupplierPdo) — папка вебхука теперь оставляет ноль.

Прежняя оценка исправлена. В промте стояло «~20 файлов оставляют по одной-две
записи». На деле после уборки восьми главных грязь оставляли ДВА файла из всей
папки: 2 и 3 записи, сумма сошлась с наблюдаемыми 5 ровно. «Нет отката» и
«гадит в базу» — разные вещи: папка Autopodbor, где без отката 16 файлов, не
оставляет ни одной записи. Мерить надо остатком, а не поиском по тексту.

Боевой код не тронут: git diff по app/app/ пуст после каждой правки.

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

471 lines
23 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 App\Services\Advertising\CampaignReviveService;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
// Откат после теста. Без него записи файла остаются в базе и роняют ЧУЖИЕ тесты:
// 01.08.2026 девятнадцать чужих проводок уронили денежную проверку приёмника МТС.
// Тест «база не даёт завести второй баннер» ловит отказ базы в отдельной точке
// сохранения — иначе откат этому файлу не подошёл бы, см. пояснение внутри него.
uses(DatabaseTransactions::class);
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']);
// Отказ базы ловим в ОТДЕЛЬНОЙ точке сохранения. Postgres после отказа внутри
// транзакции глушит все следующие команды («текущая транзакция прервана»), и
// проверка строкой ниже просто не выполнилась бы. DB::transaction внутри уже
// открытой транзакции ставит savepoint и откатывает только его — остальное живо.
expect(fn () => DB::transaction(
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);
});