b8f75b2a0c
Р3 хвост. Адрес файла баннера стал /api/creative-robot/jobs/{jobId}/banners/{bannerId}/file.
Раньше портал подставлял «какое-нибудь задание в работе» — защита выдачи чужих картинок
держалась на внешнем условии, а не на самом запросе. Робота править не пришлось: он берёт
адрес из ответа портала как есть. Запись v9.08 в журнал схемы про частичный уникальный
индекс; rls-reviewer по миграции — GO.
Р4. Замок на наборе баннеров после заведения кампании в Яндексе: перезаливка, включение
и удаление отдают 409 по тому же признаку yandex_campaign_id, что и замок на параметрах.
Без него в портале была новая картинка, а в Яндексе крутилась старая, а удаление строки
с номером объявления заставляло возобновление завести второе объявление того же размера —
старое продолжало крутиться за деньги клиента. Баннер с номером объявления не удаляется
никогда.
Р5. Захват кампании под запуск: новый промежуточный статус launching, перевод под замком
строки в транзакции. Два одновременных нажатия «запустить» проходили проверку статуса оба
и заводили две CPM-кампании при одной заморозке денег. На любой ошибке прежний статус
возвращается, номера созданных в Яндексе сущностей уцелевают — возобновляемый запуск
не тронут. Брошенный захват старше пятнадцати минут перехватывается.
Тесты: портал 266/266, робот 35/35. Все новые защиты проверены вырезанием.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
14 KiB
PHP
328 lines
14 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);
|
||
}
|
||
});
|
||
|
||
/*
|
||
* ЗАМОК НА БАННЕРАХ ПОСЛЕ ЗАВЕДЕНИЯ КАМПАНИИ В ЯНДЕКСЕ.
|
||
*
|
||
* Тот же замок, что уже стоит на правке параметров кампании, и по тому же признаку —
|
||
* `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();
|
||
});
|