Files
portal/app/tests/Feature/Advertising/CampaignLauncherTest.php
T
Дмитрий d26716ed90 fix реклама за показы: срок показа закрывает кампанию, отчёт робота только по заданию в работе, гонка выдачи заданий
Р1. Кампания, не добравшая смету показов, оставалась running навсегда, а заморозка
денег клиента - ACTIVE навсегда: единственным переходом в completed было
delivered >= paid_impressions, а задачи, закрывающей кампанию по истечении срока
показа, не существовало вовсе. Для медийки по списку телефонов недокрут сметы -
типовой исход, а не редкий случай.

Новая колонка ad_campaigns.shows_until хранит последний день показа - ровно тот,
что уходит в Директ параметром EndDate. Пишется вместе с yandex_campaign_id, то
есть в момент, когда дату начинает держать Яндекс; возобновляемый запуск
переиспользует уже записанную дату, чтобы портал и Директ считали срок одинаково.
У выхода 1 в CampaignImpressionCharger появилось второе условие - новых мест
вызова AdWalletService::release не добавилось, их по-прежнему ровно четыре.
Запись v9.07 в журнале схемы, rls-reviewer GO.

Р2. Отчёт робота принимался по любому заданию в любом состоянии: номер брался из
адреса как есть. Готово по чужому ещё не выданному заданию разложило бы номера
креативов чужой кампании по её баннерам - картинка одного клиента уехала бы в
объявление другого; сбой по уже закрытому заданию переписал бы правильный
результат на failed. Теперь done принимает отчёт только по заданию в статусе
taken - 409 в остальных случаях, та же проверка продублирована в сервисе.

Р3, первая половина. Проверка «в работе никого» в takeNext не блокировала строку:
две одновременные выдачи обе её проходили и уносили разные задания. Слепки
creatives.get перемешивались, а размеры блоков у всех клиентов одинаковые, поэтому
итог - тихая привязка чужого номера креатива. Гарантию даёт частичный уникальный
индекс uq_creative_job_single_taken, плюс advisory-замок первой строкой транзакции,
чтобы штатный путь спокойно отвечал роботу «работы нет».

Осталось по Р3: привязать выдачу файла к номеру задания, rls-reviewer по индексу,
запись v9.08 в журнал схемы. Ход работы - в файле PROGRESS рядом с промтом v12.

Тесты, прогнаны в одиночку: портал 256/256, робот 35/35. Все девять новых тестов
были красными до правок, каждая защита проверена вырезанием.

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

644 lines
29 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/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]),
]);
}
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';
});
});
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]]]]),
// Каждый вызов 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 + по одному ads.add на каждый баннер (2) = 8.
Http::assertSentCount(8);
});
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/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]]]]),
'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/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]]]]),
'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;
});
});