Files
portal/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php
T

436 lines
20 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('Неизвестный размер баннера.');
});
/**
* Предел веса картинки — 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);
});