Files
portal/app/tests/Feature/Advertising/CampaignLauncherTest.php
T
Дмитрий b8f75b2a0c fix реклама за показы: файл робота только по своему заданию, замок на баннерах, защита от двойного запуска
Р3 хвост. Адрес файла баннера стал /api/creative-robot/jobs/{jobId}/banners/{bannerId}/file.
Раньше портал подставлял «какое-нибудь задание в работе» — защита выдачи чужих картинок
держалась на внешнем условии, а не на самом запросе. Робота править не пришлось: он берёт
адрес из ответа портала как есть. Запись v9.08 в журнал схемы про частичный уникальный
индекс; rls-reviewer по миграции — GO.

Р4. Замок на наборе баннеров после заведения кампании в Яндексе: перезаливка, включение
и удаление отдают 409 по тому же признаку yandex_campaign_id, что и замок на параметрах.
Без него в портале была новая картинка, а в Яндексе крутилась старая, а удаление строки
с номером объявления заставляло возобновление завести второе объявление того же размера —
старое продолжало крутиться за деньги клиента. Баннер с номером объявления не удаляется
никогда.

Р5. Захват кампании под запуск: новый промежуточный статус launching, перевод под замком
строки в транзакции. Два одновременных нажатия «запустить» проходили проверку статуса оба
и заводили две CPM-кампании при одной заморозке денег. На любой ошибке прежний статус
возвращается, номера созданных в Яндексе сущностей уцелевают — возобновляемый запуск
не тронут. Брошенный захват старше пятнадцати минут перехватывается.

Тесты: портал 266/266, робот 35/35. Все новые защиты проверены вырезанием.

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

773 lines
36 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;
});
});
/*
* ЗАЩИТА ОТ ПОВТОРНОГО ЗАПУСКА.
*
* Проверка «статус 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/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);
});