Files
portal/app/tests/Feature/Advertising/CampaignLauncherTest.php
T
Дмитрий 9cc3f5e950 fix реклама за показы: пауза не врёт про остановку, цена запуска остаётся на кампании, робот не дерётся сам с собой
Хвосты денег Д1-Д6 и робота Р-х1-Р-х6 из приёмочного листа v12.

Д1 цена, по которой заморожены деньги, записывается на кампанию. Пока поле было
пустым, списание читало глобальную цену — админ менял её, и клиент платил больше
обещанного при запуске.

Д2 суточное списание берёт кампанию под замком строки. Ключ идемпотентности зависит
от числа показов, поэтому два одновременных прогона получали разные ключи и списали
бы клиента дважды.

Д3 пауза, не дошедшая до Директа, больше не считается паузой: отказ 409, заморозка
остаётся. Раньше реклама крутилась дальше, портал показывал паузу, а деньги были уже
свободны. У возобновления поведение намеренно прежнее — иначе понадобилось бы пятое
место разморозки, а их ровно четыре. Там же убрана мина строгого сравнения рубильника.

Д4 не трогали — это вопрос владельца.

Д5 рубильник Директа держит и служебный канал робота: выдача задания и приём отчёта
ходили в живой кабинет мимо него.

Д6 проверка рубильника приведена к общему виду: YANDEX_DIRECT_ENABLED=0 давало строку,
которую строгое сравнение читало как включено.

Р-х1 настройки читаются из .env робота, а не каталога запуска.

Р-х2 файл-замок robot.lock: проход и поддержание входа больше не дерутся за профиль
браузера. Занят — уходим молча, задание остаётся в очереди. Брошенный замок
перехватывается через полчаса.

Р-х3 письмо-алярм честно говорит, залиты ли уже креативы в кабинет. Побочно вскрылось,
что тексты писем не проверялись ни одним тестом — транспорт вынесен в src/smtp.js.

Р-х4 тест-пустышка про рабочую папку заменён настоящим: запуск из чужого каталога без
явной папки. Проверено вырезанием.

Р-х6 пустое значение в окружении читается как значение по умолчанию, мусор даёт внятную
ошибку вместо тихого NaN.

Портал 293/293, робот 57/57. Денежных выходов снятия заморозки по-прежнему четыре.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:36:11 +03:00

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