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/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, ]); } } /** Кампания «за показы» в режиме 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]]), '*/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]]), '*/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]]), '*/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]]]]), ]); $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 + один creatives.get на сверку размеров всего // набора + по одному ads.add на каждый баннер (2) = 9. Http::assertSentCount(9); }); 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/creatives') => 'creatives', 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]]]]), '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, '/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/creatives') => 'creatives', 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]]]]), '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, '/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]]), '*/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); });