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(); });