Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php
T

720 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignAd;
use App\Models\AdCampaignBanner;
use App\Models\AdCreativeJob;
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\CreativeJobService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
/**
* HTTP API кампаний Директа для клиентского портала (Часть A, Task 12).
* Слой над готовыми сервисами CampaignAudienceBuilder/CampaignLauncher/CreativeValidator.
*
* Мирроим auth/tenant setup из AdvertisingWalletEndpointTest.php — без RefreshDatabase,
* каждый тест создаёт свой Tenant::factory()->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');
});