Files
portal/app/tests/Feature/Advertising/CampaignLauncherTest.php
T
Дмитрий 9f3be090e4 fix(реклама Яндекс): деньги морозим ДО первого обращения к Директу
Приёмка блока Яндекса 03.08.2026 вскрыла неверный порядок в запуске кампании.
Заморозка стояла последней — уже после создания в Директе кампании, группы,
объявлений, отправки на модерацию и ВКЛЮЧЕНИЯ показа. Не хватило денег: клиент
получал отказ, кампания в портале оставалась черновиком, а в Яндексе крутилась
включённая реклама, за которую платила Лидерра.

Правильный порядок в проекте был известен — у кнопки «Возобновить» деньги
резервируются до обращения к Директу, и там об этом написано прямо. В запуске
он просто не был применён.

Сначала сторож, потом починка. CampaignLauncherTest → «не хватает денег — в
Директе ничего не создано»: кошелёк 100 руб, смета 12000 руб. На прежнем коде
упал с «записано неожиданное обращение». Держит именно ПОРЯДОК: пока деньги не
заморожены, Директ можно только читать — ни одного вызова с методом кроме get.
Проверка «деньги не списались» тут бесполезна, она зелёная и при поломке.

Новый порядок в CampaignLauncher::run:
1. годность аудитории — может ответить «зайдите позже», деньги ещё не тронуты;
2. сверка настоящих размеров креативов — это чтение, метод get;
3. заморозка денег;
4. и только теперь все записи в Директ.

Заодно закрылась вторая беда: сверка размеров стояла после создания кампании и
группы, и несовпадение размера оставляло в кабинете кампанию-сироту.

Сторож принят вырезанием: старый порядок — красный, починка — зелёный.
362 теста блока рекламы зелёные, статанализ 0 ошибок.

Отчёт приёмки с остальными находками — в docs/superpowers/runbooks/.
Боевого не касался: кошелёк и кампании остались как были.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:42:47 +03:00

1406 lines
72 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Exceptions\Advertising\AudienceNotReadyException;
use App\Exceptions\Advertising\AudienceTooSmallException;
use App\Exceptions\Advertising\BudgetBelowYandexMinimumException;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdWallet;
use App\Models\AdWalletHold;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\CampaignLauncher;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
// Запуск оставлял в базе кампании в статусе pending_moderation с сегментом 900001
// и сотней телефонов — коммитом, без отката. При полном прогоне SyncCampaignAudienceJob
// перечисляет кампании ВСЕХ тенантов через pgsql_supplier и подхватывал эти хвосты,
// из-за чего SyncCampaignAudienceJobTest падал на «ни одного обращения к Яндексу».
// DatabaseTransactions (а не RefreshDatabase) — откат своих строк без сноса базы.
uses(DatabaseTransactions::class);
function fakeYandexEndpoints(bool $segmentReady = true): void
{
Http::fake([
'*/segments/upload_csv_file' => 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<array<string, mixed>>
*/
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');
});
/**
* Не хватило денег — в Директе не должно появиться НИЧЕГО.
*
* Приёмка 03.08.2026: заморозка стояла последней, уже после создания кампании, группы,
* объявлений, отправки на модерацию и ВКЛЮЧЕНИЯ показа. Клиент получал отказ «не хватает
* денег», кампания в портале оставалась черновиком — а в Яндексе крутилась включённая
* реклама, за которую платила Лидерра. Правильный порядок в проекте был известен: у кнопки
* «Возобновить» деньги резервируются ДО обращения к Директу, и там об этом написано прямо.
*
* Сторож держит именно ПОРЯДОК: одной проверки «деньги не списались» мало — она зелёная и
* при неверном порядке тоже.
*/
it('не хватает денег — в Директе ничего не создано', function () {
configureYandex();
fakeYandexEndpoints();
$tenant = Tenant::factory()->create();
// Смета кампании ниже — 100 000 показов × 120 ₽/1000 = 12 000 ₽. Кладём заведомо меньше.
app(AdWalletService::class)->topup($tenant->id, '100.00', 'yandex', 'тест');
$campaign = makeImpressionCampaign($tenant->id);
seedAudience($campaign, 100);
seedBanners($campaign, ['300x250' => 4242]);
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
->toThrow(InsufficientBalanceException::class);
// Главное: до денег в Директе не сделано ни одной ЗАПИСИ. Чтение до денег разрешено и
// нужно — сверка настоящих размеров креативов ходит в Директ методом `get`. Правило,
// которое держит сторож: пока деньги не заморожены, Директ можно только читать.
// Сегмент Аудиторий создать тоже можно: он ничего не показывает и денег не тратит.
Http::assertNotSent(function ($request) {
if (! str_contains($request->url(), '/json/v5/')) {
return false;
}
return ($request->data()['method'] ?? 'get') !== 'get';
});
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT)
->and($campaign->yandex_retargeting_list_id)->toBeNull()
->and($campaign->yandex_campaign_id)->toBeNull()
->and($campaign->yandex_ad_group_id)->toBeNull()
->and($campaign->launched_at)->toBeNull();
// Денег не тронули: ни заморозки, ни списания.
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->frozen_rub)->toBe('0.00')
->and($wallet->balance_rub)->toBe('100.00');
expect(AdWalletHold::where('tenant_id', $tenant->id)
->where('source_id', $campaign->id)
->where('status', AdWalletHold::STATUS_ACTIVE)->exists())->toBeFalse();
});