Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.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

263 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\Models\AdCampaign;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Http;
/**
* Пауза/возобновление кампании клиента (Директ suspend/resume).
*
* Мирроим auth/tenant setup из AdvertisingCampaignEndpointTest.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);
});
it('returns 401 for pause without auth', function () {
auth()->logout();
$this->postJson('/api/advertising/campaigns/1/pause')->assertStatus(401);
});
it('returns 401 for resume without auth', function () {
auth()->logout();
$this->postJson('/api/advertising/campaigns/1/resume')->assertStatus(401);
});
it('pauses a running campaign without calling Direct when disabled', function () {
config(['services.yandex_direct.enabled' => false]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания на паузу',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_RUNNING,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
$response->assertOk()->assertJsonPath('status', 'paused');
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaign->id,
'status' => AdCampaign::STATUS_PAUSED,
]);
});
it('pauses a pending_moderation campaign and calls Direct suspend when enabled', 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/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 555]]]])]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания на модерации',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => 555,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
$response->assertOk()->assertJsonPath('status', 'paused');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/json/v5/campaigns')
&& ($request['method'] ?? null) === 'suspend'
&& ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 555;
});
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaign->id,
'status' => AdCampaign::STATUS_PAUSED,
]);
});
it('rejects pausing a draft campaign with 409', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Черновик',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_DRAFT,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause");
$response->assertStatus(409)
->assertJsonPath('message', 'Кампанию нельзя поставить на паузу из текущего состояния.');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT);
});
it('returns 404 pausing another tenant campaign', function () {
$tenantB = Tenant::factory()->create();
$campaignB = AdCampaign::create([
'tenant_id' => $tenantB->id,
'name' => 'Чужая кампания',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_RUNNING,
]);
$this->postJson("/api/advertising/campaigns/{$campaignB->id}/pause")->assertStatus(404);
});
it('resumes a paused campaign and calls Direct resume when enabled', 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/v5/campaigns' => Http::response(['result' => ['ResumeResults' => [['Id' => 777]]]])]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания на паузе',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_PAUSED,
'yandex_campaign_id' => 777,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
$response->assertOk()->assertJsonPath('status', 'running');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/json/v5/campaigns')
&& ($request['method'] ?? null) === 'resume'
&& ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 777;
});
$this->assertDatabaseHas('ad_campaigns', [
'id' => $campaign->id,
'status' => AdCampaign::STATUS_RUNNING,
]);
});
it('resumes a paused campaign without calling Direct when disabled', function () {
config(['services.yandex_direct.enabled' => false]);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания без Директа',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_PAUSED,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
$response->assertOk()->assertJsonPath('status', 'running');
});
it('rejects resuming a running campaign with 409', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Уже идёт',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_RUNNING,
]);
$response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
$response->assertStatus(409)
->assertJsonPath('message', 'Возобновить можно только кампанию на паузе.');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
});
it('returns 404 resuming another tenant campaign', function () {
$tenantB = Tenant::factory()->create();
$campaignB = AdCampaign::create([
'tenant_id' => $tenantB->id,
'name' => 'Чужая кампания на паузе',
'audience_days' => 10,
'weekly_budget_rub' => '1000.00',
'status' => AdCampaign::STATUS_PAUSED,
]);
$this->postJson("/api/advertising/campaigns/{$campaignB->id}/resume")->assertStatus(404);
});
// ── ВЫХОД 4: пауза снимает заморозку, возобновление морозит остаток заново ──
// Решение владельца 27.07.2026: на паузе деньги клиента свободны — он может
// потратить их на другую рекламу; при возобновлении резервируем остаток сметы.
it('ВЫХОД 4: пауза возвращает заморозку в свободные деньги', function () {
config(['services.yandex_direct.enabled' => false]);
$svc = app(App\Services\Advertising\AdWalletService::class);
$svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания на паузу с заморозкой',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'estimated_impressions' => 10000,
'paid_impressions' => 10000,
'charged_client_rub' => '200.00',
]);
// Остаток сметы: 10000 показов × 120 ₽/1000 = 1200 ₽, из них 200 ₽ уже списано.
$svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1000.00');
$this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
expect(App\Models\AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00');
});
it('ВЫХОД 4: возобновление снова морозит неоткрученный остаток сметы', function () {
config(['services.yandex_direct.enabled' => false]);
$svc = app(App\Services\Advertising\AdWalletService::class);
$svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания к возобновлению',
'audience_days' => 10,
'status' => AdCampaign::STATUS_PAUSED,
'estimated_impressions' => 10000,
'paid_impressions' => 10000,
'charged_client_rub' => '200.00',
]);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/resume")->assertOk();
expect(App\Models\AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1000.00');
});
it('ВЫХОД 4: возобновление при нехватке денег отказывает понятно и оставляет паузу', function () {
config(['services.yandex_direct.enabled' => false]);
$svc = app(App\Services\Advertising\AdWalletService::class);
$svc->topup($this->tenant->id, '500.00', 'yandex', 'тест'); // меньше остатка 1000 ₽
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кампания без денег на возобновление',
'audience_days' => 10,
'status' => AdCampaign::STATUS_PAUSED,
'estimated_impressions' => 10000,
'paid_impressions' => 10000,
'charged_client_rub' => '200.00',
]);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/resume")
->assertStatus(409)
->assertJsonPath('message', 'Не хватает денег на рекламном кошельке, чтобы возобновить кампанию. Пополните кошелёк.');
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_PAUSED);
});