Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php
T
Дмитрий 9488075076 fix реклама: минимум площадки считается по длине периода, а созданные объявления сами уходят на модерацию
Живой запуск кампании на бою 30.07.2026 вскрыл две поломки, которых вчерашняя
починка не видела. Обе — из класса молчаливых: портал рапортует успех, а в жизни
ничего не происходит.

ПЕРВАЯ. Минимум Директа НЕ постоянный.

Вчера мы приняли живой отказ «Budget for this period cannot be less than 600 rub.»
за постоянный порог и зашили 600 ₽ в настройку. Сегодня та же кампания на неделю
получила отказ «cannot be less than 2400 rub.» — проверка её пропустила, и клиент
снова увидел английский текст.

Две точки дали правило: минимум = 300 ₽ за каждый календарный день периода,
считая оба края.
   период 30.07-31.07, 2 дня → 600 ₽  = 2 × 300
   период 30.07-06.08, 8 дней → 2400 ₽ = 8 × 300

Теперь порог считается от периода показа, а ставка за день вынесена в настройку
YANDEX_DIRECT_MIN_SPEND_RUB_PER_DAY. Период вычисляется ДО денег — иначе считать
минимум не от чего.

ВТОРАЯ. Создать объявления — не значит запустить рекламу.

Запуск проходил успешно, портал ставил статус «на модерации», а в кабинете лежали
кампания, группа и 15 объявлений в состоянии DRAFT и OFF. Яндекс кладёт всё
созданное черновиком и проверку сам не начинает. Реклама не показалась бы никогда,
и узнать об этом можно было только глазами в кабинете: наш журнал говорил, что всё
хорошо.

Добавлены два вызова, которых не было вовсе:
   ads.moderate     — отдать созданные объявления на проверку
   campaigns.resume — включить показ

Порядок обязателен. Пока кампания черновик, включить её нельзя — Директ отвечает
«кампания является черновиком и не может быть остановлена». Сначала модерация,
она переводит кампанию в MODERATION, и только потом включение.

Отбор объявлений строго по Ids. На CampaignIds Директ отвечает «отсутствует
обязательный параметр Ids» — именно так отправка молча не сработала бы.

На модерацию уходят объявления, созданные ИМЕННО этим заходом: повторная отправка
уже проверяемого — отказ по позиции, он порвал бы возобновляемый запуск. Включение
показа безобидно при любом повторе: на включённой кампании Яндекс отвечает
предупреждением, а не отказом.

Заодно клиент Директа перестал молчать про отказы внутри ответа: раньше он смотрел
только AddResults, UpdateResults и DeleteResults, теперь ещё ModerateResults и
ResumeResults. Отказ по позиции в этих двух проходил бы насквозь незамеченным.

ПРОВЕРЕНО ЖИВЬЁМ. Кампания № 6 на бою: в кабинете 713175197, группа 5778555449,
15 объявлений, аудитория подключена, статус MODERATION, показ включён. На счету
Яндекса 8775 ₽. У клиента заморожено 3333.36 ₽ за 27778 показов до 06.08.

Тесты: 346 зелёных по рекламе, 43 по клиенту Директа, статанализ ноль замечаний.
Четыре новых теста — минимум по длине периода на боевом случае, отправка на
модерацию, порядок модерация-до-включения, отсутствие повторной отправки.
Два старых теста закрепляли частный случай в два дня — им проставлен явный срок
показа, иначе они молча описывали бы неправду.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:43:25 +03:00

953 lines
39 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignAd;
use App\Models\AdCampaignBanner;
use App\Models\AdCreativeJob;
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\CreativeJobService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
/**
* HTTP API кампаний Директа для клиентского портала (Часть A, Task 12).
* Слой над готовыми сервисами CampaignAudienceBuilder/CampaignLauncher/CreativeValidator.
*
* Мирроим auth/tenant setup из AdvertisingWalletEndpointTest.php — без RefreshDatabase,
* каждый тест создаёт свой Tenant::factory()->create().
*/
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
function seedCampaignPhones(AdCampaign $campaign, int $count): void
{
$rows = [];
for ($i = 0; $i < $count; $i++) {
$rows[] = [
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'phone' => sprintf('798800%05d', $i),
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
];
}
DB::table('ad_campaign_phones')->insert($rows);
}
it('returns 401 without auth', function () {
auth()->logout();
$this->getJson('/api/advertising/campaigns')->assertStatus(401);
});
it('creates a draft campaign with valid body', function () {
$response = $this->postJson('/api/advertising/campaigns', [
'name' => 'Кампания А',
'audience_days' => 10,
'weekly_budget_rub' => '1500.00',
]);
$response->assertCreated();
$campaignId = $response->json('id');
expect($campaignId)->not->toBeNull();
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaignId,
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_DRAFT,
]);
});
it('rejects an invalid body with 422', function () {
$response = $this->postJson('/api/advertising/campaigns', [
'name' => '',
'audience_days' => 0,
'weekly_budget_rub' => '1500.00',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'audience_days']);
});
it('lists tenant campaigns and shows one with ads and spent_rub', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания B',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$index = $this->getJson('/api/advertising/campaigns');
$index->assertOk();
expect(collect($index->json('data'))->pluck('id')->all())->toContain($campaign->id);
$show = $this->getJson("/api/advertising/campaigns/{$campaign->id}");
$show->assertOk()
->assertJsonPath('campaign.id', $campaign->id)
->assertJsonPath('ads', [])
->assertJsonPath('spent_rub', '0.00');
});
it('sums charge transactions as spent_rub', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания расход',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '1000.00', 'frozen_rub' => '0.00']);
AdWalletTransaction::create([
'tenant_id' => $this->tenant->id,
'type' => AdWalletTransaction::TYPE_CHARGE,
'amount_rub' => '-260.00',
'balance_rub_after' => '740.00',
'channel' => 'yandex',
'related_type' => 'campaign',
'related_id' => $campaign->id,
'external_key' => 'yandex:'.$campaign->id.':2026-07-20',
'created_at' => now(),
]);
AdWalletTransaction::create([
'tenant_id' => $this->tenant->id,
'type' => AdWalletTransaction::TYPE_CHARGE,
'amount_rub' => '-40.00',
'balance_rub_after' => '700.00',
'channel' => 'yandex',
'related_type' => 'campaign',
'related_id' => $campaign->id,
'external_key' => 'yandex:'.$campaign->id.':2026-07-21',
'created_at' => now(),
]);
$show = $this->getJson("/api/advertising/campaigns/{$campaign->id}");
$show->assertOk()->assertJsonPath('spent_rub', '300.00');
});
it('updates a campaign partially', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'До правки',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
'name' => 'После правки',
'budget_rub' => '2000.00',
]);
$response->assertOk()
->assertJsonPath('name', 'После правки')
->assertJsonPath('budget_rub', '2000.00');
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaign->id,
'name' => 'После правки',
'budget_rub' => '2000.00',
]);
});
it('refuses to change the impression budget once the campaign is created in Yandex', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Уже в Яндексе',
'audience_days' => 10,
'estimated_impressions' => 100000,
'client_cpm_rub' => '120.00',
'landing_url' => 'https://liderra.ru/promo',
'yandex_campaign_id' => 222,
]);
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
'estimated_impressions' => 5000,
'client_cpm_rub' => '10.00',
]);
$response->assertStatus(409);
expect($response->json('message'))->not->toBeNull();
// Смета в базе НЕ изменилась — иначе портал показывал бы одни числа, а Яндекс крутил другие.
$campaign->refresh();
expect($campaign->estimated_impressions)->toBe(100000)
->and($campaign->client_cpm_rub)->toBe('120.00');
});
it('refuses to change landing_url once the campaign is created in Yandex', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Уже в Яндексе, адрес',
'audience_days' => 10,
'landing_url' => 'https://liderra.ru/promo',
'yandex_campaign_id' => 222,
]);
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
'landing_url' => 'https://liderra.ru/promo-new',
]);
$response->assertStatus(409);
$campaign->refresh();
expect($campaign->landing_url)->toBe('https://liderra.ru/promo');
});
/**
* Замок стоял только по номеру кампании Директа, а сегмент Яндекс.Аудиторий создаётся
* РАНЬШЕ неё. Обрыв запуска в этом окне (сегмент уже есть, кампании ещё нет) оставлял
* настройки аудитории открытыми: клиент менял срок сбора или список номеров, портал
* показывал новое, а возобновлённый запуск переиспользовал СТАРЫЙ сегмент — реклама
* шла по прежним телефонам.
*/
it('refuses to change audience settings once the Yandex audience segment exists', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Сегмент уже создан',
'audience_days' => 10,
'estimated_impressions' => 100000,
'yandex_segment_id' => 900001,
// Кампании в Директе ещё нет — запуск оборвался ровно между сегментом и кампанией.
'yandex_campaign_id' => null,
]);
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
'audience_days' => 30,
]);
$response->assertStatus(409);
$campaign->refresh();
expect($campaign->audience_days)->toBe(10);
});
it('still allows renaming a campaign whose audience segment exists', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Старое имя при сегменте',
'audience_days' => 10,
'yandex_segment_id' => 900001,
]);
$this->patchJson("/api/advertising/campaigns/{$campaign->id}", ['name' => 'Новое имя'])
->assertOk()->assertJsonPath('name', 'Новое имя');
});
it('still allows renaming a campaign that is already created in Yandex', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Старое имя',
'audience_days' => 10,
'estimated_impressions' => 100000,
'yandex_campaign_id' => 222,
]);
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
'name' => 'Новое имя',
]);
$response->assertOk()->assertJsonPath('name', 'Новое имя');
$campaign->refresh();
expect($campaign->name)->toBe('Новое имя')
->and($campaign->estimated_impressions)->toBe(100000);
});
it('still allows editing the impression budget of a plain draft with nothing in Yandex', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Обычный черновик',
'audience_days' => 10,
'estimated_impressions' => 1000,
]);
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
'estimated_impressions' => 5000,
]);
$response->assertOk();
$campaign->refresh();
expect($campaign->estimated_impressions)->toBe(5000);
});
it('creates a campaign with a valid landing_url and returns it', function () {
$response = $this->postJson('/api/advertising/campaigns', [
'name' => 'Кампания с адресом',
'audience_days' => 10,
'landing_url' => 'https://liderra.ru/promo',
]);
$response->assertCreated()
->assertJsonPath('landing_url', 'https://liderra.ru/promo');
$this->assertDatabaseHas('ad_campaigns', [
'id' => $response->json('id'),
'landing_url' => 'https://liderra.ru/promo',
]);
});
it('rejects an invalid landing_url with 422 on store', function () {
$response = $this->postJson('/api/advertising/campaigns', [
'name' => 'Кампания с плохим адресом',
'audience_days' => 10,
'landing_url' => 'не-адрес',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['landing_url']);
});
it('updates landing_url on an existing campaign', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания под адрес',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
'landing_url' => 'https://liderra.ru/promo2',
]);
$response->assertOk()->assertJsonPath('landing_url', 'https://liderra.ru/promo2');
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaign->id,
'landing_url' => 'https://liderra.ru/promo2',
]);
});
it('rejects an invalid landing_url with 422 on update', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания под плохой адрес',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
'landing_url' => 'не-адрес',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['landing_url']);
});
it('reports a small audience as not enough with a hint', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Малая аудитория',
'audience_days' => 10,
'use_uploaded_list' => false,
'weekly_budget_rub' => '1000.00',
]);
$response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10");
$response->assertOk()
->assertJsonPath('min', 100)
->assertJsonPath('enough', false);
expect($response->json('size'))->toBeLessThan(100);
expect($response->json('hint'))->not->toBeNull();
});
it('reports a large audience as enough with no hint', function () {
// T3: список считается только в mode=manual (auto его больше не подмешивает) —
// передаём mode=manual в запросе, как это делает мастер.
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Большая аудитория',
'audience_days' => 10,
'use_uploaded_list' => true,
'weekly_budget_rub' => '1000.00',
]);
seedCampaignPhones($campaign, 150);
$response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10&mode=manual");
$response->assertOk()
->assertJsonPath('enough', true)
->assertJsonPath('hint', null);
expect($response->json('size'))->toBeGreaterThanOrEqual(100);
});
it('rejects a bad ad creative with 422 errors.title', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания под объявление',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads", [
'title' => str_repeat('а', 60),
'text' => 'Обычный текст объявления',
'href' => 'https://liderra.ru',
]);
$response->assertStatus(422);
expect($response->json('errors.title'))->not->toBeNull();
});
it('creates a valid ad creative', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания под объявление 2',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads", [
'title' => 'Хороший заголовок',
'text' => 'Хороший текст объявления в пределах нормы',
'href' => 'https://liderra.ru',
]);
$response->assertCreated();
$this->assertDatabaseHas('ad_campaign_ads', [
'campaign_id' => $campaign->id,
'tenant_id' => $this->tenant->id,
'title' => 'Хороший заголовок',
]);
});
it('returns 422 with a message when launching with a too-small audience', function () {
config(['services.yandex_direct.enabled' => true]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Запуск малая аудитория',
'audience_days' => 10,
'use_uploaded_list' => false,
'weekly_budget_rub' => '1000.00',
// Медийные поля — заполнены, чтобы дойти именно до проверки аудитории (не оборваться раньше).
'estimated_impressions' => 10000,
'landing_url' => 'https://liderra.ru',
]);
// Набор баннеров с номерами креативов — иначе запуск оборвётся на проверке набора,
// а не на аудитории (адаптивного креатива у медийной кампании больше нет).
AdCampaignBanner::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
'yandex_creative_id' => 4242,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch");
$response->assertStatus(422);
expect($response->json('message'))->not->toBeNull();
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
});
it('uploads a valid ad image and stores the returned hash', function () {
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
config(['services.yandex_direct.token' => 'DIRTOKEN']);
Http::fake(['*/json/v501/adimages' => Http::response(['result' => ['AddResults' => [['AdImageHash' => 'HASH123']]]])]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания под картинку',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$ad = AdCampaignAd::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'title' => 'Заголовок',
'text' => 'Текст объявления',
'href' => 'https://liderra.ru',
'moderation_status' => 'draft',
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [
'file' => UploadedFile::fake()->image('creative.jpg', 500, 500),
]);
$response->assertOk()->assertJsonPath('hash', 'HASH123');
$this->assertDatabaseHas('ad_campaign_ads', [
'id' => $ad->id,
'image_normal_hash' => 'HASH123',
]);
});
it('rejects an ad image upload when yandex_direct is disabled', function () {
config(['services.yandex_direct.enabled' => false]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания под картинку 2',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$ad = AdCampaignAd::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'title' => 'Заголовок',
'text' => 'Текст объявления',
'href' => 'https://liderra.ru',
'moderation_status' => 'draft',
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [
'file' => UploadedFile::fake()->image('creative.jpg', 500, 500),
]);
$response->assertStatus(409);
});
it('isolates tenants: user of tenant A cannot see tenant B campaign', function () {
$tenantB = Tenant::factory()->create();
$campaignB = AdCampaign::create([
'tenant_id' => $tenantB->id,
'name' => 'Чужая кампания',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
]);
$response = $this->getJson("/api/advertising/campaigns/{$campaignB->id}");
$response->assertStatus(404);
});
it('queues a creative job instead of failing when banners have no creative numbers', function () {
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.token' => 'T']);
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Запуск без креативов',
'audience_days' => 10,
'use_uploaded_list' => false,
'estimated_impressions' => 10000,
'landing_url' => 'https://liderra.ru',
]);
AdCampaignBanner::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
// yandex_creative_id намеренно НЕ задан — робот его ещё не привёз.
]);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")
->assertStatus(202)
->assertJsonPath('status', 'creatives_pending');
expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1)
->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
});
it('does not queue a second creative job on repeated launch', function () {
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.token' => 'T']);
config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']);
Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Повторный запуск без креативов',
'audience_days' => 10,
'use_uploaded_list' => false,
'estimated_impressions' => 10000,
'landing_url' => 'https://liderra.ru',
]);
AdCampaignBanner::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
]);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(202);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(202);
expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1);
});
/**
* Постановка задания роботу может не удаться (упала база, лёг Яндекс — исторически она
* ходила туда за слепком). Раньше клиент получал голый 500 «что-то пошло не так»:
* непонятно, виноват ли он, надо ли заливать картинки заново, стоит ли пробовать ещё раз.
* Ответ должен быть человеческим и означать «это не вы, попробуйте позже».
*/
it('answers politely instead of a bare error when the creative job cannot be queued', function () {
config(['services.yandex_direct.enabled' => true]);
Http::fake();
// Постановку задания роняем изнутри: причина не важна, важно, что клиент не увидит
// голого 500 ни при какой поломке.
$this->app->bind(CreativeJobService::class, function () {
throw new RuntimeException('очередь недоступна');
});
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Очередь легла',
'audience_days' => 10,
'use_uploaded_list' => false,
'estimated_impressions' => 10000,
'landing_url' => 'https://liderra.ru',
]);
AdCampaignBanner::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
]);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")
->assertStatus(503)
->assertJsonPath('status', 'yandex_unavailable');
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
});
/**
* 🪤 Мина той же породы, что уже ловилась в проекте: `config(...) === false`.
*
* Значение приходит из `env('YANDEX_DIRECT_ENABLED', false)`. Стоит написать в `.env`
* `YANDEX_DIRECT_ENABLED=0` — и Laravel вернёт строку «0», которая рубильником читается
* как «выключено», а сравнением `=== false` — как «включено». Запрос ушёл бы в живой
* Яндекс при выключенном рубильнике. Все остальные места проверяют через `! config(...)`.
*/
it('treats a string switch value as off, not on', function () {
config(['services.yandex_direct.enabled' => '0']);
Http::fake();
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Рубильник строкой',
'audience_days' => 10,
'use_uploaded_list' => false,
]);
$ad = AdCampaignAd::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'title' => 'Заголовок',
'text' => 'Текст объявления',
'href' => 'https://liderra.ru',
]);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [
'file' => UploadedFile::fake()->image('b.jpg', 1080, 607),
])->assertStatus(409);
Http::assertNothingSent();
});
it('does not touch yandex at all when the direct switch is off', function () {
config(['services.yandex_direct.enabled' => false]);
Http::fake();
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Рубильник выключен',
'audience_days' => 10,
'use_uploaded_list' => false,
'estimated_impressions' => 10000,
'landing_url' => 'https://liderra.ru',
]);
AdCampaignBanner::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
]);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(409);
Http::assertNothingSent();
expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0);
});
/**
* Красный ярлык «Отклонено» без единого слова объяснения — то, что клиент видел
* до 28.07.2026. Причина лежала в базе и никуда не отдавалась.
*
* Наценке в клиентском ответе места нет ни при каких обстоятельствах.
*/
it('в списке кампаний у отклонённой видна причина, а наценки нет', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false,
'status' => AdCampaign::STATUS_REJECTED, 'moderation_reason' => 'Изображение не подошло',
]);
$res = $this->actingAs($user)->getJson('/api/advertising/campaigns');
$res->assertOk();
// Ответ index() — обёртка {"data": [...]}, проверено по коду 28.07.2026.
$row = collect($res->json('data'))->firstWhere('id', $campaign->id);
expect($row['moderation_reason'])->toBe('Изображение не подошло');
expect($res->getContent())->not->toContain('yandex_cost_rub')
->and($res->getContent())->not->toContain('ad_margin_percent');
});
/** Кампания заданного статуса, уже заведённая в Яндексе, со своим пользователем. */
function campaignInYandex(string $status): array
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false,
'status' => $status,
'yandex_campaign_id' => 555, 'yandex_ad_group_id' => 666, 'yandex_segment_id' => 777,
]);
AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true,
'path' => 'ad-banners/a/300x250.jpg',
'yandex_creative_id' => 100, 'yandex_ad_id' => 200,
'moderation_status' => AdCampaignBanner::MOD_REJECTED,
]);
return [$tenant, $user, $campaign];
}
it('клиент оживляет свою отклонённую кампанию', function () {
config(['services.yandex_direct.enabled' => false]);
[, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED);
$res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive");
$res->assertOk();
expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
});
it('оживить работающую кампанию нельзя', function () {
config(['services.yandex_direct.enabled' => false]);
[, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_RUNNING);
$this->actingAs($user)
->postJson("/api/advertising/campaigns/{$campaign->id}/revive")
->assertStatus(409);
expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
});
/**
* Двойной щелчок по «Исправить». Второй заход видит кампанию уже черновиком — и обязан
* получить отказ, а не пройти второй раз по чистке номеров: он стёр бы номера объявлений,
* которые к тому моменту мог создать новый запуск.
*/
it('второе нажатие Исправить получает отказ', function () {
config(['services.yandex_direct.enabled' => false]);
[, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED);
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertOk();
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertStatus(409);
});
it('чужую кампанию оживить нельзя', function () {
config(['services.yandex_direct.enabled' => false]);
[, , $campaignA] = campaignInYandex(AdCampaign::STATUS_REJECTED);
[, $userB] = campaignInYandex(AdCampaign::STATUS_REJECTED);
$this->actingAs($userB)
->postJson("/api/advertising/campaigns/{$campaignA->id}/revive")
->assertStatus(404);
expect($campaignA->refresh()->status)->toBe(AdCampaign::STATUS_REJECTED);
});
it('у отклонённой кампании правка настроек разрешена', function () {
[, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED);
$this->actingAs($user)
->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new'])
->assertOk();
});
/** 🔑 Исключение не должно протечь на работающую рекламу — она крутится за деньги клиента. */
it('у работающей кампании правка настроек по-прежнему заперта', function () {
[, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_RUNNING);
$this->actingAs($user)
->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new'])
->assertStatus(409);
});
/**
* 🔴 Поймано живой проверкой в браузере 28.07.2026, все тесты при этом были зелёные.
*
* «Исправить» возвращает кампанию в черновик — и в ту же секунду исключение из замка
* перестаёт действовать: оно написано по статусу «отклонено», а статуса уже нет.
* Клиент оказывался в том же тупике, только шагом дальше: мастер открылся, а сервер
* не даёт ни сменить адрес сайта, ни перезалить картинку.
*
* Признак «отдана на починку» обязан пережить возврат в черновик.
*/
it('после нажатия Исправить правка настроек разрешена', function () {
config(['services.yandex_direct.enabled' => false]);
[, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED);
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertOk();
$this->actingAs($user)
->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new'])
->assertOk();
});
/**
* Оборвавшийся запуск оставляет черновик, у которого в Яндексе уже есть кампания, группа
* и сегмент — его правку замок обязан держать по-прежнему. Иначе клиент поменяет срок
* сбора, а возобновление переиспользует СТАРЫЙ сегмент, и реклама пойдёт по прежним номерам.
*/
it('черновик с оборвавшимся запуском по-прежнему заперт', function () {
[, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_DRAFT);
$this->actingAs($user)
->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new'])
->assertStatus(409);
});
/**
* 30.07.2026, живой бой. Сегмент существовал, но Яндекс ещё сводил номера с людьми,
* и портал в этот момент лез в Директ за условием ретаргетинга. Директ отвечал
* «объект не найден» — тем же отказом, что и на несуществующий сегмент. Клиент читал
* непонятное и думал, что сломался он.
*
* Теперь это «подождите», а не ошибка: тот же 202, что и на ожидание картинок.
*/
it('отвечает «подождите», пока рекламная система готовит аудиторию', function () {
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
config(['services.yandex_direct.token' => 'DIRTOKEN']);
config(['services.yandex_audience.token' => 'AUDTOKEN']);
Http::fake([
'*/v1/management/segments' => Http::response(['segments' => [
['id' => 900001, 'name' => 'C', 'status' => 'is_processed', 'can_create_dependent' => false],
]]),
]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Аудитория ещё готовится',
'audience_days' => 10,
'mode' => AdCampaign::MODE_MANUAL,
'use_uploaded_list' => true,
'estimated_impressions' => 10000,
// Срок показа задан явно: минимум площадки растёт с длиной периода, и без этого
// проверка сметы сработала бы раньше — до вопроса о готовности аудитории.
'run_days' => 1,
'client_cpm_rub' => '120.00',
'landing_url' => 'https://liderra.ru',
'yandex_segment_id' => 900001,
]);
seedCampaignPhones($campaign, 150);
AdCampaignBanner::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
'yandex_creative_id' => 4242,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch");
$response->assertStatus(202)->assertJsonPath('status', 'audience_pending');
// Человеческий текст: без «ретаргетинга» и «сегмента».
$text = (string) $response->json('message');
expect($text)->not->toContain('ретаргетинг')
->and($text)->not->toContain('сегмент');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT)
->and($campaign->yandex_retargeting_list_id)->toBeNull();
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'retargetinglists'));
});
/**
* Живой отказ Директа 30.07.2026: «Budget for this period cannot be less than 600 rub.».
* Клиент видел его как есть — по-английски, про чужие 600 ₽, без подсказки, что делать.
*
* Теперь портал считает сам и говорит клиенту его же числами. Доли рекламной площадки
* в ответе быть не должно: по паре «минимум площадки / цена клиенту» наценка вычисляется делением.
*/
it('объясняет клиенту, какая смета нужна, вместо английской ошибки про 600 рублей', function () {
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
config(['services.yandex_direct.token' => 'DIRTOKEN']);
config(['services.yandex_audience.token' => 'AUDTOKEN']);
Http::fake();
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Смета мала',
'audience_days' => 10,
'mode' => AdCampaign::MODE_MANUAL,
'use_uploaded_list' => true,
// Ровно та кампания, что стояла на бою.
'estimated_impressions' => 1693,
// Один день показа = период из двух календарных дней ⇒ минимум площадки 600 ₽.
// Порог растёт с длиной периода, поэтому срок здесь задан явно.
'run_days' => 1,
'client_cpm_rub' => '120.00',
'landing_url' => 'https://liderra.ru',
]);
seedCampaignPhones($campaign, 150);
AdCampaignBanner::create([
'tenant_id' => $this->tenant->id,
'campaign_id' => $campaign->id,
'width' => 300,
'height' => 250,
'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000,
'included' => true,
'yandex_creative_id' => 4242,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch");
$response->assertStatus(422)->assertJsonPath('status', 'budget_too_small');
$text = (string) $response->json('message');
expect($text)->toContain('6945') // сколько показов нужно
->and($text)->toContain('833.40') // во сколько это обойдётся клиенту
->and($text)->not->toContain('600'); // доля площадки наружу не уходит
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT);
Http::assertNothingSent();
});