Files
portal/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php
T
Дмитрий dd894a5224 fix(реклама показы): заморозка тает при списании и снимается на всех 4 выходах кампании
Заморозка ставилась один раз при запуске на всю смету показов и не снималась
нигде — AdWalletService::release не вызывался ни одной строкой приложения.

Главное следствие было блокирующим: charge уменьшал balance_rub, но не трогал
frozen_rub, поэтому одни и те же рубли считались дважды. Свободный остаток
balance − frozen уходил в минус, а AdWalletGate::isSolvent вызывается сразу
после списания в ChargeCampaignSpendJob — клиент объявлялся неплатёжеспособным
после первого же суточного списания, и AdStopAll глушил все его кампании.
Кампания умерла бы после первого дня показов даже при полном кошельке.

Что сделано:
- charge уменьшает активный холд на списанную сумму, холд закрывается при нуле;
- release стал идемпотентным — отсутствие кошелька или холда больше не ошибка;
- выход 1 completed — CampaignImpressionCharger возвращает остаток резерва;
- выход 2 rejected — SyncCampaignModerationJob возвращает резерв целиком;
- выход 3 stopped_no_funds — PauseCampaignsOnAdStop снимает резерв;
- выход 4 paused — контроллер снимает резерв, resume морозит остаток сметы
  до обращения к Директу и отдаёт 409 с понятным текстом при нехватке денег.

Удаление кампании выходом не является — destroy разрешён только для черновика,
а черновик ещё не заморожен.

Решение по паузе согласовано с владельцем 27.07.2026: на паузе деньги свободны.

Тесты: рекламный модуль 156/156. Переписан сценарий одного существующего теста
ChargeCampaignSpendJobTest — нехватку денег теперь создаёт резерв ВТОРОЙ
кампании, так как прежняя постановка опиралась на двойной счёт и стала
недостижимой; проверяемое требование сохранено.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:44:12 +03:00

259 lines
10 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\Events\AdvertisingStopped;
use App\Jobs\SyncCampaignModerationJob;
use App\Models\AdCampaign;
use App\Models\AdCampaignAd;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
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('модерация: REJECTED объявление помечает кампанию rejected (passthrough)', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 555, 'Status' => 'REJECTED', 'State' => 'OFF', 'StatusClarification' => 'Нет гарантий'],
]]], 200),
]);
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания на модерации',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => 222,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
$ad = AdCampaignAd::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'title' => 'Заголовок',
'text' => 'Текст объявления',
'href' => 'https://example.test',
'yandex_ad_id' => 555,
'moderation_status' => 'MODERATION',
]);
app(SyncCampaignModerationJob::class)->handle();
$ad->refresh();
expect($ad->moderation_status)->toBe('REJECTED');
expect($ad->moderation_reason)->toBe('Нет гарантий');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED);
});
it('модерация: все ACCEPTED переводит кампанию в running (passthrough)', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 555, 'Status' => 'ACCEPTED', 'State' => 'ON', 'StatusClarification' => null],
]]], 200),
]);
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания на модерации 2',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => 223,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
$ad = AdCampaignAd::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'title' => 'Заголовок',
'text' => 'Текст объявления',
'href' => 'https://example.test',
'yandex_ad_id' => 555,
'moderation_status' => 'MODERATION',
]);
app(SyncCampaignModerationJob::class)->handle();
$ad->refresh();
expect($ad->moderation_status)->toBe('ACCEPTED');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
});
it('AdvertisingStopped ставит кампанию тенанта на паузу через реальный слушатель', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => []], 200),
]);
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания в работе',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 224,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
// Без Event::fake — доказываем, что слушатель реально ЗАРЕГИСТРИРОВАН и сработал.
event(new AdvertisingStopped($tenant->id));
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/json/v5/campaigns')
&& ($request->data()['method'] ?? null) === 'suspend';
});
});
it('AdvertisingStopped не трогает кампанию чужого тенанта (явный tenant-фильтр)', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => []], 200),
]);
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$campaignA = AdCampaign::create([
'tenant_id' => $tenantA->id,
'name' => 'Кампания тенанта A',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 225,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
$campaignB = AdCampaign::create([
'tenant_id' => $tenantB->id,
'name' => 'Кампания тенанта B',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 226,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
event(new AdvertisingStopped($tenantA->id));
$campaignA->refresh();
$campaignB->refresh();
expect($campaignA->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
expect($campaignB->status)->toBe(AdCampaign::STATUS_RUNNING);
});
// ВЫХОД 2 — Яндекс отклонил объявление. Показов не было, деньги не тратились →
// заморозка возвращается клиенту полностью.
it('ВЫХОД 2: отклонение модерацией снимает заморозку кампании', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 557, 'Status' => 'REJECTED', 'State' => 'OFF', 'StatusClarification' => 'Не та тематика'],
]]], 200),
]);
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '3000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания под отказ',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => 227,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
AdCampaignAd::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'title' => 'Заголовок',
'text' => 'Текст объявления',
'href' => 'https://example.test',
'yandex_ad_id' => 557,
'moderation_status' => 'MODERATION',
]);
$svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
app(SyncCampaignModerationJob::class)->handle();
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED)
->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00');
});
// ВЫХОД 3 — деньги кончились, вся реклама тенанта заглушена. Держать заморозку
// на мёртвой кампании бессмысленно — она только занижает свободный остаток.
it('ВЫХОД 3: остановка без средств снимает заморозку кампании', function () {
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => []], 200),
]);
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '3000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'Кампания без денег',
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 228,
'weekly_budget_rub' => '500.00',
'audience_days' => 10,
'use_uploaded_list' => true,
]);
$svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
event(new AdvertisingStopped($tenant->id));
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS)
->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00');
});