c9406533bf
Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива на все размеры не существует. Медийная кампания состоит из объявления на каждый размер блока со своим креативом, поэтому строка баннера стала единицей размер плюс файл плюс креатив плюс объявление. Что сделано: - у баннера появились номер креатива, номер объявления и статус модерации - кап веса баннера поднят со 150 КБ до предела Яндекса 512 КБ - слепок картиночных креативов аккаунта через creatives.get - опознание своих креативов разницей слепков до и после загрузки по размеру - запуск заводит объявление на каждый включённый баннер - модерация считается по каждому объявлению: кампания работает, если принято хотя бы одно, а деньги возвращаются только когда отклонены все Заморозка и возврат денег не тронуты, денежных выходов по-прежнему четыре. После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql, иначе джоб модерации молча увидит ноль баннеров. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
16 KiB
PHP
368 lines
16 KiB
PHP
<?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('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');
|
||
});
|