3c69f43864
- 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>
262 lines
10 KiB
PHP
262 lines
10 KiB
PHP
<?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');
|
||
});
|