5a4c0e0235
Часть 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>
235 lines
9.5 KiB
PHP
235 lines
9.5 KiB
PHP
<?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);
|
||
}
|
||
});
|