2026-07-24 23:55:54 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Models\AdCampaign;
|
|
|
|
|
|
use App\Models\AdCampaignAd;
|
2026-07-27 14:33:02 +03:00
|
|
|
|
use App\Models\AdCampaignBanner;
|
2026-07-27 17:13:02 +03:00
|
|
|
|
use App\Models\AdCreativeJob;
|
2026-07-24 23:55:54 +03:00
|
|
|
|
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' => 'После правки',
|
2026-07-26 15:45:53 +03:00
|
|
|
|
'budget_rub' => '2000.00',
|
2026-07-24 23:55:54 +03:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$response->assertOk()
|
|
|
|
|
|
->assertJsonPath('name', 'После правки')
|
2026-07-26 15:45:53 +03:00
|
|
|
|
->assertJsonPath('budget_rub', '2000.00');
|
2026-07-24 23:55:54 +03:00
|
|
|
|
|
|
|
|
|
|
$this->assertDatabaseHas('ad_campaigns', [
|
|
|
|
|
|
'id' => $campaign->id,
|
|
|
|
|
|
'name' => 'После правки',
|
2026-07-26 15:45:53 +03:00
|
|
|
|
'budget_rub' => '2000.00',
|
2026-07-24 23:55:54 +03:00
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-27 15:54:31 +03: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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-27 09:12:50 +03:00
|
|
|
|
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']);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-24 23:55:54 +03: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 () {
|
2026-07-26 21:13:20 +03:00
|
|
|
|
// T3: список считается только в mode=manual (auto его больше не подмешивает) —
|
|
|
|
|
|
// передаём mode=manual в запросе, как это делает мастер.
|
2026-07-24 23:55:54 +03:00
|
|
|
|
$campaign = AdCampaign::create([
|
|
|
|
|
|
'tenant_id' => $this->tenant->id,
|
|
|
|
|
|
'name' => 'Большая аудитория',
|
|
|
|
|
|
'audience_days' => 10,
|
|
|
|
|
|
'use_uploaded_list' => true,
|
|
|
|
|
|
'weekly_budget_rub' => '1000.00',
|
|
|
|
|
|
]);
|
|
|
|
|
|
seedCampaignPhones($campaign, 150);
|
|
|
|
|
|
|
2026-07-26 21:13:20 +03:00
|
|
|
|
$response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10&mode=manual");
|
2026-07-24 23:55:54 +03:00
|
|
|
|
|
|
|
|
|
|
$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',
|
2026-07-27 00:21:17 +03:00
|
|
|
|
// Медийные поля — заполнены, чтобы дойти именно до проверки аудитории (не оборваться раньше).
|
|
|
|
|
|
'estimated_impressions' => 10000,
|
|
|
|
|
|
'landing_url' => 'https://liderra.ru',
|
2026-07-24 23:55:54 +03:00
|
|
|
|
]);
|
2026-07-27 14:33:02 +03:00
|
|
|
|
// Набор баннеров с номерами креативов — иначе запуск оборвётся на проверке набора,
|
|
|
|
|
|
// а не на аудитории (адаптивного креатива у медийной кампании больше нет).
|
|
|
|
|
|
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,
|
|
|
|
|
|
]);
|
2026-07-24 23:55:54 +03:00
|
|
|
|
|
|
|
|
|
|
$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);
|
|
|
|
|
|
});
|
2026-07-27 17:13:02 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|