Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php
T
Дмитрий 83c8eec63b feat(реклама показы): запуск медийной кампании через Директ + адрес сайта и id объявления
Задача 7 плана «реклама за показы». CampaignLauncher переписан под медийную
кампанию CPM: дешёвые проверки до трат денег рубильник, номер креатива, адрес
сайта, смета показов, затем аудитория не меньше 100, деньги через bcmath с
наценкой, цепочка сегмент → retargeting → CPM-кампания → CPM-группа →
медиа-таргет → баннер по готовому креативу, заморозка клиентской суммы,
статус на модерации.

Колонки ad_campaigns.landing_url и yandex_ad_id, модель, журнал схемы v9.02.
Наценка и yandex_cost_rub клиенту не видны. Реклама-модуль 190/190 зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 00:21:17 +03:00

331 lines
12 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\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('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,
'yandex_creative_id' => 4242,
'landing_url' => 'https://liderra.ru',
]);
$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);
});