Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php
T
Дмитрий d9f22d1353 fix тесты: убрана грязь между тестами и закрыт выход в интернет из прогона
Полный набор стал зелёным целиком: 4063 теста, 0 падений и 0 ошибок против
36 непроходящих до правок. Рабочий код не тронут - изменены только тесты.

Корень у пяти правок один: тест зелёный, а после себя оставляет мусор в базе,
и падают соседи. Три файла рекламы писали набело, без отката. Их записи -
задание роботу и проекты без источника лидов - переживали тест, и дальше
в том же прогоне их подбирали чужие проверки. Отсюда 16 падений в файле
про робота креативов и 13 в файлах про ночной слепок.

Тестам запрещён выход в интернет - Http::preventStrayRequests в TestCase.
До этого прогон физически ходил в живой кабинет Яндекс Директа, а поломка
маскировалась под "Invalid OAuth token", хотя ключ в тесте подставной.
Сторож принят вырезанием: со снятой правкой та же поломка называет себя
честно, с адресом запроса.

Он же вскрыл, что ProjectRuleNotificationTest слал живой запрос на удаление
проекта в кабинет поставщика crm.bp-gr.ru, оставаясь зелёным - ответ кабинета
тест не проверяет. Поставлена заглушка.

ExternalServiceDownAlertTest избавлен от зависимости от сети - закрыт хвост,
тянувшийся с 30.07: падал в общем прогоне, проходил в одиночку.

InAppNotificationTest брал первую попавшуюся запись во всей таблице вместо
своей. SalesOverviewTest попадал в топ-4 клиентов по удаче: при запросе
всего отдела отбор не ограничен ничем, все тенанты базы с нулём лидов равны.
Клиенту даны настоящие лиды - место в четвёрке заслуженное.

Проверено: полный прогон 4063/4059 зелёный, статанализ 0, код-стиль моих
файлов чисто. На бой ничего не выкачено.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:29:10 +03:00

963 lines
40 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\Foundation\Testing\DatabaseTransactions;
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 — каждый тест
* создаёт свой Tenant::factory()->create().
*
* 🔴 DatabaseTransactions обязателен, хотя файл и без него был зелёным. Заливка
* картинок ставит задание в `ad_creative_jobs`, и без отката задание ПЕРЕЖИВАЕТ тест.
* Дальше в том же прогоне CreativeRobotEndpointTest («очередь пуста — работы нет»)
* подбирает это чужое задание, портал идёт за слепком креативов в ЖИВОЙ Яндекс и
* получает отказ авторизации — 16 падений в чужом файле при 40/40 зелёных здесь.
* Найдено 01.08.2026 сужением: файл оставлял после себя 2 задания в статусе queued.
*/
uses(DatabaseTransactions::class);
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();
});