Files
portal/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php
T
Дмитрий 5a4c0e0235 feat(реклама): показы — баннеры клиента по размерам, два режима аудитории, клиентская цена и наценка
Часть 5c — баннеры: клиент грузит свой готовый файл на каждый из 15 размеров вместо автогенерации из одной картинки. Частичное утверждение флагом included, замена и удаление отдельного баннера, валидация точного размера и веса. Админ-поле цены за 1000 показов. Пример CSV для скачивания и подъём лимита загрузки.

Часть 5d — два режима сбора аудитории. Авто: скользящее окно, обновляется ежедневно, только контакты системы. Ручной: снимок сделок за период плюс свой список номеров и срок показа. Клиент сам задаёт цену за 1000 показов с дефолтом из админки. Наценка настраивается в админке, по умолчанию 40 процентов, в Директ уходит меньше, клиенту не видна нигде.

Миграции: ad_campaign_banners += included; ad_campaigns += mode/snapshot_from/snapshot_to/run_days/client_cpm_rub; ad_settings += ad_margin_percent. RLS-ревью PASS на всех миграциях. Backend 166 тестов, фронт 123 теста, сборка чистая. Маржа и yandex_cost_rub клиенту не сериализуются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 21:13:20 +03:00

235 lines
9.5 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\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];
}
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');
$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);
}
});