Http::response(['segment' => ['id' => 900001]]), // Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент // или ещё «обрабатывается». По умолчанию — готовый. '*/v1/management/segments' => Http::response(['segments' => [[ 'id' => 900001, 'name' => 'C', 'status' => $segmentReady ? 'processed' : 'is_processed', 'matched_quantity' => $segmentReady ? 150 : null, 'can_create_dependent' => $segmentReady, ]]]), '*/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/creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), ]); } /** * Настоящие размеры креативов, которые раздаёт seedBanners(): запуск сверяет их * с размерами баннеров перед созданием объявлений. Возвращаем весь известный набор — * лишние номера в ответе безвредны, сверка идёт по номеру. * * @return list> */ function creativesOfSeedBanners(): array { $out = []; foreach ([4242 => [300, 250], 4243 => [728, 90], 4244 => [970, 250]] as $id => [$w, $h]) { $out[] = ['Id' => $id, 'Type' => 'HTML5_CREATIVE', 'Width' => $w, 'Height' => $h]; } return $out; } 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, ]); } } /** Ответ Аудиторий «сегмент готов»: запуск спрашивает об этом перед походом в Директ. */ function otvetSegmentGotov() { return Http::response(['segments' => [[ 'id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true, ]]]); } /** Кампания «за показы» в режиме 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'; }); }); /** * Номер креатива до сих пор брался на веру. А попасть в баннер он может двумя путями: * от робота (опознание по разнице слепков) и руками оператора. Ошибка в любом из них — * это объявление 300×250, в котором крутится картинка 728×90: клиент платит за показы * чужого или битого баннера, и заметить это можно только глазами в кабинете. * * Спрашиваем у Яндекса настоящий размер каждого креатива ОДНИМ запросом и сверяем * с размером баннера. Не сошлось — не запускаемся вообще. */ it('refuses to launch when the creative size does not match the banner size', function () { configureYandex(); Http::fake([ '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), // Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент // или ещё «обрабатывается». Здесь — готовый (can_create_dependent = true). '*/v1/management/segments' => Http::response(['segments' => [ ['id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true], ]]), '*/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]]]]), // Креатив 4242 на самом деле 728×90, а висит на баннере 300×250. '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ ['Id' => 4242, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], ]]]), '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), ]); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'размер'); // Объявление не создано, деньги не заморожены, кампания вернулась в черновик. Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads')); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); }); /** * Тот же класс беды с другой стороны: номер вписан, но такого креатива в кабинете нет * вовсе (удалили, опечатались в цифре, номер из чужого аккаунта). Молча создать * объявление по несуществующему креативу нельзя. */ it('refuses to launch when the creative number is not found in the cabinet', function () { configureYandex(); Http::fake([ '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), // Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент // или ещё «обрабатывается». Здесь — готовый (can_create_dependent = true). '*/v1/management/segments' => Http::response(['segments' => [ ['id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true], ]]), '*/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/creatives' => Http::response(['result' => ['Creatives' => []]]), '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), ]); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, '4242'); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads')); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); }); /** * Цена, по которой заморозили деньги, обязана остаться НА КАМПАНИИ. * * Кампания могла быть заведена без своей цены — тогда `effectiveCpm()` читает глобальную * из настроек рекламы. Заморозка считается по ней и записывается в кошелёк, а вот на самой * кампании цена не оставалась. Админ менял глобальную цену — и суточное списание шло уже * по НОВОЙ, выше замороженной: клиент платил больше, чем ему обещали при запуске. */ it('records the client price the money was frozen at', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); // Своей цены у кампании нет — берётся глобальная из ad_settings. $campaign = makeImpressionCampaign($tenant->id, ['client_cpm_rub' => null]); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); $globalCpm = $campaign->effectiveCpm(); app(CampaignLauncher::class)->launch($campaign); expect($campaign->fresh()->client_cpm_rub)->toBe($globalCpm); // Админ поменял глобальную цену — запущенная кампания обязана остаться на своей. DB::table('ad_settings')->update(['client_cpm_rub' => '999.00']); expect($campaign->fresh()->effectiveCpm())->toBe($globalCpm); }); 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]]), // Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент // или ещё «обрабатывается». Здесь — готовый (can_create_dependent = true). '*/v1/management/segments' => Http::response(['segments' => [ ['id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true], ]]), '*/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/creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), // Каждый вызов ads.add отдаёт свой номер объявления. '*/json/v5/ads' => Http::sequence() ->push(['result' => ['AddResults' => [['Id' => 5551]]]]) ->push(['result' => ['AddResults' => [['Id' => 5552]]]]) // Третьим по тому же адресу идёт ads.moderate — отправка созданных объявлений // на проверку Яндекса. ->push(['result' => ['ModerateResults' => [['Id' => 5551], ['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 + подтверждение) + 1 вопрос «готов ли // сегмент» + retargetinglists + campaigns + adgroups + audiencetargets + один // creatives.get на сверку размеров всего набора + по одному ads.add на каждый // баннер (2) + ads.moderate + campaigns.resume = 12. Http::assertSentCount(12); }); 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, '/v1/management/segments') => 'segments_list', str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', // resume отделяем от add: «кампанию не создавали заново» и «показ включили» — // это разные утверждения, а адрес у них один. str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : 'campaigns'), str_contains($url, '/json/v5/adgroups') => 'adgroups', str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets', str_contains($url, '/json/v5/creatives') => 'creatives', str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'), default => 'other', }; $calls[$key] = ($calls[$key] ?? 0) + 1; return match ($key) { 'segment_upload', 'segment_confirm' => Http::response(['segment' => ['id' => 900001]]), 'segments_list' => otvetSegmentGotov(), 'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), 'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), 'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]), 'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5551]]]]), 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), '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, '/v1/management/segments') => 'segments_list', str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', // resume отделяем от add: «кампанию не создавали заново» и «показ включили» — // это разные утверждения, а адрес у них один. str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : 'campaigns'), str_contains($url, '/json/v5/adgroups') => 'adgroups', str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets', str_contains($url, '/json/v5/creatives') => 'creatives', str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'), default => 'other', }; $calls[$key] = ($calls[$key] ?? 0) + 1; return match ($key) { 'segment_upload', 'segment_confirm' => Http::response(['segment' => ['id' => 900001]]), 'segments_list' => otvetSegmentGotov(), 'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), 'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), 'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]), 'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5551]]]]), 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), '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'); }); /** * Дата последнего дня показа должна остаться НА КАМПАНИИ и совпадать с EndDate, * который ушёл в Директ. По ней суточное списание закрывает кампанию, не добравшую * смету, и возвращает клиенту замороженный остаток. Пока даты не было, такая * кампания висела `running` вечно, а деньги были заперты навсегда. */ it('records the last show day on the campaign, matching the EndDate sent to Direct', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); // run_days = 14 seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); $expected = now()->addDays(14)->toDateString(); expect($campaign->refresh()->shows_until->format('Y-m-d'))->toBe($expected); Http::assertSent(function ($request) use ($expected) { if (! str_contains($request->url(), '/json/v5/campaigns')) { return false; } return ($request['params']['Campaigns'][0]['EndDate'] ?? null) === $expected; }); }); /* * ЗАЩИТА ОТ ПОВТОРНОГО ЗАПУСКА. * * Проверка «статус draft или queued» была обычным чтением: два одновременных нажатия * «запустить» проходили её оба, каждый со своего экземпляра модели видел * `yandex_campaign_id = 0` — и оба шли по всей цепочке. В кабинете получались два сегмента, * два условия ретаргетинга, ДВЕ медийные кампании, две группы и два комплекта объявлений. * Заморозка денег при этом одна: портал списал бы за одну кампанию, а крутились бы две. * * Ответ 202 «попробуйте чуть позже» на запуск без креативов прямо приглашает нажать ещё раз, * так что повтор — не экзотика, а штатный сценарий. * * Лечение: захват кампании под замком строки и атомарный перевод в промежуточный статус * `launching`. Второй заход видит его и получает отказ, не сходив в Яндекс ни разу. */ it('второй запуск, пока идёт первый, отбивается и в Яндекс не ходит', 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]); // Первый запуск «уже идёт»: кампания захвачена и помечена «запускается». DB::table('ad_campaigns')->where('id', $campaign->id) ->update(['status' => AdCampaign::STATUS_LAUNCHING, 'updated_at' => now()]); expect(fn () => app(CampaignLauncher::class)->launch($campaign->fresh())) ->toThrow(RuntimeException::class, 'Кампания уже запущена'); Http::assertNothingSent(); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_LAUNCHING); }); /** * Обратная сторона захвата: если процесс запуска убили насмерть (перезагрузка сервера, * убитый воркер), статус `launching` остался бы навсегда и кампанию нельзя было бы запустить * уже никогда. Поэтому захват старше пятнадцати минут считается брошенным и перехватывается. */ it('зависший захват старше пятнадцати минут перехватывается и запуск доводится до конца', 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]); DB::table('ad_campaigns')->where('id', $campaign->id)->update([ 'status' => AdCampaign::STATUS_LAUNCHING, 'updated_at' => now()->subMinutes(20), ]); app(CampaignLauncher::class)->launch($campaign->fresh()); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); }); /** * Захват не должен превращаться в ловушку на обычной ошибке: не хватило аудитории — кампания * обязана вернуться в черновик, чтобы клиент мог исправить и нажать «запустить» снова. */ it('неудачный запуск возвращает кампанию в черновик, а не оставляет её захваченной', 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); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); }); /** * Захват должен быть настоящим: пока запуск идёт, в базе стоит `launching`. Именно эта * запись и отбивает второе нажатие — без неё второй заход прочитал бы обычный `draft` * и пошёл заводить вторую кампанию в кабинете Яндекса. * * Подсматриваем изнутри запуска: заглушка Яндекса читает статус кампании в базе ровно в тот * момент, когда запуск уже начался, но ещё не закончился. */ it('пока запуск идёт, кампания в базе помечена «запускается»', function () { configureYandex(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); $statusDuringLaunch = null; $campaignId = $campaign->id; Http::fake(function ($request) use (&$statusDuringLaunch, $campaignId) { $url = $request->url(); if (str_contains($url, 'upload_csv_file') && $statusDuringLaunch === null) { $statusDuringLaunch = DB::table('ad_campaigns')->where('id', $campaignId)->value('status'); } return match (true) { str_contains($url, 'upload_csv_file'), str_contains($url, '/confirm') => Http::response(['segment' => ['id' => 900001]]), str_contains($url, '/v1/management/segments') => otvetSegmentGotov(), str_contains($url, '/json/v5/retargetinglists') => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), str_contains($url, '/json/v5/campaigns') => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), str_contains($url, '/json/v5/adgroups') => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), str_contains($url, '/json/v5/audiencetargets') => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), str_contains($url, '/json/v5/creatives') => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), str_contains($url, '/json/v5/ads') => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), default => Http::response([], 404), }; }); app(CampaignLauncher::class)->launch($campaign); expect($statusDuringLaunch)->toBe(AdCampaign::STATUS_LAUNCHING) ->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); }); /** * Номер креатива, равный нулю, — это мусор, а не номер. Приезжает он из ручной правки * или из чужого импорта. Проверка «номера нет» ловила его случайно, нестрогим сравнением * (в PHP ноль равен пустоте); стоило написать строгое сравнение «по уму» — и ноль поехал бы * дальше, в запрос к Яндексу. Ловим намеренно и говорим клиенту то же самое, что и про * отсутствующий номер: картинки в кабинет ещё не отвезли. */ it('баннер с нулевым номером креатива считается без номера, а не отправляется в Яндекс', 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' => 0]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'нет номера креатива'); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads')); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); }); /** * Один и тот же номер креатива, вписанный двум баннерам, дал бы два объявления с одной * картинкой, и хотя бы одно — не своего размера: клиент платит за показы битого баннера. * * Отдельной проверки «номера в наборе не повторяются» не нужно: слот «кампания + размер» * теперь уникален в базе, значит два баннера набора всегда разных размеров, а сверка * настоящего размера креатива перед созданием объявлений одного из них обязательно поймает. * Тест держит это рассуждение: сломается сверка — сломается и он. */ it('один номер креатива на двух баннерах не создаёт объявлений', function () { configureYandex(); Http::fake([ '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), // Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент // или ещё «обрабатывается». Здесь — готовый (can_create_dependent = true). '*/v1/management/segments' => Http::response(['segments' => [ ['id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true], ]]), '*/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/creatives' => Http::response(['result' => ['Creatives' => [ ['Id' => 4242, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], ]]]), '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), ]); $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' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'размер'); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads')); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); }); /** * Отметка «отдана клиенту на починку» держит открытым исключение из замка правки. * Жить ей ровно до нового запуска: объявления созданы — дальше правка снова * расходилась бы с кабинетом Яндекса. */ it('запуск гасит отметку об отданной на починку кампании', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); $campaign->update(['revived_at' => now()]); seedAudience($campaign, 100); seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); expect($campaign->refresh()->revived_at)->toBeNull(); }); /** * Приёмочный лист замысла, кусок 2: «после правки обычная кнопка "Запустить" доводит * кампанию до модерации». Второго пути запуска мы не делали намеренно — работает * существующий лаунчер, который доигрывает начатое. * * Здесь же проверяется главное денежное следствие: в Яндексе НЕ заводится второй комплект * кампании, группы и сегмента. Заводился бы — клиент платил бы дважды. */ it('после оживления обычный запуск доводит кампанию до модерации и ничего не создаёт заново', function () { configureYandex(); $calls = []; Http::fake(function ($request) use (&$calls) { $url = $request->url(); $key = match (true) { str_contains($url, 'upload_csv_file') => 'segment_upload', str_contains($url, '/confirm') => 'segment_confirm', str_contains($url, '/v1/management/segments') => 'segments_list', str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', // resume отделяем от add: «кампанию не создавали заново» и «показ включили» — // это разные утверждения, а адрес у них один. str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : 'campaigns'), str_contains($url, '/json/v5/adgroups') => 'adgroups', str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets', str_contains($url, '/json/v5/creatives') => 'creatives', str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'), default => 'other', }; $calls[$key] = ($calls[$key] ?? 0) + 1; return match ($key) { 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), 'ads' => Http::response(['result' => ['AddResults' => [['Id' => 5561]]]]), 'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5561]]]]), 'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]), 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]); $campaign->update([ 'status' => AdCampaign::STATUS_DRAFT, 'revived_at' => now(), 'yandex_segment_id' => 900001, 'yandex_retargeting_list_id' => 111, 'yandex_campaign_id' => 222, 'yandex_ad_group_id' => 333, ]); 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(1); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) ->and($campaign->yandex_campaign_id)->toBe(222) ->and($campaign->revived_at)->toBeNull(); expect(AdCampaignBanner::where('campaign_id', $campaign->id)->first()->yandex_ad_id)->toBe(5561); }); /** * 30.07.2026, живой бой. Сегмент дозрел, портал нажал «Запустить» — и Директ отказал: * «Budget for this period cannot be less than 600 rub.». Приёмочная кампания была * слишком маленькой: 1693 показа при цене 120 ₽ за тысячу дают Яндексу примерно 146 ₽. * * До этой проверки клиент видел сырой отказ Яндекса на английском и не понимал, * что от него хотят. Проверяем ДО похода в Яндекс: в кабинете ничего не создаётся, * деньги не морозятся, кампания остаётся черновиком. */ it('отказывает понятно и не трогает Яндекс, когда смета показов ниже минимума Директа', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); // Ровно та кампания, что стояла на бою. $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 1693]); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(BudgetBelowYandexMinimumException::class); Http::assertNothingSent(); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT) ->and($campaign->yandex_segment_id)->toBeNull() ->and($campaign->yandex_campaign_id)->toBeNull(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); }); /** Исключение считает клиенту, сколько показов и рублей нужно — без единого слова про долю Яндекса. */ it('подсказывает клиенту минимальную смету показов и её цену', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); // Один день показа = период из двух календарных дней ⇒ минимум 2 × 300 = 600 ₽. $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 1693, 'run_days' => 1]); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242]); try { app(CampaignLauncher::class)->launch($campaign); expect('запуск прошёл')->toBe('ожидали отказ по минимальному бюджету'); } catch (BudgetBelowYandexMinimumException $e) { // 600 ₽ минимума ÷ (запас 1.2 × 72 ₽ за тысячу) = 6944.4 → округляем вверх. expect($e->minImpressions)->toBe(6945) ->and($e->minClientRub)->toBe('833.40'); } }); /** * Минимум Директа растёт с длиной периода, и эта смета — ровно та, что прошла старую * проверку на бою и всё равно получила английский отказ Яндекса. * * 6950 показов дают Яндексу 600.48 ₽ — постоянного порога в 600 ₽ хватало. Но кампания * на неделю занимает 8 календарных дней, и Директ требует 8 × 300 = 2400 ₽. */ it('считает минимум по длине периода, а не одной постоянной цифрой', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 6950, 'run_days' => 7]); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242]); try { app(CampaignLauncher::class)->launch($campaign); expect('запуск прошёл')->toBe('ожидали отказ: неделя требует 2400 ₽, а не 600 ₽'); } catch (BudgetBelowYandexMinimumException $e) { // 2400 ₽ ÷ (запас 1.2 × 72 ₽ за тысячу) = 27777.7 → округляем вверх. expect($e->minImpressions)->toBe(27778) ->and($e->minClientRub)->toBe('3333.36'); } // В Яндекс не ходили вовсе: отказ рождается до первого запроса. Http::assertNothingSent(); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); }); /** * Аудитория ЕСТЬ, но Яндекс ещё сводит номера с людьми. Готовность определяется * ТОЛЬКО по can_create_dependent: статус is_processed — это «обрабатывается», не «готов». * * Раньше портал в этот момент лез создавать условие ретаргетинга и получал * «объект не найден» — тот же отказ, что и на несуществующий сегмент. */ it('отказывает понятно, пока Яндекс не свёл номера с людьми', function () { configureYandex(); fakeYandexEndpoints(segmentReady: false); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['yandex_segment_id' => 900001]); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(AudienceNotReadyException::class); // Главное: в Директ за условием ретаргетинга не полезли — именно там раньше // приходил невнятный «объект не найден». Http::assertNotSent(fn ($request) => str_contains($request->url(), 'retargetinglists')); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT) ->and($campaign->yandex_retargeting_list_id)->toBeNull(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); expect($wallet->frozen_rub)->toBe('0.00'); }); /** Готовый сегмент проверку проходит насквозь — она не должна мешать нормальному запуску. */ it('пропускает запуск, когда Яндекс отдаёт can_create_dependent', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['yandex_segment_id' => 900001]); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); }); /** * Создать объявления — не значит запустить рекламу. * * Яндекс кладёт всё созданное черновиком и проверку сам не начинает. Живая приёмка * 30.07.2026: портал отрапортовал «на модерации», а в кабинете висели кампания, группа * и 15 объявлений в DRAFT/OFF — показов не было бы никогда. Запуск обязан сам отдать * объявления на проверку и включить показ. */ it('отдаёт объявления на модерацию и включает показ кампании', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); app(CampaignLauncher::class)->launch($campaign); // Отбор строго по Ids: на CampaignIds Директ отвечает «отсутствует обязательный // параметр Ids» — именно так молча ломалась отправка на бою. Http::assertSent(function ($request) { return str_contains($request->url(), '/json/v5/ads') && ($request->data()['method'] ?? null) === 'moderate' && ($request->data()['params']['SelectionCriteria']['Ids'] ?? []) === [555, 555]; }); Http::assertSent(function ($request) { return str_contains($request->url(), '/json/v5/campaigns') && ($request->data()['method'] ?? null) === 'resume' && ($request->data()['params']['SelectionCriteria']['Ids'] ?? []) === [222]; }); }); /** Без модерации показ включать нельзя: черновик Директ включать отказывается. */ it('сначала отдаёт на модерацию и только потом включает показ', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); $poryadok = []; foreach (Http::recorded() as [$request]) { $metod = $request->data()['method'] ?? null; if ($metod === 'moderate' || $metod === 'resume') { $poryadok[] = $metod; } } expect($poryadok)->toBe(['moderate', 'resume']); }); /** Повторный заход по уже созданным объявлениям на модерацию их НЕ переотправляет. */ it('при повторном запуске не отправляет на модерацию заново', function () { configureYandex(); fakeYandexEndpoints(); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, [ 'yandex_segment_id' => 900001, 'yandex_retargeting_list_id' => 111, 'yandex_campaign_id' => 222, 'yandex_ad_group_id' => 333, ]); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242]); $campaign->banners()->update(['yandex_ad_id' => 999]); app(CampaignLauncher::class)->launch($campaign); Http::assertNotSent(fn ($request) => ($request->data()['method'] ?? null) === 'moderate'); });