Files
portal/app/tests/Feature/Advertising/CampaignLauncherTest.php
T
Дмитрий 9488075076 fix реклама: минимум площадки считается по длине периода, а созданные объявления сами уходят на модерацию
Живой запуск кампании на бою 30.07.2026 вскрыл две поломки, которых вчерашняя
починка не видела. Обе — из класса молчаливых: портал рапортует успех, а в жизни
ничего не происходит.

ПЕРВАЯ. Минимум Директа НЕ постоянный.

Вчера мы приняли живой отказ «Budget for this period cannot be less than 600 rub.»
за постоянный порог и зашили 600 ₽ в настройку. Сегодня та же кампания на неделю
получила отказ «cannot be less than 2400 rub.» — проверка её пропустила, и клиент
снова увидел английский текст.

Две точки дали правило: минимум = 300 ₽ за каждый календарный день периода,
считая оба края.
   период 30.07-31.07, 2 дня → 600 ₽  = 2 × 300
   период 30.07-06.08, 8 дней → 2400 ₽ = 8 × 300

Теперь порог считается от периода показа, а ставка за день вынесена в настройку
YANDEX_DIRECT_MIN_SPEND_RUB_PER_DAY. Период вычисляется ДО денег — иначе считать
минимум не от чего.

ВТОРАЯ. Создать объявления — не значит запустить рекламу.

Запуск проходил успешно, портал ставил статус «на модерации», а в кабинете лежали
кампания, группа и 15 объявлений в состоянии DRAFT и OFF. Яндекс кладёт всё
созданное черновиком и проверку сам не начинает. Реклама не показалась бы никогда,
и узнать об этом можно было только глазами в кабинете: наш журнал говорил, что всё
хорошо.

Добавлены два вызова, которых не было вовсе:
   ads.moderate     — отдать созданные объявления на проверку
   campaigns.resume — включить показ

Порядок обязателен. Пока кампания черновик, включить её нельзя — Директ отвечает
«кампания является черновиком и не может быть остановлена». Сначала модерация,
она переводит кампанию в MODERATION, и только потом включение.

Отбор объявлений строго по Ids. На CampaignIds Директ отвечает «отсутствует
обязательный параметр Ids» — именно так отправка молча не сработала бы.

На модерацию уходят объявления, созданные ИМЕННО этим заходом: повторная отправка
уже проверяемого — отказ по позиции, он порвал бы возобновляемый запуск. Включение
показа безобидно при любом повторе: на включённой кампании Яндекс отвечает
предупреждением, а не отказом.

Заодно клиент Директа перестал молчать про отказы внутри ответа: раньше он смотрел
только AddResults, UpdateResults и DeleteResults, теперь ещё ModerateResults и
ResumeResults. Отказ по позиции в этих двух проходил бы насквозь незамеченным.

ПРОВЕРЕНО ЖИВЬЁМ. Кампания № 6 на бою: в кабинете 713175197, группа 5778555449,
15 объявлений, аудитория подключена, статус MODERATION, показ включён. На счету
Яндекса 8775 ₽. У клиента заморожено 3333.36 ₽ за 27778 показов до 06.08.

Тесты: 346 зелёных по рекламе, 43 по клиенту Директа, статанализ ноль замечаний.
Четыре новых теста — минимум по длине периода на боевом случае, отправка на
модерацию, порядок модерация-до-включения, отсутствие повторной отправки.
Два старых теста закрепляли частный случай в два дня — им проставлен явный срок
показа, иначе они молча описывали бы неправду.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:43:25 +03:00

1349 lines
69 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\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');
});