Files
portal/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php
T
Дмитрий 5a4c0e0235 feat(реклама): показы — баннеры клиента по размерам, два режима аудитории, клиентская цена и наценка
Часть 5c — баннеры: клиент грузит свой готовый файл на каждый из 15 размеров вместо автогенерации из одной картинки. Частичное утверждение флагом included, замена и удаление отдельного баннера, валидация точного размера и веса. Админ-поле цены за 1000 показов. Пример CSV для скачивания и подъём лимита загрузки.

Часть 5d — два режима сбора аудитории. Авто: скользящее окно, обновляется ежедневно, только контакты системы. Ручной: снимок сделок за период плюс свой список номеров и срок показа. Клиент сам задаёт цену за 1000 показов с дефолтом из админки. Наценка настраивается в админке, по умолчанию 40 процентов, в Директ уходит меньше, клиенту не видна нигде.

Миграции: ad_campaign_banners += included; ad_campaigns += mode/snapshot_from/snapshot_to/run_days/client_cpm_rub; ad_settings += ad_margin_percent. RLS-ревью PASS на всех миграциях. Backend 166 тестов, фронт 123 теста, сборка чистая. Маржа и yandex_cost_rub клиенту не сериализуются.

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

142 lines
5.2 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\Jobs\SyncCampaignAudienceJob;
use App\Models\AdCampaign;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
// Джоб намеренно обходит running/pending-кампании ВСЕХ тенантов — без изоляции
// между кейсами кампания из одного теста осталась бы видна следующему (проверка
// "пустая аудитория ⇒ ни одного обращения" иначе ловила бы чужой сегмент).
uses(RefreshDatabase::class);
// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
// второму соединению до commit'а.
uses(SharesSupplierPdo::class);
it('replace-заливает аудиторию активной кампании в Яндекс.Аудитории', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_audience.token' => 'AUDTOKEN',
]);
Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]);
$tenant = Tenant::factory()->create();
// T3: свой список считается только в mode=manual (auto его игнорирует).
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания ночной заливки',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_segment_id' => 900001,
'weekly_budget_rub' => '500.00',
'mode' => AdCampaign::MODE_MANUAL,
'audience_days' => 10,
'use_uploaded_list' => true,
]);
DB::table('ad_campaign_phones')->insert([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000001',
'expires_at' => now()->addDays(5),
'created_at' => now(),
'updated_at' => now(),
]);
app(SyncCampaignAudienceJob::class)->handle();
Http::assertSent(function ($request) {
return str_contains($request->url(), 'modify_data')
&& str_contains($request->url(), 'replace');
});
});
it('рубильник выключен — ни одного обращения к Яндексу', function () {
config([
'services.yandex_direct.enabled' => false,
'services.yandex_audience.token' => 'AUDTOKEN',
]);
Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]);
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания при выключенном рубильнике',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_segment_id' => 900002,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
DB::table('ad_campaign_phones')->insert([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000002',
'expires_at' => now()->addDays(5),
'created_at' => now(),
'updated_at' => now(),
]);
app(SyncCampaignAudienceJob::class)->handle();
Http::assertNothingSent();
});
it('пустая аудитория кампании — пропускает без обращения к Яндексу', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_audience.token' => 'AUDTOKEN',
]);
Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]);
$tenant = Tenant::factory()->create();
AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания без аудитории',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_segment_id' => 900003,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => false,
]);
app(SyncCampaignAudienceJob::class)->handle();
Http::assertNothingSent();
});
it('кампания без сегмента Яндекса — пропускает', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_audience.token' => 'AUDTOKEN',
]);
Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]);
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания без сегмента',
'status' => AdCampaign::STATUS_RUNNING,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
DB::table('ad_campaign_phones')->insert([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000004',
'expires_at' => now()->addDays(5),
'created_at' => now(),
'updated_at' => now(),
]);
app(SyncCampaignAudienceJob::class)->handle();
Http::assertNothingSent();
});