Files
portal/app/tests/Feature/Advertising/CampaignLauncherTest.php
T

612 lines
28 KiB
PHP
Raw Normal View History

<?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');
});