Files
portal/app/tests/Feature/Advertising/CampaignLauncherTest.php
T
Дмитрий 83c8eec63b feat(реклама показы): запуск медийной кампании через Директ + адрес сайта и id объявления
Задача 7 плана «реклама за показы». CampaignLauncher переписан под медийную
кампанию CPM: дешёвые проверки до трат денег рубильник, номер креатива, адрес
сайта, смета показов, затем аудитория не меньше 100, деньги через bcmath с
наценкой, цепочка сегмент → retargeting → CPM-кампания → CPM-группа →
медиа-таргет → баннер по готовому креативу, заморозка клиентской суммы,
статус на модерации.

Колонки ad_campaigns.landing_url и yandex_ad_id, модель, журнал схемы v9.02.
Наценка и yandex_cost_rub клиенту не видны. Реклама-модуль 190/190 зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 00:21:17 +03:00

253 lines
9.7 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\AdWallet;
use App\Models\AdWalletHold;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\CampaignLauncher;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
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);
}
/** Кампания «за показы» в режиме 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' => 4242,
'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);
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->yandex_ad_id)->toBe(555)
->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);
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('throws RuntimeException with a clear message when creative id is missing', function () {
configureYandex();
fakeYandexEndpoints();
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
$campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]);
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 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]);
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]);
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('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);
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');
});