9488075076
Живой запуск кампании на бою 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>
953 lines
39 KiB
PHP
953 lines
39 KiB
PHP
<?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();
|
||
});
|