Files
portal/app/tests/Feature/Advertising/CampaignLauncherTest.php
T
Дмитрий 3c69f43864 fix(реклама): доступность рекламных экранов и чистота тестов
- Pa11y: в конфиг добавлены три рекламных экрана — клиентский Яндекс,
  рекламный кошелёк и админский расход/маржа.
- Чипы статусов «Черновик» и «На паузе» были на обычном grey — контраст
  2.42:1 при норме WCAG 2.1 AA 4.5:1. Заменены на grey-darken-2, после
  чего оба клиентских рекламных экрана дают 0 ошибок.
- CampaignLauncherTest коммитил кампании в статусе pending_moderation
  с сегментом и сотней телефонов и не откатывал их. При полном прогоне
  SyncCampaignAudienceJob перечисляет кампании всех тенантов через
  pgsql_supplier и подхватывал эти хвосты — SyncCampaignAudienceJobTest
  падал на «ни одного обращения к Яндексу». Добавлен DatabaseTransactions.
  Замер: раньше после прогона папки оставалось 2 живых кампании, теперь 0.
  Полный прогон: было 5 падений, стало 3 — оба рекламных ушли.
- Находка про контраст палитры портала вынесена отдельным документом:
  зелёный 4.25 и жёлтый 2.25 на белом не проходят AA, но это брендбук
  и уже на бою — решение за владельцем.

Реклама 156/156. Боевого не касается, в main не влито.

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

262 lines
10 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\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);
}
/** Кампания «за показы» в режиме 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->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);
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');
});