2026-07-24 23:19:23 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
2026-07-30 15:07:49 +03:00
|
|
|
|
use App\Exceptions\Advertising\AudienceNotReadyException;
|
2026-07-24 23:19:23 +03:00
|
|
|
|
use App\Exceptions\Advertising\AudienceTooSmallException;
|
2026-07-30 15:07:49 +03:00
|
|
|
|
use App\Exceptions\Advertising\BudgetBelowYandexMinimumException;
|
2026-07-24 23:19:23 +03:00
|
|
|
|
use App\Models\AdCampaign;
|
2026-07-27 14:33:02 +03:00
|
|
|
|
use App\Models\AdCampaignBanner;
|
2026-07-24 23:19:23 +03:00
|
|
|
|
use App\Models\AdWallet;
|
|
|
|
|
|
use App\Models\AdWalletHold;
|
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
|
use App\Services\Advertising\AdWalletService;
|
|
|
|
|
|
use App\Services\Advertising\CampaignLauncher;
|
2026-07-27 10:41:36 +03:00
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
2026-07-24 23:19:23 +03:00
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
2026-07-27 10:41:36 +03:00
|
|
|
|
// Запуск оставлял в базе кампании в статусе pending_moderation с сегментом 900001
|
|
|
|
|
|
// и сотней телефонов — коммитом, без отката. При полном прогоне SyncCampaignAudienceJob
|
|
|
|
|
|
// перечисляет кампании ВСЕХ тенантов через pgsql_supplier и подхватывал эти хвосты,
|
|
|
|
|
|
// из-за чего SyncCampaignAudienceJobTest падал на «ни одного обращения к Яндексу».
|
|
|
|
|
|
// DatabaseTransactions (а не RefreshDatabase) — откат своих строк без сноса базы.
|
|
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
|
|
|
2026-07-30 15:07:49 +03:00
|
|
|
|
function fakeYandexEndpoints(bool $segmentReady = true): void
|
2026-07-24 23:19:23 +03:00
|
|
|
|
{
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]),
|
2026-07-30 15:07:49 +03:00
|
|
|
|
// Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент
|
|
|
|
|
|
// или ещё «обрабатывается». По умолчанию — готовый.
|
|
|
|
|
|
'*/v1/management/segments' => Http::response(['segments' => [[
|
|
|
|
|
|
'id' => 900001,
|
|
|
|
|
|
'name' => 'C',
|
|
|
|
|
|
'status' => $segmentReady ? 'processed' : 'is_processed',
|
|
|
|
|
|
'matched_quantity' => $segmentReady ? 150 : null,
|
|
|
|
|
|
'can_create_dependent' => $segmentReady,
|
|
|
|
|
|
]]]),
|
2026-07-24 23:19:23 +03:00
|
|
|
|
'*/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]]]]),
|
2026-07-28 05:37:56 +03:00
|
|
|
|
'*/json/v5/creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]),
|
2026-07-24 23:19:23 +03:00
|
|
|
|
'*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 05:37:56 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Настоящие размеры креативов, которые раздаёт seedBanners(): запуск сверяет их
|
|
|
|
|
|
* с размерами баннеров перед созданием объявлений. Возвращаем весь известный набор —
|
|
|
|
|
|
* лишние номера в ответе безвредны, сверка идёт по номеру.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return list<array<string, mixed>>
|
|
|
|
|
|
*/
|
|
|
|
|
|
function creativesOfSeedBanners(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$out = [];
|
|
|
|
|
|
foreach ([4242 => [300, 250], 4243 => [728, 90], 4244 => [970, 250]] as $id => [$w, $h]) {
|
|
|
|
|
|
$out[] = ['Id' => $id, 'Type' => 'HTML5_CREATIVE', 'Width' => $w, 'Height' => $h];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 23:19:23 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 14:33:02 +03:00
|
|
|
|
/** Кладёт кампании набор баннеров с номерами креативов, как будто робот уже отработал. */
|
|
|
|
|
|
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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 15:07:49 +03:00
|
|
|
|
/** Ответ Аудиторий «сегмент готов»: запуск спрашивает об этом перед походом в Директ. */
|
|
|
|
|
|
function otvetSegmentGotov()
|
|
|
|
|
|
{
|
|
|
|
|
|
return Http::response(['segments' => [[
|
|
|
|
|
|
'id' => 900001,
|
|
|
|
|
|
'name' => 'C',
|
|
|
|
|
|
'status' => 'processed',
|
|
|
|
|
|
'matched_quantity' => 150,
|
|
|
|
|
|
'can_create_dependent' => true,
|
|
|
|
|
|
]]]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 14:33:02 +03:00
|
|
|
|
/** Кампания «за показы» в режиме manual с адресом сайта; креативы — на баннерах набора. */
|
2026-07-27 00:21:17 +03:00
|
|
|
|
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',
|
2026-07-27 14:33:02 +03:00
|
|
|
|
// Конструктора креативов у Яндекса больше нет: поле кампании — аварийный ручной
|
|
|
|
|
|
// путь, запуск его не читает. Номера креативов живут на баннерах набора.
|
|
|
|
|
|
'yandex_creative_id' => null,
|
2026-07-27 00:21:17 +03:00
|
|
|
|
'landing_url' => 'https://liderra.ru/promo',
|
|
|
|
|
|
'run_days' => 14,
|
|
|
|
|
|
], $overrides));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('launches an impression campaign: segment → Direct CPM → freeze budget → pending moderation', function () {
|
2026-07-24 23:19:23 +03:00
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
2026-07-27 00:21:17 +03:00
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
2026-07-24 23:19:23 +03:00
|
|
|
|
|
2026-07-27 00:21:17 +03:00
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id);
|
2026-07-24 23:19:23 +03:00
|
|
|
|
seedAudience($campaign, 100);
|
2026-07-27 14:33:02 +03:00
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
2026-07-24 23:19:23 +03:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-27 10:17:06 +03:00
|
|
|
|
->and($campaign->paid_impressions)->toBe(100000) // смета = потолок биллинга (Задача 10 fix)
|
2026-07-24 23:19:23 +03:00
|
|
|
|
->and($campaign->launched_at)->not->toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
2026-07-27 00:21:17 +03:00
|
|
|
|
expect($wallet->frozen_rub)->toBe('12000.00'); // 100000 показов × 120.00₽/1000
|
2026-07-24 23:19:23 +03:00
|
|
|
|
|
|
|
|
|
|
$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()
|
2026-07-27 00:21:17 +03:00
|
|
|
|
->and($hold->amount_rub)->toBe('12000.00');
|
2026-07-24 23:19:23 +03:00
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
|
if (! str_contains($request->url(), '/json/v5/campaigns')) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 00:21:17 +03:00
|
|
|
|
$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;
|
|
|
|
|
|
});
|
2026-07-24 23:19:23 +03:00
|
|
|
|
|
2026-07-27 00:21:17 +03:00
|
|
|
|
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';
|
2026-07-24 23:19:23 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-28 05:37:56 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Номер креатива до сих пор брался на веру. А попасть в баннер он может двумя путями:
|
|
|
|
|
|
* от робота (опознание по разнице слепков) и руками оператора. Ошибка в любом из них —
|
|
|
|
|
|
* это объявление 300×250, в котором крутится картинка 728×90: клиент платит за показы
|
|
|
|
|
|
* чужого или битого баннера, и заметить это можно только глазами в кабинете.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Спрашиваем у Яндекса настоящий размер каждого креатива ОДНИМ запросом и сверяем
|
|
|
|
|
|
* с размером баннера. Не сошлось — не запускаемся вообще.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('refuses to launch when the creative size does not match the banner size', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]),
|
2026-07-30 15:07:49 +03:00
|
|
|
|
// Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент
|
|
|
|
|
|
// или ещё «обрабатывается». Здесь — готовый (can_create_dependent = true).
|
|
|
|
|
|
'*/v1/management/segments' => Http::response(['segments' => [
|
|
|
|
|
|
['id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true],
|
|
|
|
|
|
]]),
|
2026-07-28 05:37:56 +03:00
|
|
|
|
'*/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]]]]),
|
|
|
|
|
|
// Креатив 4242 на самом деле 728×90, а висит на баннере 300×250.
|
|
|
|
|
|
'*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [
|
|
|
|
|
|
['Id' => 4242, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90],
|
|
|
|
|
|
]]]),
|
|
|
|
|
|
'*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id);
|
|
|
|
|
|
seedAudience($campaign, 100);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
|
|
|
|
|
|
->toThrow(RuntimeException::class, 'размер');
|
|
|
|
|
|
|
|
|
|
|
|
// Объявление не создано, деньги не заморожены, кампания вернулась в черновик.
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads'));
|
|
|
|
|
|
|
|
|
|
|
|
$campaign->refresh();
|
|
|
|
|
|
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
|
|
|
|
|
|
|
|
|
|
|
|
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
|
|
|
|
|
expect($wallet->frozen_rub)->toBe('0.00');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Тот же класс беды с другой стороны: номер вписан, но такого креатива в кабинете нет
|
|
|
|
|
|
* вовсе (удалили, опечатались в цифре, номер из чужого аккаунта). Молча создать
|
|
|
|
|
|
* объявление по несуществующему креативу нельзя.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('refuses to launch when the creative number is not found in the cabinet', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]),
|
2026-07-30 15:07:49 +03:00
|
|
|
|
// Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент
|
|
|
|
|
|
// или ещё «обрабатывается». Здесь — готовый (can_create_dependent = true).
|
|
|
|
|
|
'*/v1/management/segments' => Http::response(['segments' => [
|
|
|
|
|
|
['id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true],
|
|
|
|
|
|
]]),
|
2026-07-28 05:37:56 +03:00
|
|
|
|
'*/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/creatives' => Http::response(['result' => ['Creatives' => []]]),
|
|
|
|
|
|
'*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id);
|
|
|
|
|
|
seedAudience($campaign, 100);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
|
|
|
|
|
|
->toThrow(RuntimeException::class, '4242');
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads'));
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-28 06:36:11 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Цена, по которой заморозили деньги, обязана остаться НА КАМПАНИИ.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Кампания могла быть заведена без своей цены — тогда `effectiveCpm()` читает глобальную
|
|
|
|
|
|
* из настроек рекламы. Заморозка считается по ней и записывается в кошелёк, а вот на самой
|
|
|
|
|
|
* кампании цена не оставалась. Админ менял глобальную цену — и суточное списание шло уже
|
|
|
|
|
|
* по НОВОЙ, выше замороженной: клиент платил больше, чем ему обещали при запуске.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('records the client price the money was frozen at', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
// Своей цены у кампании нет — берётся глобальная из ad_settings.
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, ['client_cpm_rub' => null]);
|
|
|
|
|
|
seedAudience($campaign, 100);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
$globalCpm = $campaign->effectiveCpm();
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
expect($campaign->fresh()->client_cpm_rub)->toBe($globalCpm);
|
|
|
|
|
|
|
|
|
|
|
|
// Админ поменял глобальную цену — запущенная кампания обязана остаться на своей.
|
|
|
|
|
|
DB::table('ad_settings')->update(['client_cpm_rub' => '999.00']);
|
|
|
|
|
|
expect($campaign->fresh()->effectiveCpm())->toBe($globalCpm);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-24 23:19:23 +03:00
|
|
|
|
it('throws AudienceTooSmallException and does not freeze when audience is under 100', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
2026-07-27 00:21:17 +03:00
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
2026-07-24 23:19:23 +03:00
|
|
|
|
|
2026-07-27 00:21:17 +03:00
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id);
|
2026-07-24 23:19:23 +03:00
|
|
|
|
seedAudience($campaign, 50);
|
2026-07-27 14:33:02 +03:00
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
2026-07-24 23:19:23 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-07-27 00:21:17 +03:00
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, ['use_uploaded_list' => false]);
|
2026-07-24 23:19:23 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-07-27 00:21:17 +03:00
|
|
|
|
|
2026-07-27 14:33:02 +03:00
|
|
|
|
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]]),
|
2026-07-30 15:07:49 +03:00
|
|
|
|
// Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент
|
|
|
|
|
|
// или ещё «обрабатывается». Здесь — готовый (can_create_dependent = true).
|
|
|
|
|
|
'*/v1/management/segments' => Http::response(['segments' => [
|
|
|
|
|
|
['id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true],
|
|
|
|
|
|
]]),
|
2026-07-27 14:33:02 +03:00
|
|
|
|
'*/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]]]]),
|
2026-07-28 05:37:56 +03:00
|
|
|
|
'*/json/v5/creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]),
|
2026-07-27 14:33:02 +03:00
|
|
|
|
// Каждый вызов ads.add отдаёт свой номер объявления.
|
|
|
|
|
|
'*/json/v5/ads' => Http::sequence()
|
|
|
|
|
|
->push(['result' => ['AddResults' => [['Id' => 5551]]]])
|
2026-07-30 16:43:25 +03:00
|
|
|
|
->push(['result' => ['AddResults' => [['Id' => 5552]]]])
|
|
|
|
|
|
// Третьим по тому же адресу идёт ads.moderate — отправка созданных объявлений
|
|
|
|
|
|
// на проверку Яндекса.
|
|
|
|
|
|
->push(['result' => ['ModerateResults' => [['Id' => 5551], ['Id' => 5552]]]]),
|
2026-07-27 14:33:02 +03:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$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);
|
|
|
|
|
|
|
2026-07-30 15:07:49 +03:00
|
|
|
|
// 2 запроса на сегмент Аудиторий (заливка csv + подтверждение) + 1 вопрос «готов ли
|
|
|
|
|
|
// сегмент» + retargetinglists + campaigns + adgroups + audiencetargets + один
|
|
|
|
|
|
// creatives.get на сверку размеров всего набора + по одному ads.add на каждый
|
2026-07-30 16:43:25 +03:00
|
|
|
|
// баннер (2) + ads.moderate + campaigns.resume = 12.
|
|
|
|
|
|
Http::assertSentCount(12);
|
2026-07-27 14:33:02 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('skips banners that are switched off by the client', function () {
|
2026-07-27 00:21:17 +03:00
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]);
|
2026-07-27 14:33:02 +03:00
|
|
|
|
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]);
|
2026-07-27 00:21:17 +03:00
|
|
|
|
|
|
|
|
|
|
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
|
|
|
|
|
|
->toThrow(RuntimeException::class, 'креатив');
|
|
|
|
|
|
|
2026-07-27 14:33:02 +03:00
|
|
|
|
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, 'баннер');
|
2026-07-27 00:21:17 +03:00
|
|
|
|
|
2026-07-27 14:33:02 +03:00
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
|
2026-07-27 00:21:17 +03:00
|
|
|
|
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]);
|
2026-07-27 14:33:02 +03:00
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
2026-07-27 00:21:17 +03:00
|
|
|
|
|
|
|
|
|
|
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]);
|
2026-07-27 14:33:02 +03:00
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
2026-07-27 00:21:17 +03:00
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-27 15:54:31 +03: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',
|
2026-07-30 15:07:49 +03:00
|
|
|
|
str_contains($url, '/v1/management/segments') => 'segments_list',
|
2026-07-27 15:54:31 +03:00
|
|
|
|
str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists',
|
2026-07-30 16:43:25 +03:00
|
|
|
|
// resume отделяем от add: «кампанию не создавали заново» и «показ включили» —
|
|
|
|
|
|
// это разные утверждения, а адрес у них один.
|
|
|
|
|
|
str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : 'campaigns'),
|
2026-07-27 15:54:31 +03:00
|
|
|
|
str_contains($url, '/json/v5/adgroups') => 'adgroups',
|
|
|
|
|
|
str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets',
|
2026-07-28 05:37:56 +03:00
|
|
|
|
str_contains($url, '/json/v5/creatives') => 'creatives',
|
2026-07-30 16:43:25 +03:00
|
|
|
|
str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'),
|
2026-07-27 15:54:31 +03:00
|
|
|
|
default => 'other',
|
|
|
|
|
|
};
|
|
|
|
|
|
$calls[$key] = ($calls[$key] ?? 0) + 1;
|
|
|
|
|
|
|
|
|
|
|
|
return match ($key) {
|
|
|
|
|
|
'segment_upload', 'segment_confirm' => Http::response(['segment' => ['id' => 900001]]),
|
2026-07-30 15:07:49 +03:00
|
|
|
|
'segments_list' => otvetSegmentGotov(),
|
2026-07-27 15:54:31 +03:00
|
|
|
|
'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]),
|
|
|
|
|
|
'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]),
|
2026-07-30 16:43:25 +03:00
|
|
|
|
'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]),
|
|
|
|
|
|
'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5551]]]]),
|
2026-07-28 05:37:56 +03:00
|
|
|
|
'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]),
|
2026-07-27 15:54:31 +03:00
|
|
|
|
'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',
|
2026-07-30 15:07:49 +03:00
|
|
|
|
str_contains($url, '/v1/management/segments') => 'segments_list',
|
2026-07-27 15:54:31 +03:00
|
|
|
|
str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists',
|
2026-07-30 16:43:25 +03:00
|
|
|
|
// resume отделяем от add: «кампанию не создавали заново» и «показ включили» —
|
|
|
|
|
|
// это разные утверждения, а адрес у них один.
|
|
|
|
|
|
str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : 'campaigns'),
|
2026-07-27 15:54:31 +03:00
|
|
|
|
str_contains($url, '/json/v5/adgroups') => 'adgroups',
|
|
|
|
|
|
str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets',
|
2026-07-28 05:37:56 +03:00
|
|
|
|
str_contains($url, '/json/v5/creatives') => 'creatives',
|
2026-07-30 16:43:25 +03:00
|
|
|
|
str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'),
|
2026-07-27 15:54:31 +03:00
|
|
|
|
default => 'other',
|
|
|
|
|
|
};
|
|
|
|
|
|
$calls[$key] = ($calls[$key] ?? 0) + 1;
|
|
|
|
|
|
|
|
|
|
|
|
return match ($key) {
|
|
|
|
|
|
'segment_upload', 'segment_confirm' => Http::response(['segment' => ['id' => 900001]]),
|
2026-07-30 15:07:49 +03:00
|
|
|
|
'segments_list' => otvetSegmentGotov(),
|
2026-07-27 15:54:31 +03:00
|
|
|
|
'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]),
|
|
|
|
|
|
'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]),
|
2026-07-30 16:43:25 +03:00
|
|
|
|
'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]),
|
|
|
|
|
|
'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5551]]]]),
|
2026-07-28 05:37:56 +03:00
|
|
|
|
'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]),
|
2026-07-27 15:54:31 +03:00
|
|
|
|
'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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-27 00:21:17 +03: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);
|
2026-07-27 14:33:02 +03:00
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
2026-07-27 00:21:17 +03:00
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
2026-07-27 19:36:03 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Дата последнего дня показа должна остаться НА КАМПАНИИ и совпадать с EndDate,
|
|
|
|
|
|
* который ушёл в Директ. По ней суточное списание закрывает кампанию, не добравшую
|
|
|
|
|
|
* смету, и возвращает клиенту замороженный остаток. Пока даты не было, такая
|
|
|
|
|
|
* кампания висела `running` вечно, а деньги были заперты навсегда.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('records the last show day on the campaign, matching the EndDate sent to Direct', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id); // run_days = 14
|
|
|
|
|
|
seedAudience($campaign, 100);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
$expected = now()->addDays(14)->toDateString();
|
|
|
|
|
|
|
|
|
|
|
|
expect($campaign->refresh()->shows_until->format('Y-m-d'))->toBe($expected);
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) use ($expected) {
|
|
|
|
|
|
if (! str_contains($request->url(), '/json/v5/campaigns')) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ($request['params']['Campaigns'][0]['EndDate'] ?? null) === $expected;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-07-27 20:06:57 +03:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* ЗАЩИТА ОТ ПОВТОРНОГО ЗАПУСКА.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Проверка «статус draft или queued» была обычным чтением: два одновременных нажатия
|
|
|
|
|
|
* «запустить» проходили её оба, каждый со своего экземпляра модели видел
|
|
|
|
|
|
* `yandex_campaign_id = 0` — и оба шли по всей цепочке. В кабинете получались два сегмента,
|
|
|
|
|
|
* два условия ретаргетинга, ДВЕ медийные кампании, две группы и два комплекта объявлений.
|
|
|
|
|
|
* Заморозка денег при этом одна: портал списал бы за одну кампанию, а крутились бы две.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Ответ 202 «попробуйте чуть позже» на запуск без креативов прямо приглашает нажать ещё раз,
|
|
|
|
|
|
* так что повтор — не экзотика, а штатный сценарий.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Лечение: захват кампании под замком строки и атомарный перевод в промежуточный статус
|
|
|
|
|
|
* `launching`. Второй заход видит его и получает отказ, не сходив в Яндекс ни разу.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
it('второй запуск, пока идёт первый, отбивается и в Яндекс не ходит', 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]);
|
|
|
|
|
|
|
|
|
|
|
|
// Первый запуск «уже идёт»: кампания захвачена и помечена «запускается».
|
|
|
|
|
|
DB::table('ad_campaigns')->where('id', $campaign->id)
|
|
|
|
|
|
->update(['status' => AdCampaign::STATUS_LAUNCHING, 'updated_at' => now()]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fn () => app(CampaignLauncher::class)->launch($campaign->fresh()))
|
|
|
|
|
|
->toThrow(RuntimeException::class, 'Кампания уже запущена');
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_LAUNCHING);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Обратная сторона захвата: если процесс запуска убили насмерть (перезагрузка сервера,
|
|
|
|
|
|
* убитый воркер), статус `launching` остался бы навсегда и кампанию нельзя было бы запустить
|
|
|
|
|
|
* уже никогда. Поэтому захват старше пятнадцати минут считается брошенным и перехватывается.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('зависший захват старше пятнадцати минут перехватывается и запуск доводится до конца', 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]);
|
|
|
|
|
|
|
|
|
|
|
|
DB::table('ad_campaigns')->where('id', $campaign->id)->update([
|
|
|
|
|
|
'status' => AdCampaign::STATUS_LAUNCHING,
|
|
|
|
|
|
'updated_at' => now()->subMinutes(20),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign->fresh());
|
|
|
|
|
|
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Захват не должен превращаться в ловушку на обычной ошибке: не хватило аудитории — кампания
|
|
|
|
|
|
* обязана вернуться в черновик, чтобы клиент мог исправить и нажать «запустить» снова.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('неудачный запуск возвращает кампанию в черновик, а не оставляет её захваченной', 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);
|
|
|
|
|
|
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Захват должен быть настоящим: пока запуск идёт, в базе стоит `launching`. Именно эта
|
|
|
|
|
|
* запись и отбивает второе нажатие — без неё второй заход прочитал бы обычный `draft`
|
|
|
|
|
|
* и пошёл заводить вторую кампанию в кабинете Яндекса.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Подсматриваем изнутри запуска: заглушка Яндекса читает статус кампании в базе ровно в тот
|
|
|
|
|
|
* момент, когда запуск уже начался, но ещё не закончился.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('пока запуск идёт, кампания в базе помечена «запускается»', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id);
|
|
|
|
|
|
seedAudience($campaign, 100);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
$statusDuringLaunch = null;
|
|
|
|
|
|
$campaignId = $campaign->id;
|
|
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$statusDuringLaunch, $campaignId) {
|
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
if (str_contains($url, 'upload_csv_file') && $statusDuringLaunch === null) {
|
|
|
|
|
|
$statusDuringLaunch = DB::table('ad_campaigns')->where('id', $campaignId)->value('status');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return match (true) {
|
|
|
|
|
|
str_contains($url, 'upload_csv_file'), str_contains($url, '/confirm') => Http::response(['segment' => ['id' => 900001]]),
|
2026-07-30 15:07:49 +03:00
|
|
|
|
str_contains($url, '/v1/management/segments') => otvetSegmentGotov(),
|
2026-07-27 20:06:57 +03:00
|
|
|
|
str_contains($url, '/json/v5/retargetinglists') => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]),
|
|
|
|
|
|
str_contains($url, '/json/v5/campaigns') => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]),
|
|
|
|
|
|
str_contains($url, '/json/v5/adgroups') => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]),
|
|
|
|
|
|
str_contains($url, '/json/v5/audiencetargets') => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]),
|
2026-07-28 05:37:56 +03:00
|
|
|
|
str_contains($url, '/json/v5/creatives') => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]),
|
2026-07-27 20:06:57 +03:00
|
|
|
|
str_contains($url, '/json/v5/ads') => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]),
|
|
|
|
|
|
default => Http::response([], 404),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
expect($statusDuringLaunch)->toBe(AdCampaign::STATUS_LAUNCHING)
|
|
|
|
|
|
->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION);
|
|
|
|
|
|
});
|
2026-07-28 07:21:02 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Номер креатива, равный нулю, — это мусор, а не номер. Приезжает он из ручной правки
|
|
|
|
|
|
* или из чужого импорта. Проверка «номера нет» ловила его случайно, нестрогим сравнением
|
|
|
|
|
|
* (в PHP ноль равен пустоте); стоило написать строгое сравнение «по уму» — и ноль поехал бы
|
|
|
|
|
|
* дальше, в запрос к Яндексу. Ловим намеренно и говорим клиенту то же самое, что и про
|
|
|
|
|
|
* отсутствующий номер: картинки в кабинет ещё не отвезли.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('баннер с нулевым номером креатива считается без номера, а не отправляется в Яндекс', 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' => 0]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
|
|
|
|
|
|
->toThrow(RuntimeException::class, 'нет номера креатива');
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads'));
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Один и тот же номер креатива, вписанный двум баннерам, дал бы два объявления с одной
|
|
|
|
|
|
* картинкой, и хотя бы одно — не своего размера: клиент платит за показы битого баннера.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Отдельной проверки «номера в наборе не повторяются» не нужно: слот «кампания + размер»
|
|
|
|
|
|
* теперь уникален в базе, значит два баннера набора всегда разных размеров, а сверка
|
|
|
|
|
|
* настоящего размера креатива перед созданием объявлений одного из них обязательно поймает.
|
|
|
|
|
|
* Тест держит это рассуждение: сломается сверка — сломается и он.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('один номер креатива на двух баннерах не создаёт объявлений', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]),
|
2026-07-30 15:07:49 +03:00
|
|
|
|
// Запуск спрашивает у Яндекса, свёл ли он уже номера с людьми: годен сегмент
|
|
|
|
|
|
// или ещё «обрабатывается». Здесь — готовый (can_create_dependent = true).
|
|
|
|
|
|
'*/v1/management/segments' => Http::response(['segments' => [
|
|
|
|
|
|
['id' => 900001, 'name' => 'C', 'status' => 'processed', 'matched_quantity' => 150, 'can_create_dependent' => true],
|
|
|
|
|
|
]]),
|
2026-07-28 07:21:02 +03:00
|
|
|
|
'*/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/creatives' => Http::response(['result' => ['Creatives' => [
|
|
|
|
|
|
['Id' => 4242, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250],
|
|
|
|
|
|
]]]),
|
|
|
|
|
|
'*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$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' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
|
|
|
|
|
|
->toThrow(RuntimeException::class, 'размер');
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads'));
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
|
|
|
|
|
|
});
|
2026-07-28 12:28:20 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Отметка «отдана клиенту на починку» держит открытым исключение из замка правки.
|
|
|
|
|
|
* Жить ей ровно до нового запуска: объявления созданы — дальше правка снова
|
|
|
|
|
|
* расходилась бы с кабинетом Яндекса.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('запуск гасит отметку об отданной на починку кампании', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id);
|
|
|
|
|
|
$campaign->update(['revived_at' => now()]);
|
|
|
|
|
|
seedAudience($campaign, 100);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
expect($campaign->refresh()->revived_at)->toBeNull();
|
|
|
|
|
|
});
|
2026-07-28 12:36:34 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Приёмочный лист замысла, кусок 2: «после правки обычная кнопка "Запустить" доводит
|
|
|
|
|
|
* кампанию до модерации». Второго пути запуска мы не делали намеренно — работает
|
|
|
|
|
|
* существующий лаунчер, который доигрывает начатое.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Здесь же проверяется главное денежное следствие: в Яндексе НЕ заводится второй комплект
|
|
|
|
|
|
* кампании, группы и сегмента. Заводился бы — клиент платил бы дважды.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('после оживления обычный запуск доводит кампанию до модерации и ничего не создаёт заново', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
|
|
|
|
|
|
$calls = [];
|
|
|
|
|
|
Http::fake(function ($request) use (&$calls) {
|
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
$key = match (true) {
|
|
|
|
|
|
str_contains($url, 'upload_csv_file') => 'segment_upload',
|
|
|
|
|
|
str_contains($url, '/confirm') => 'segment_confirm',
|
2026-07-30 15:07:49 +03:00
|
|
|
|
str_contains($url, '/v1/management/segments') => 'segments_list',
|
2026-07-28 12:36:34 +03:00
|
|
|
|
str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists',
|
2026-07-30 16:43:25 +03:00
|
|
|
|
// resume отделяем от add: «кампанию не создавали заново» и «показ включили» —
|
|
|
|
|
|
// это разные утверждения, а адрес у них один.
|
|
|
|
|
|
str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : 'campaigns'),
|
2026-07-28 12:36:34 +03:00
|
|
|
|
str_contains($url, '/json/v5/adgroups') => 'adgroups',
|
|
|
|
|
|
str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets',
|
|
|
|
|
|
str_contains($url, '/json/v5/creatives') => 'creatives',
|
2026-07-30 16:43:25 +03:00
|
|
|
|
str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'),
|
2026-07-28 12:36:34 +03:00
|
|
|
|
default => 'other',
|
|
|
|
|
|
};
|
|
|
|
|
|
$calls[$key] = ($calls[$key] ?? 0) + 1;
|
|
|
|
|
|
|
|
|
|
|
|
return match ($key) {
|
|
|
|
|
|
'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]),
|
|
|
|
|
|
'ads' => Http::response(['result' => ['AddResults' => [['Id' => 5561]]]]),
|
2026-07-30 16:43:25 +03:00
|
|
|
|
'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5561]]]]),
|
|
|
|
|
|
'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]),
|
2026-07-28 12:36:34 +03:00
|
|
|
|
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]);
|
|
|
|
|
|
$campaign->update([
|
|
|
|
|
|
'status' => AdCampaign::STATUS_DRAFT,
|
|
|
|
|
|
'revived_at' => now(),
|
|
|
|
|
|
'yandex_segment_id' => 900001,
|
|
|
|
|
|
'yandex_retargeting_list_id' => 111,
|
|
|
|
|
|
'yandex_campaign_id' => 222,
|
|
|
|
|
|
'yandex_ad_group_id' => 333,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
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(1);
|
|
|
|
|
|
|
|
|
|
|
|
$campaign->refresh();
|
|
|
|
|
|
expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION)
|
|
|
|
|
|
->and($campaign->yandex_campaign_id)->toBe(222)
|
|
|
|
|
|
->and($campaign->revived_at)->toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
expect(AdCampaignBanner::where('campaign_id', $campaign->id)->first()->yandex_ad_id)->toBe(5561);
|
|
|
|
|
|
});
|
2026-07-30 15:07:49 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 30.07.2026, живой бой. Сегмент дозрел, портал нажал «Запустить» — и Директ отказал:
|
|
|
|
|
|
* «Budget for this period cannot be less than 600 rub.». Приёмочная кампания была
|
|
|
|
|
|
* слишком маленькой: 1693 показа при цене 120 ₽ за тысячу дают Яндексу примерно 146 ₽.
|
|
|
|
|
|
*
|
|
|
|
|
|
* До этой проверки клиент видел сырой отказ Яндекса на английском и не понимал,
|
|
|
|
|
|
* что от него хотят. Проверяем ДО похода в Яндекс: в кабинете ничего не создаётся,
|
|
|
|
|
|
* деньги не морозятся, кампания остаётся черновиком.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('отказывает понятно и не трогает Яндекс, когда смета показов ниже минимума Директа', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
// Ровно та кампания, что стояла на бою.
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 1693]);
|
|
|
|
|
|
seedAudience($campaign, 200);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
|
|
|
|
|
|
->toThrow(BudgetBelowYandexMinimumException::class);
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
|
|
|
|
|
|
|
$campaign->refresh();
|
|
|
|
|
|
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT)
|
|
|
|
|
|
->and($campaign->yandex_segment_id)->toBeNull()
|
|
|
|
|
|
->and($campaign->yandex_campaign_id)->toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
|
|
|
|
|
expect($wallet->frozen_rub)->toBe('0.00');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** Исключение считает клиенту, сколько показов и рублей нужно — без единого слова про долю Яндекса. */
|
|
|
|
|
|
it('подсказывает клиенту минимальную смету показов и её цену', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
2026-07-30 16:43:25 +03:00
|
|
|
|
// Один день показа = период из двух календарных дней ⇒ минимум 2 × 300 = 600 ₽.
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 1693, 'run_days' => 1]);
|
2026-07-30 15:07:49 +03:00
|
|
|
|
seedAudience($campaign, 200);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
expect('запуск прошёл')->toBe('ожидали отказ по минимальному бюджету');
|
|
|
|
|
|
} catch (BudgetBelowYandexMinimumException $e) {
|
|
|
|
|
|
// 600 ₽ минимума ÷ (запас 1.2 × 72 ₽ за тысячу) = 6944.4 → округляем вверх.
|
|
|
|
|
|
expect($e->minImpressions)->toBe(6945)
|
|
|
|
|
|
->and($e->minClientRub)->toBe('833.40');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-30 16:43:25 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Минимум Директа растёт с длиной периода, и эта смета — ровно та, что прошла старую
|
|
|
|
|
|
* проверку на бою и всё равно получила английский отказ Яндекса.
|
|
|
|
|
|
*
|
|
|
|
|
|
* 6950 показов дают Яндексу 600.48 ₽ — постоянного порога в 600 ₽ хватало. Но кампания
|
|
|
|
|
|
* на неделю занимает 8 календарных дней, и Директ требует 8 × 300 = 2400 ₽.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('считает минимум по длине периода, а не одной постоянной цифрой', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 6950, 'run_days' => 7]);
|
|
|
|
|
|
seedAudience($campaign, 200);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
expect('запуск прошёл')->toBe('ожидали отказ: неделя требует 2400 ₽, а не 600 ₽');
|
|
|
|
|
|
} catch (BudgetBelowYandexMinimumException $e) {
|
|
|
|
|
|
// 2400 ₽ ÷ (запас 1.2 × 72 ₽ за тысячу) = 27777.7 → округляем вверх.
|
|
|
|
|
|
expect($e->minImpressions)->toBe(27778)
|
|
|
|
|
|
->and($e->minClientRub)->toBe('3333.36');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// В Яндекс не ходили вовсе: отказ рождается до первого запроса.
|
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-30 15:07:49 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Аудитория ЕСТЬ, но Яндекс ещё сводит номера с людьми. Готовность определяется
|
|
|
|
|
|
* ТОЛЬКО по can_create_dependent: статус is_processed — это «обрабатывается», не «готов».
|
|
|
|
|
|
*
|
|
|
|
|
|
* Раньше портал в этот момент лез создавать условие ретаргетинга и получал
|
|
|
|
|
|
* «объект не найден» — тот же отказ, что и на несуществующий сегмент.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('отказывает понятно, пока Яндекс не свёл номера с людьми', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
|
|
|
|
|
|
fakeYandexEndpoints(segmentReady: false);
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, ['yandex_segment_id' => 900001]);
|
|
|
|
|
|
seedAudience($campaign, 200);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fn () => app(CampaignLauncher::class)->launch($campaign))
|
|
|
|
|
|
->toThrow(AudienceNotReadyException::class);
|
|
|
|
|
|
|
|
|
|
|
|
// Главное: в Директ за условием ретаргетинга не полезли — именно там раньше
|
|
|
|
|
|
// приходил невнятный «объект не найден».
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'retargetinglists'));
|
|
|
|
|
|
|
|
|
|
|
|
$campaign->refresh();
|
|
|
|
|
|
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT)
|
|
|
|
|
|
->and($campaign->yandex_retargeting_list_id)->toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
|
|
|
|
|
expect($wallet->frozen_rub)->toBe('0.00');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** Готовый сегмент проверку проходит насквозь — она не должна мешать нормальному запуску. */
|
|
|
|
|
|
it('пропускает запуск, когда Яндекс отдаёт can_create_dependent', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, ['yandex_segment_id' => 900001]);
|
|
|
|
|
|
seedAudience($campaign, 200);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION);
|
|
|
|
|
|
});
|
2026-07-30 16:43:25 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Создать объявления — не значит запустить рекламу.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Яндекс кладёт всё созданное черновиком и проверку сам не начинает. Живая приёмка
|
|
|
|
|
|
* 30.07.2026: портал отрапортовал «на модерации», а в кабинете висели кампания, группа
|
|
|
|
|
|
* и 15 объявлений в DRAFT/OFF — показов не было бы никогда. Запуск обязан сам отдать
|
|
|
|
|
|
* объявления на проверку и включить показ.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('отдаёт объявления на модерацию и включает показ кампании', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id);
|
|
|
|
|
|
seedAudience($campaign, 200);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]);
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
// Отбор строго по Ids: на CampaignIds Директ отвечает «отсутствует обязательный
|
|
|
|
|
|
// параметр Ids» — именно так молча ломалась отправка на бою.
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
|
return str_contains($request->url(), '/json/v5/ads')
|
|
|
|
|
|
&& ($request->data()['method'] ?? null) === 'moderate'
|
|
|
|
|
|
&& ($request->data()['params']['SelectionCriteria']['Ids'] ?? []) === [555, 555];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
|
return str_contains($request->url(), '/json/v5/campaigns')
|
|
|
|
|
|
&& ($request->data()['method'] ?? null) === 'resume'
|
|
|
|
|
|
&& ($request->data()['params']['SelectionCriteria']['Ids'] ?? []) === [222];
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** Без модерации показ включать нельзя: черновик Директ включать отказывается. */
|
|
|
|
|
|
it('сначала отдаёт на модерацию и только потом включает показ', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id);
|
|
|
|
|
|
seedAudience($campaign, 200);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
$poryadok = [];
|
|
|
|
|
|
foreach (Http::recorded() as [$request]) {
|
|
|
|
|
|
$metod = $request->data()['method'] ?? null;
|
|
|
|
|
|
if ($metod === 'moderate' || $metod === 'resume') {
|
|
|
|
|
|
$poryadok[] = $metod;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
expect($poryadok)->toBe(['moderate', 'resume']);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** Повторный заход по уже созданным объявлениям на модерацию их НЕ переотправляет. */
|
|
|
|
|
|
it('при повторном запуске не отправляет на модерацию заново', function () {
|
|
|
|
|
|
configureYandex();
|
|
|
|
|
|
fakeYandexEndpoints();
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест');
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeImpressionCampaign($tenant->id, [
|
|
|
|
|
|
'yandex_segment_id' => 900001,
|
|
|
|
|
|
'yandex_retargeting_list_id' => 111,
|
|
|
|
|
|
'yandex_campaign_id' => 222,
|
|
|
|
|
|
'yandex_ad_group_id' => 333,
|
|
|
|
|
|
]);
|
|
|
|
|
|
seedAudience($campaign, 200);
|
|
|
|
|
|
seedBanners($campaign, ['300x250' => 4242]);
|
|
|
|
|
|
$campaign->banners()->update(['yandex_ad_id' => 999]);
|
|
|
|
|
|
|
|
|
|
|
|
app(CampaignLauncher::class)->launch($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => ($request->data()['method'] ?? null) === 'moderate');
|
|
|
|
|
|
});
|