Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php
T
Дмитрий 9234a9c2bc feat реклама показы: очередь заданий робота-грузчика, служебный канал и постановка при запуске
Портал ставит роботу задание, когда у баннеров ещё нет номеров креативов: вместо
ошибки клиент видит «готовим картинки», кампания остаётся черновиком, деньги не
морозятся. Робот берёт задания строго по одному — иначе слепки креативов до и
после перемешаются, и опознать их будет нельзя.

Канал робота закрыт своим сервис-токеном, внесён в исключения проверки CSRF и
отдаёт файл только того задания, которое сейчас в работе. Постановка задания
стоит внутри проверки рубильника Директа — при выключенном рубильнике портал в
Яндекс не ходит.

Права на новую таблицу выданы роли crm_admin_user: канал идёт через посредник
admin-db, подменяющий подключение. Нумератор выдан crm_app_user — он единственный
вставляет строки. Журнал схемы — запись v9.06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:13:02 +03:00

580 lines
21 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 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('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);
});
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);
});