create(). */ beforeEach(function () { $this->tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); }); function seedCampaignPhones(AdCampaign $campaign, int $count): void { $rows = []; for ($i = 0; $i < $count; $i++) { $rows[] = [ 'tenant_id' => $campaign->tenant_id, 'campaign_id' => $campaign->id, 'phone' => sprintf('798800%05d', $i), 'expires_at' => null, 'created_at' => now(), 'updated_at' => now(), ]; } DB::table('ad_campaign_phones')->insert($rows); } it('returns 401 without auth', function () { auth()->logout(); $this->getJson('/api/advertising/campaigns')->assertStatus(401); }); it('creates a draft campaign with valid body', function () { $response = $this->postJson('/api/advertising/campaigns', [ 'name' => 'Кампания А', 'audience_days' => 10, 'weekly_budget_rub' => '1500.00', ]); $response->assertCreated(); $campaignId = $response->json('id'); expect($campaignId)->not->toBeNull(); $this->assertDatabaseHas('ad_campaigns', [ 'id' => $campaignId, 'tenant_id' => $this->tenant->id, 'status' => AdCampaign::STATUS_DRAFT, ]); }); it('rejects an invalid body with 422', function () { $response = $this->postJson('/api/advertising/campaigns', [ 'name' => '', 'audience_days' => 0, 'weekly_budget_rub' => '1500.00', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['name', 'audience_days']); }); it('lists tenant campaigns and shows one with ads and spent_rub', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Кампания B', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $index = $this->getJson('/api/advertising/campaigns'); $index->assertOk(); expect(collect($index->json('data'))->pluck('id')->all())->toContain($campaign->id); $show = $this->getJson("/api/advertising/campaigns/{$campaign->id}"); $show->assertOk() ->assertJsonPath('campaign.id', $campaign->id) ->assertJsonPath('ads', []) ->assertJsonPath('spent_rub', '0.00'); }); it('sums charge transactions as spent_rub', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Кампания расход', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '1000.00', 'frozen_rub' => '0.00']); AdWalletTransaction::create([ 'tenant_id' => $this->tenant->id, 'type' => AdWalletTransaction::TYPE_CHARGE, 'amount_rub' => '-260.00', 'balance_rub_after' => '740.00', 'channel' => 'yandex', 'related_type' => 'campaign', 'related_id' => $campaign->id, 'external_key' => 'yandex:'.$campaign->id.':2026-07-20', 'created_at' => now(), ]); AdWalletTransaction::create([ 'tenant_id' => $this->tenant->id, 'type' => AdWalletTransaction::TYPE_CHARGE, 'amount_rub' => '-40.00', 'balance_rub_after' => '700.00', 'channel' => 'yandex', 'related_type' => 'campaign', 'related_id' => $campaign->id, 'external_key' => 'yandex:'.$campaign->id.':2026-07-21', 'created_at' => now(), ]); $show = $this->getJson("/api/advertising/campaigns/{$campaign->id}"); $show->assertOk()->assertJsonPath('spent_rub', '300.00'); }); it('updates a campaign partially', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'До правки', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ 'name' => 'После правки', 'budget_rub' => '2000.00', ]); $response->assertOk() ->assertJsonPath('name', 'После правки') ->assertJsonPath('budget_rub', '2000.00'); $this->assertDatabaseHas('ad_campaigns', [ 'id' => $campaign->id, 'name' => 'После правки', 'budget_rub' => '2000.00', ]); }); it('refuses to change the impression budget once the campaign is created in Yandex', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Уже в Яндексе', 'audience_days' => 10, 'estimated_impressions' => 100000, 'client_cpm_rub' => '120.00', 'landing_url' => 'https://liderra.ru/promo', 'yandex_campaign_id' => 222, ]); $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ 'estimated_impressions' => 5000, 'client_cpm_rub' => '10.00', ]); $response->assertStatus(409); expect($response->json('message'))->not->toBeNull(); // Смета в базе НЕ изменилась — иначе портал показывал бы одни числа, а Яндекс крутил другие. $campaign->refresh(); expect($campaign->estimated_impressions)->toBe(100000) ->and($campaign->client_cpm_rub)->toBe('120.00'); }); it('refuses to change landing_url once the campaign is created in Yandex', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Уже в Яндексе, адрес', 'audience_days' => 10, 'landing_url' => 'https://liderra.ru/promo', 'yandex_campaign_id' => 222, ]); $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ 'landing_url' => 'https://liderra.ru/promo-new', ]); $response->assertStatus(409); $campaign->refresh(); expect($campaign->landing_url)->toBe('https://liderra.ru/promo'); }); /** * Замок стоял только по номеру кампании Директа, а сегмент Яндекс.Аудиторий создаётся * РАНЬШЕ неё. Обрыв запуска в этом окне (сегмент уже есть, кампании ещё нет) оставлял * настройки аудитории открытыми: клиент менял срок сбора или список номеров, портал * показывал новое, а возобновлённый запуск переиспользовал СТАРЫЙ сегмент — реклама * шла по прежним телефонам. */ it('refuses to change audience settings once the Yandex audience segment exists', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Сегмент уже создан', 'audience_days' => 10, 'estimated_impressions' => 100000, 'yandex_segment_id' => 900001, // Кампании в Директе ещё нет — запуск оборвался ровно между сегментом и кампанией. 'yandex_campaign_id' => null, ]); $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ 'audience_days' => 30, ]); $response->assertStatus(409); $campaign->refresh(); expect($campaign->audience_days)->toBe(10); }); it('still allows renaming a campaign whose audience segment exists', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Старое имя при сегменте', 'audience_days' => 10, 'yandex_segment_id' => 900001, ]); $this->patchJson("/api/advertising/campaigns/{$campaign->id}", ['name' => 'Новое имя']) ->assertOk()->assertJsonPath('name', 'Новое имя'); }); it('still allows renaming a campaign that is already created in Yandex', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Старое имя', 'audience_days' => 10, 'estimated_impressions' => 100000, 'yandex_campaign_id' => 222, ]); $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ 'name' => 'Новое имя', ]); $response->assertOk()->assertJsonPath('name', 'Новое имя'); $campaign->refresh(); expect($campaign->name)->toBe('Новое имя') ->and($campaign->estimated_impressions)->toBe(100000); }); it('still allows editing the impression budget of a plain draft with nothing in Yandex', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Обычный черновик', 'audience_days' => 10, 'estimated_impressions' => 1000, ]); $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ 'estimated_impressions' => 5000, ]); $response->assertOk(); $campaign->refresh(); expect($campaign->estimated_impressions)->toBe(5000); }); it('creates a campaign with a valid landing_url and returns it', function () { $response = $this->postJson('/api/advertising/campaigns', [ 'name' => 'Кампания с адресом', 'audience_days' => 10, 'landing_url' => 'https://liderra.ru/promo', ]); $response->assertCreated() ->assertJsonPath('landing_url', 'https://liderra.ru/promo'); $this->assertDatabaseHas('ad_campaigns', [ 'id' => $response->json('id'), 'landing_url' => 'https://liderra.ru/promo', ]); }); it('rejects an invalid landing_url with 422 on store', function () { $response = $this->postJson('/api/advertising/campaigns', [ 'name' => 'Кампания с плохим адресом', 'audience_days' => 10, 'landing_url' => 'не-адрес', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['landing_url']); }); it('updates landing_url on an existing campaign', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Кампания под адрес', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ 'landing_url' => 'https://liderra.ru/promo2', ]); $response->assertOk()->assertJsonPath('landing_url', 'https://liderra.ru/promo2'); $this->assertDatabaseHas('ad_campaigns', [ 'id' => $campaign->id, 'landing_url' => 'https://liderra.ru/promo2', ]); }); it('rejects an invalid landing_url with 422 on update', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Кампания под плохой адрес', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ 'landing_url' => 'не-адрес', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['landing_url']); }); it('reports a small audience as not enough with a hint', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Малая аудитория', 'audience_days' => 10, 'use_uploaded_list' => false, 'weekly_budget_rub' => '1000.00', ]); $response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10"); $response->assertOk() ->assertJsonPath('min', 100) ->assertJsonPath('enough', false); expect($response->json('size'))->toBeLessThan(100); expect($response->json('hint'))->not->toBeNull(); }); it('reports a large audience as enough with no hint', function () { // T3: список считается только в mode=manual (auto его больше не подмешивает) — // передаём mode=manual в запросе, как это делает мастер. $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Большая аудитория', 'audience_days' => 10, 'use_uploaded_list' => true, 'weekly_budget_rub' => '1000.00', ]); seedCampaignPhones($campaign, 150); $response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10&mode=manual"); $response->assertOk() ->assertJsonPath('enough', true) ->assertJsonPath('hint', null); expect($response->json('size'))->toBeGreaterThanOrEqual(100); }); it('rejects a bad ad creative with 422 errors.title', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Кампания под объявление', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads", [ 'title' => str_repeat('а', 60), 'text' => 'Обычный текст объявления', 'href' => 'https://liderra.ru', ]); $response->assertStatus(422); expect($response->json('errors.title'))->not->toBeNull(); }); it('creates a valid ad creative', function () { $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Кампания под объявление 2', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads", [ 'title' => 'Хороший заголовок', 'text' => 'Хороший текст объявления в пределах нормы', 'href' => 'https://liderra.ru', ]); $response->assertCreated(); $this->assertDatabaseHas('ad_campaign_ads', [ 'campaign_id' => $campaign->id, 'tenant_id' => $this->tenant->id, 'title' => 'Хороший заголовок', ]); }); it('returns 422 with a message when launching with a too-small audience', function () { config(['services.yandex_direct.enabled' => true]); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Запуск малая аудитория', 'audience_days' => 10, 'use_uploaded_list' => false, 'weekly_budget_rub' => '1000.00', // Медийные поля — заполнены, чтобы дойти именно до проверки аудитории (не оборваться раньше). 'estimated_impressions' => 10000, 'landing_url' => 'https://liderra.ru', ]); // Набор баннеров с номерами креативов — иначе запуск оборвётся на проверке набора, // а не на аудитории (адаптивного креатива у медийной кампании больше нет). AdCampaignBanner::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'width' => 300, 'height' => 250, 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", 'bytes' => 1000, 'included' => true, 'yandex_creative_id' => 4242, ]); $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch"); $response->assertStatus(422); expect($response->json('message'))->not->toBeNull(); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); }); it('uploads a valid ad image and stores the returned hash', function () { config(['services.yandex_direct.enabled' => true]); config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); config(['services.yandex_direct.token' => 'DIRTOKEN']); Http::fake(['*/json/v501/adimages' => Http::response(['result' => ['AddResults' => [['AdImageHash' => 'HASH123']]]])]); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Кампания под картинку', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $ad = AdCampaignAd::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'title' => 'Заголовок', 'text' => 'Текст объявления', 'href' => 'https://liderra.ru', 'moderation_status' => 'draft', ]); $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [ 'file' => UploadedFile::fake()->image('creative.jpg', 500, 500), ]); $response->assertOk()->assertJsonPath('hash', 'HASH123'); $this->assertDatabaseHas('ad_campaign_ads', [ 'id' => $ad->id, 'image_normal_hash' => 'HASH123', ]); }); it('rejects an ad image upload when yandex_direct is disabled', function () { config(['services.yandex_direct.enabled' => false]); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Кампания под картинку 2', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $ad = AdCampaignAd::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'title' => 'Заголовок', 'text' => 'Текст объявления', 'href' => 'https://liderra.ru', 'moderation_status' => 'draft', ]); $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [ 'file' => UploadedFile::fake()->image('creative.jpg', 500, 500), ]); $response->assertStatus(409); }); it('isolates tenants: user of tenant A cannot see tenant B campaign', function () { $tenantB = Tenant::factory()->create(); $campaignB = AdCampaign::create([ 'tenant_id' => $tenantB->id, 'name' => 'Чужая кампания', 'audience_days' => 10, 'weekly_budget_rub' => '1000.00', ]); $response = $this->getJson("/api/advertising/campaigns/{$campaignB->id}"); $response->assertStatus(404); }); it('queues a creative job instead of failing when banners have no creative numbers', function () { config(['services.yandex_direct.enabled' => true]); config(['services.yandex_direct.token' => 'T']); config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Запуск без креативов', 'audience_days' => 10, 'use_uploaded_list' => false, 'estimated_impressions' => 10000, 'landing_url' => 'https://liderra.ru', ]); AdCampaignBanner::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'width' => 300, 'height' => 250, 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", 'bytes' => 1000, 'included' => true, // yandex_creative_id намеренно НЕ задан — робот его ещё не привёз. ]); $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch") ->assertStatus(202) ->assertJsonPath('status', 'creatives_pending'); expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1) ->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); }); it('does not queue a second creative job on repeated launch', function () { config(['services.yandex_direct.enabled' => true]); config(['services.yandex_direct.token' => 'T']); config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Повторный запуск без креативов', 'audience_days' => 10, 'use_uploaded_list' => false, 'estimated_impressions' => 10000, 'landing_url' => 'https://liderra.ru', ]); AdCampaignBanner::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'width' => 300, 'height' => 250, 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", 'bytes' => 1000, 'included' => true, ]); $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(202); $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(202); expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1); }); /** * Постановка задания роботу может не удаться (упала база, лёг Яндекс — исторически она * ходила туда за слепком). Раньше клиент получал голый 500 «что-то пошло не так»: * непонятно, виноват ли он, надо ли заливать картинки заново, стоит ли пробовать ещё раз. * Ответ должен быть человеческим и означать «это не вы, попробуйте позже». */ it('answers politely instead of a bare error when the creative job cannot be queued', function () { config(['services.yandex_direct.enabled' => true]); Http::fake(); // Постановку задания роняем изнутри: причина не важна, важно, что клиент не увидит // голого 500 ни при какой поломке. $this->app->bind(CreativeJobService::class, function () { throw new RuntimeException('очередь недоступна'); }); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Очередь легла', 'audience_days' => 10, 'use_uploaded_list' => false, 'estimated_impressions' => 10000, 'landing_url' => 'https://liderra.ru', ]); AdCampaignBanner::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'width' => 300, 'height' => 250, 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", 'bytes' => 1000, 'included' => true, ]); $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch") ->assertStatus(503) ->assertJsonPath('status', 'yandex_unavailable'); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); }); /** * 🪤 Мина той же породы, что уже ловилась в проекте: `config(...) === false`. * * Значение приходит из `env('YANDEX_DIRECT_ENABLED', false)`. Стоит написать в `.env` * `YANDEX_DIRECT_ENABLED=0` — и Laravel вернёт строку «0», которая рубильником читается * как «выключено», а сравнением `=== false` — как «включено». Запрос ушёл бы в живой * Яндекс при выключенном рубильнике. Все остальные места проверяют через `! config(...)`. */ it('treats a string switch value as off, not on', function () { config(['services.yandex_direct.enabled' => '0']); Http::fake(); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Рубильник строкой', 'audience_days' => 10, 'use_uploaded_list' => false, ]); $ad = AdCampaignAd::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'title' => 'Заголовок', 'text' => 'Текст объявления', 'href' => 'https://liderra.ru', ]); $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [ 'file' => UploadedFile::fake()->image('b.jpg', 1080, 607), ])->assertStatus(409); Http::assertNothingSent(); }); it('does not touch yandex at all when the direct switch is off', function () { config(['services.yandex_direct.enabled' => false]); Http::fake(); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Рубильник выключен', 'audience_days' => 10, 'use_uploaded_list' => false, 'estimated_impressions' => 10000, 'landing_url' => 'https://liderra.ru', ]); AdCampaignBanner::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'width' => 300, 'height' => 250, 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", 'bytes' => 1000, 'included' => true, ]); $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(409); Http::assertNothingSent(); expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0); }); /** * Красный ярлык «Отклонено» без единого слова объяснения — то, что клиент видел * до 28.07.2026. Причина лежала в базе и никуда не отдавалась. * * Наценке в клиентском ответе места нет ни при каких обстоятельствах. */ it('в списке кампаний у отклонённой видна причина, а наценки нет', function () { $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, 'status' => AdCampaign::STATUS_REJECTED, 'moderation_reason' => 'Изображение не подошло', ]); $res = $this->actingAs($user)->getJson('/api/advertising/campaigns'); $res->assertOk(); // Ответ index() — обёртка {"data": [...]}, проверено по коду 28.07.2026. $row = collect($res->json('data'))->firstWhere('id', $campaign->id); expect($row['moderation_reason'])->toBe('Изображение не подошло'); expect($res->getContent())->not->toContain('yandex_cost_rub') ->and($res->getContent())->not->toContain('ad_margin_percent'); }); /** Кампания заданного статуса, уже заведённая в Яндексе, со своим пользователем. */ function campaignInYandex(string $status): 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, 'status' => $status, 'yandex_campaign_id' => 555, 'yandex_ad_group_id' => 666, 'yandex_segment_id' => 777, ]); AdCampaignBanner::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true, 'path' => 'ad-banners/a/300x250.jpg', 'yandex_creative_id' => 100, 'yandex_ad_id' => 200, 'moderation_status' => AdCampaignBanner::MOD_REJECTED, ]); return [$tenant, $user, $campaign]; } it('клиент оживляет свою отклонённую кампанию', function () { config(['services.yandex_direct.enabled' => false]); [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive"); $res->assertOk(); expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_DRAFT); }); it('оживить работающую кампанию нельзя', function () { config(['services.yandex_direct.enabled' => false]); [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_RUNNING); $this->actingAs($user) ->postJson("/api/advertising/campaigns/{$campaign->id}/revive") ->assertStatus(409); expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING); }); /** * Двойной щелчок по «Исправить». Второй заход видит кампанию уже черновиком — и обязан * получить отказ, а не пройти второй раз по чистке номеров: он стёр бы номера объявлений, * которые к тому моменту мог создать новый запуск. */ it('второе нажатие Исправить получает отказ', function () { config(['services.yandex_direct.enabled' => false]); [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertOk(); $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertStatus(409); }); it('чужую кампанию оживить нельзя', function () { config(['services.yandex_direct.enabled' => false]); [, , $campaignA] = campaignInYandex(AdCampaign::STATUS_REJECTED); [, $userB] = campaignInYandex(AdCampaign::STATUS_REJECTED); $this->actingAs($userB) ->postJson("/api/advertising/campaigns/{$campaignA->id}/revive") ->assertStatus(404); expect($campaignA->refresh()->status)->toBe(AdCampaign::STATUS_REJECTED); }); it('у отклонённой кампании правка настроек разрешена', function () { [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); $this->actingAs($user) ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) ->assertOk(); }); /** 🔑 Исключение не должно протечь на работающую рекламу — она крутится за деньги клиента. */ it('у работающей кампании правка настроек по-прежнему заперта', function () { [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_RUNNING); $this->actingAs($user) ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) ->assertStatus(409); }); /** * 🔴 Поймано живой проверкой в браузере 28.07.2026, все тесты при этом были зелёные. * * «Исправить» возвращает кампанию в черновик — и в ту же секунду исключение из замка * перестаёт действовать: оно написано по статусу «отклонено», а статуса уже нет. * Клиент оказывался в том же тупике, только шагом дальше: мастер открылся, а сервер * не даёт ни сменить адрес сайта, ни перезалить картинку. * * Признак «отдана на починку» обязан пережить возврат в черновик. */ it('после нажатия Исправить правка настроек разрешена', function () { config(['services.yandex_direct.enabled' => false]); [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertOk(); $this->actingAs($user) ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) ->assertOk(); }); /** * Оборвавшийся запуск оставляет черновик, у которого в Яндексе уже есть кампания, группа * и сегмент — его правку замок обязан держать по-прежнему. Иначе клиент поменяет срок * сбора, а возобновление переиспользует СТАРЫЙ сегмент, и реклама пойдёт по прежним номерам. */ it('черновик с оборвавшимся запуском по-прежнему заперт', function () { [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_DRAFT); $this->actingAs($user) ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) ->assertStatus(409); }); /** * 30.07.2026, живой бой. Сегмент существовал, но Яндекс ещё сводил номера с людьми, * и портал в этот момент лез в Директ за условием ретаргетинга. Директ отвечал * «объект не найден» — тем же отказом, что и на несуществующий сегмент. Клиент читал * непонятное и думал, что сломался он. * * Теперь это «подождите», а не ошибка: тот же 202, что и на ожидание картинок. */ it('отвечает «подождите», пока рекламная система готовит аудиторию', function () { config(['services.yandex_direct.enabled' => true]); config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); config(['services.yandex_direct.token' => 'DIRTOKEN']); config(['services.yandex_audience.token' => 'AUDTOKEN']); Http::fake([ '*/v1/management/segments' => Http::response(['segments' => [ ['id' => 900001, 'name' => 'C', 'status' => 'is_processed', 'can_create_dependent' => false], ]]), ]); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Аудитория ещё готовится', 'audience_days' => 10, 'mode' => AdCampaign::MODE_MANUAL, 'use_uploaded_list' => true, 'estimated_impressions' => 10000, // Срок показа задан явно: минимум площадки растёт с длиной периода, и без этого // проверка сметы сработала бы раньше — до вопроса о готовности аудитории. 'run_days' => 1, 'client_cpm_rub' => '120.00', 'landing_url' => 'https://liderra.ru', 'yandex_segment_id' => 900001, ]); seedCampaignPhones($campaign, 150); AdCampaignBanner::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'width' => 300, 'height' => 250, 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", 'bytes' => 1000, 'included' => true, 'yandex_creative_id' => 4242, ]); $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch"); $response->assertStatus(202)->assertJsonPath('status', 'audience_pending'); // Человеческий текст: без «ретаргетинга» и «сегмента». $text = (string) $response->json('message'); expect($text)->not->toContain('ретаргетинг') ->and($text)->not->toContain('сегмент'); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT) ->and($campaign->yandex_retargeting_list_id)->toBeNull(); Http::assertNotSent(fn ($request) => str_contains($request->url(), 'retargetinglists')); }); /** * Живой отказ Директа 30.07.2026: «Budget for this period cannot be less than 600 rub.». * Клиент видел его как есть — по-английски, про чужие 600 ₽, без подсказки, что делать. * * Теперь портал считает сам и говорит клиенту его же числами. Доли рекламной площадки * в ответе быть не должно: по паре «минимум площадки / цена клиенту» наценка вычисляется делением. */ it('объясняет клиенту, какая смета нужна, вместо английской ошибки про 600 рублей', function () { config(['services.yandex_direct.enabled' => true]); config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); config(['services.yandex_direct.token' => 'DIRTOKEN']); config(['services.yandex_audience.token' => 'AUDTOKEN']); Http::fake(); $campaign = AdCampaign::create([ 'tenant_id' => $this->tenant->id, 'name' => 'Смета мала', 'audience_days' => 10, 'mode' => AdCampaign::MODE_MANUAL, 'use_uploaded_list' => true, // Ровно та кампания, что стояла на бою. 'estimated_impressions' => 1693, // Один день показа = период из двух календарных дней ⇒ минимум площадки 600 ₽. // Порог растёт с длиной периода, поэтому срок здесь задан явно. 'run_days' => 1, 'client_cpm_rub' => '120.00', 'landing_url' => 'https://liderra.ru', ]); seedCampaignPhones($campaign, 150); AdCampaignBanner::create([ 'tenant_id' => $this->tenant->id, 'campaign_id' => $campaign->id, 'width' => 300, 'height' => 250, 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", 'bytes' => 1000, 'included' => true, 'yandex_creative_id' => 4242, ]); $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch"); $response->assertStatus(422)->assertJsonPath('status', 'budget_too_small'); $text = (string) $response->json('message'); expect($text)->toContain('6945') // сколько показов нужно ->and($text)->toContain('833.40') // во сколько это обойдётся клиенту ->and($text)->not->toContain('600'); // доля площадки наружу не уходит expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); Http::assertNothingSent(); });