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