0548fc0d8f
Хвосты портала П1-П8 из приёмочного листа v12. П1 обрыв постановки задания больше не даёт клиенту голый 500 — 503 с человеческим текстом и записью в журнал. Таймаут у HTTP-клиента Laravel уже был, эта половина находки не подтвердилась. П2 и П6 роботу отдаются только баннеры без номера креатива — тот же список сопоставляется при отчёте. Раньше стороны расходились и опознание падало на безупречной работе робота, плодя дубли в кабинете. Плюс постраничный обход описи креативов: слепок обрывался на первой странице. П3 слепок «до» снимается при выдаче задания, а не при постановке, и в той же транзакции, что и перевод в работу. Два задания в очереди получали одинаковый слепок, второе падало всегда. Постановка перестала зависеть от живости Яндекса. П4 перед созданием объявлений сверяется настоящий размер каждого креатива одним запросом. Не сошлось или креатива нет — запуск не идёт. П5 уникальный индекс uq_ad_campaign_banner_slot, запись v9.09 в CHANGELOG схемы, rls-reviewer GO. П7 замок на правку расширен на сегмент Аудиторий — он создаётся раньше кампании. Смежная находка: перезаливка картинки теперь обнуляет номер креатива. П8 и Р-х5 файл отдаётся под настоящим расширением и типом содержимого, имя от номера баннера; робот берёт расширение из ответа портала. Портал 287/287, робот 43/43. Денежных выходов снятия заморозки по-прежнему четыре. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
389 lines
18 KiB
PHP
389 lines
18 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\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('Неизвестный размер баннера.');
|
||
});
|
||
|
||
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();
|
||
});
|