Http::response(['segment' => ['id' => 900001]]), '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), ]); } function configureYandex(): void { config(['services.yandex_direct.enabled' => true]); config(['services.yandex_direct.token' => 'DIRTOKEN']); config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); config(['services.yandex_audience.token' => 'AUDTOKEN']); } /** Наполняет ad_campaign_phones $count уникальными номерами для кампании (обходит фабрику Deal — быстрее). */ function seedAudience(AdCampaign $campaign, int $count): void { $rows = []; for ($i = 0; $i < $count; $i++) { $rows[] = [ 'tenant_id' => $campaign->tenant_id, 'campaign_id' => $campaign->id, 'phone' => sprintf('799900%05d', $i), 'expires_at' => null, 'created_at' => now(), 'updated_at' => now(), ]; } DB::table('ad_campaign_phones')->insert($rows); } /** Кладёт кампании набор баннеров с номерами креативов, как будто робот уже отработал. */ function seedBanners(AdCampaign $campaign, array $sizesToCreativeId): void { foreach ($sizesToCreativeId as $size => $creativeId) { [$w, $h] = array_map('intval', explode('x', (string) $size)); AdCampaignBanner::create([ 'tenant_id' => $campaign->tenant_id, 'campaign_id' => $campaign->id, 'width' => $w, 'height' => $h, 'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$size}.jpg", 'bytes' => 1000, 'included' => true, 'yandex_creative_id' => $creativeId, ]); } } /** Кампания «за показы» в режиме manual с адресом сайта; креативы — на баннерах набора. */ function makeImpressionCampaign(int $tenantId, array $overrides = []): AdCampaign { return AdCampaign::create(array_merge([ 'tenant_id' => $tenantId, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL, 'audience_days' => 10, 'use_uploaded_list' => true, 'estimated_impressions' => 100000, 'frequency' => 3, 'frequency_period_days' => 7, 'client_cpm_rub' => '120.00', // Конструктора креативов у Яндекса больше нет: поле кампании — аварийный ручной // путь, запуск его не читает. Номера креативов живут на баннерах набора. 'yandex_creative_id' => null, 'landing_url' => 'https://liderra.ru/promo', 'run_days' => 14, ], $overrides)); } it('launches an impression campaign: segment → Direct CPM → freeze budget → pending moderation', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) ->and($campaign->yandex_segment_id)->toBe(900001) ->and($campaign->yandex_retargeting_list_id)->toBe(111) ->and($campaign->yandex_campaign_id)->toBe(222) ->and($campaign->yandex_ad_group_id)->toBe(333) ->and($campaign->paid_impressions)->toBe(100000) // смета = потолок биллинга (Задача 10 fix) ->and($campaign->launched_at)->not->toBeNull(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('12000.00'); // 100000 показов × 120.00₽/1000 $hold = AdWalletHold::where('tenant_id', $tenant->id) ->where('channel', 'yandex')->where('source_type', 'campaign') ->where('source_id', $campaign->id)->where('status', AdWalletHold::STATUS_ACTIVE)->first(); expect($hold)->not->toBeNull() ->and($hold->amount_rub)->toBe('12000.00'); Http::assertSent(function ($request) { if (! str_contains($request->url(), '/json/v5/campaigns')) { return false; } $cpmStrategy = $request['params']['Campaigns'][0]['CpmBannerCampaign']['BiddingStrategy'] ?? null; $freqCap = $request['params']['Campaigns'][0]['CpmBannerCampaign']['FrequencyCap'] ?? null; // margin по умолчанию 40% (ad_settings.ad_margin_percent), client_cpm 120.00 → // yandex cpm = 72.00₽/1000 → AverageCpm = 72 000 000 микросов. return ($cpmStrategy['Network']['CpMaximumImpressions']['AverageCpm'] ?? null) === 72000000 && ($cpmStrategy['Network']['CpMaximumImpressions']['SpendLimit'] ?? null) === 8640000000 && ($cpmStrategy['Search']['BiddingStrategyType'] ?? null) === 'SERVING_OFF' && ($cpmStrategy['Network']['BiddingStrategyType'] ?? null) === 'CP_MAXIMUM_IMPRESSIONS' && ($freqCap['Impressions'] ?? null) === 3 && ($freqCap['PeriodDays'] ?? null) === 7; }); Http::assertSent(function ($request) { if (! str_contains($request->url(), '/json/v5/ads')) { return false; } $ad = $request['params']['Ads'][0]['CpmBannerAdBuilderAd'] ?? null; return ($ad['Creative']['CreativeId'] ?? null) === 4242 && ($ad['Href'] ?? null) === 'https://liderra.ru/promo'; }); }); it('throws AudienceTooSmallException and does not freeze when audience is under 100', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 50); seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(AudienceTooSmallException::class); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); $holdExists = AdWalletHold::where('tenant_id', $tenant->id) ->where('source_type', 'campaign')->where('source_id', $campaign->id) ->where('status', AdWalletHold::STATUS_ACTIVE)->exists(); expect($holdExists)->toBeFalse(); }); it('throws RuntimeException when yandex_direct is disabled and creates nothing', function () { config(['services.yandex_direct.enabled' => false]); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); $campaign = makeImpressionCampaign($tenant->id, ['use_uploaded_list' => false]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT) ->and($campaign->yandex_segment_id)->toBeNull(); Http::assertNothingSent(); }); it('creates one ad per included banner and stores the ad id on each banner', function () { configureYandex(); Http::fake([ '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), // Каждый вызов ads.add отдаёт свой номер объявления. '*/json/v5/ads' => Http::sequence() ->push(['result' => ['AddResults' => [['Id' => 5551]]]]) ->push(['result' => ['AddResults' => [['Id' => 5552]]]]), ]); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); app(CampaignLauncher::class)->launch($campaign); $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); expect($banners)->toHaveCount(2) ->and($banners[0]->yandex_ad_id)->toBe(5551) ->and($banners[0]->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION) ->and($banners[1]->yandex_ad_id)->toBe(5552); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); // 2 запроса на сегмент Аудиторий (заливка csv + подтверждение) + retargetinglists + // campaigns + adgroups + audiencetargets + по одному ads.add на каждый баннер (2) = 8. Http::assertSentCount(8); }); it('skips banners that are switched off by the client', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->update(['included' => false]); app(CampaignLauncher::class)->launch($campaign); Http::assertSent(function ($request) { return str_contains($request->url(), '/json/v5/ads') && ($request['params']['Ads'][0]['CpmBannerAdBuilderAd']['Creative']['CreativeId'] ?? null) === 4242; }); $off = AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->first(); expect($off->yandex_ad_id)->toBeNull(); }); it('refuses to launch when a included banner has no creative number yet', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); AdCampaignBanner::where('campaign_id', $campaign->id)->update(['yandex_creative_id' => null]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'креатив'); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); Http::assertNothingSent(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); }); it('refuses to launch when the campaign has no banners at all', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); seedAudience($campaign, 100); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'баннер'); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); Http::assertNothingSent(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); }); it('throws RuntimeException with a clear message when landing_url is missing', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['landing_url' => null]); seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'адрес сайта'); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); Http::assertNothingSent(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); }); it('throws RuntimeException with a clear message when estimated_impressions is not set', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => null]); seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'смета показов'); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); Http::assertNothingSent(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); }); it('resumes an interrupted launch: no second Yandex campaign, no duplicated ads', function () { configureYandex(); // Один фейк на весь тест с состоянием: считаем обращения по адресам и умеем «оборвать // связь» на втором объявлении. Повторный Http::fake() сбросил бы счётчики Laravel, но // старые заглушки остаются в силе (они мержатся, а не заменяются) — поэтому счёт свой. $calls = []; $breakAds = true; $adsSeen = 0; Http::fake(function ($request) use (&$calls, &$breakAds, &$adsSeen) { $url = $request->url(); $key = match (true) { str_contains($url, 'upload_csv_file') => 'segment_upload', str_contains($url, '/confirm') => 'segment_confirm', str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', str_contains($url, '/json/v5/campaigns') => 'campaigns', str_contains($url, '/json/v5/adgroups') => 'adgroups', str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets', str_contains($url, '/json/v5/ads') => 'ads', default => 'other', }; $calls[$key] = ($calls[$key] ?? 0) + 1; return match ($key) { 'segment_upload', 'segment_confirm' => Http::response(['segment' => ['id' => 900001]]), 'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), 'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), 'adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), 'audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), 'ads' => (++$adsSeen === 2 && $breakAds) ? Http::response([], 500) // обрыв связи на втором объявлении из трёх : Http::response(['result' => ['AddResults' => [['Id' => 5550 + $adsSeen]]]]), default => Http::response([], 404), }; }); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243, '970x250' => 4244]); // 1) Первый запуск обрывается на втором объявлении. expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class); // Всё, что уже создано в кабинете Яндекса, должно быть записано на кампанию СРАЗУ — // иначе портал «забудет» о ней и повторный запуск заведёт вторую кампанию-сироту. $campaign->refresh(); expect($campaign->yandex_segment_id)->toBe(900001) ->and($campaign->yandex_retargeting_list_id)->toBe(111) ->and($campaign->yandex_campaign_id)->toBe(222) ->and($campaign->yandex_ad_group_id)->toBe(333) ->and($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); // запуск не завершён $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); expect($banners[0]->yandex_ad_id)->toBe(5551) ->and($banners[1]->yandex_ad_id)->toBeNull() ->and($banners[2]->yandex_ad_id)->toBeNull(); // 2) Повторный запуск — связь восстановилась. $calls = []; $breakAds = false; app(CampaignLauncher::class)->launch($campaign); // Ничего из уже созданного не создаётся заново. expect($calls)->not->toHaveKey('segment_upload') ->and($calls)->not->toHaveKey('retargetinglists') ->and($calls)->not->toHaveKey('campaigns') ->and($calls)->not->toHaveKey('adgroups') ->and($calls)->not->toHaveKey('audiencetargets') ->and($calls['ads'] ?? 0)->toBe(2); // только два баннера без номера объявления $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) ->and($campaign->yandex_campaign_id)->toBe(222) ->and($campaign->yandex_ad_group_id)->toBe(333); $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); expect($banners[0]->yandex_ad_id)->toBe(5551) // не перезаписан ->and($banners[1]->yandex_ad_id)->toBe(5553) ->and($banners[2]->yandex_ad_id)->toBe(5554); }); it('re-creates the ad group when the launch broke between the group and the audience target', function () { configureYandex(); // Тот же приём, что и в тесте про обрыв на объявлениях: одно фейк-замыкание с // состоянием, счёт обращений — свой (повторный Http::fake() сбросил бы счётчики // Laravel, но старые заглушки остались бы в силе). $calls = []; $breakTarget = true; Http::fake(function ($request) use (&$calls, &$breakTarget) { $url = $request->url(); $key = match (true) { str_contains($url, 'upload_csv_file') => 'segment_upload', str_contains($url, '/confirm') => 'segment_confirm', str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', str_contains($url, '/json/v5/campaigns') => 'campaigns', str_contains($url, '/json/v5/adgroups') => 'adgroups', str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets', str_contains($url, '/json/v5/ads') => 'ads', default => 'other', }; $calls[$key] = ($calls[$key] ?? 0) + 1; return match ($key) { 'segment_upload', 'segment_confirm' => Http::response(['segment' => ['id' => 900001]]), 'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), 'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), 'adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), 'audiencetargets' => $breakTarget ? Http::response([], 500) // обрыв ровно между группой и аудиторией : Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), 'ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), default => Http::response([], 404), }; }); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); // 1) Первый запуск обрывается на навешивании аудитории. expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class); // Номер группы — отметка «группа готова ЦЕЛИКОМ» (создана И аудитория привязана). // Таргет не встал ⇒ номер писать нельзя, иначе повторный запуск переиспользует // группу без аудитории и кампания молча никогда не заработает. $campaign->refresh(); expect($campaign->yandex_campaign_id)->toBe(222) ->and($campaign->yandex_retargeting_list_id)->toBe(111) ->and($campaign->yandex_ad_group_id)->toBeNull() ->and($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); // 2) Повторный запуск — связь восстановилась. $calls = []; $breakTarget = false; app(CampaignLauncher::class)->launch($campaign); expect($calls['adgroups'] ?? 0)->toBe(1) // группа заводится заново ->and($calls['audiencetargets'] ?? 0)->toBe(1) // и аудитория к ней привязывается ->and($calls)->not->toHaveKey('segment_upload') ->and($calls)->not->toHaveKey('retargetinglists') ->and($calls)->not->toHaveKey('campaigns'); $campaign->refresh(); expect($campaign->yandex_ad_group_id)->toBe(333) ->and($campaign->yandex_campaign_id)->toBe(222) ->and($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); $banner = AdCampaignBanner::where('campaign_id', $campaign->id)->first(); expect($banner->yandex_ad_id)->toBe(555); }); it('refuses to launch a campaign that is already running and touches neither Yandex nor the money', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['status' => AdCampaign::STATUS_RUNNING]); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'уже запущена'); // Статус не откатился на «на модерации», отметка о запуске не перебита. $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING) ->and($campaign->launched_at)->toBeNull(); Http::assertNothingSent(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); $holdExists = AdWalletHold::where('tenant_id', $tenant->id) ->where('source_type', 'campaign')->where('source_id', $campaign->id) ->where('status', AdWalletHold::STATUS_ACTIVE)->exists(); expect($holdExists)->toBeFalse(); }); it('launches a campaign that is queued for the operator', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['status' => AdCampaign::STATUS_QUEUED]); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) ->and($campaign->yandex_campaign_id)->toBe(222) ->and($campaign->launched_at)->not->toBeNull(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('12000.00'); }); it('does not freeze the money twice when the launch is repeated', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); // Имитация «портал думает, что запуска не было»: статус вернули в черновик. DB::table('ad_campaigns')->where('id', $campaign->id)->update(['status' => AdCampaign::STATUS_DRAFT]); app(CampaignLauncher::class)->launch($campaign->fresh()); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('12000.00'); // а НЕ 24000.00 $holds = AdWalletHold::where('tenant_id', $tenant->id) ->where('channel', 'yandex')->where('source_type', 'campaign') ->where('source_id', $campaign->id)->where('status', AdWalletHold::STATUS_ACTIVE)->get(); expect($holds)->toHaveCount(1) ->and($holds[0]->amount_rub)->toBe('12000.00'); }); it('does not leak yandex_cost_rub or ad_margin_percent into the serialized campaign after launch', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); $json = json_decode(json_encode($campaign->fresh()), true); expect($json)->not->toHaveKey('yandex_cost_rub') ->and($json)->not->toHaveKey('ad_margin_percent'); });