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('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, 'yandex_creative_id' => 4242, 'landing_url' => 'https://liderra.ru', ]); $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); });