Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php
T
Дмитрий 732de3c33b fix(реклама): пауза сперва платит за показанное, потом отпускает деньги
Вопрос владельца вскрыл дыру в деньгах. Списание за показы делает суточная
задача в 04:20 и берёт только кампании со статусом «крутится». Кампанию,
оставленную на паузе, она не видит никогда — значит показы, сделанные с
последнего списания, не оплачивались вовсе. До суток рекламы клиент получал
бесплатно, а Яндексу за неё платили мы.

Дыра открывалась только если после паузы не возобновить: при возобновлении
пропущенное списывается разницей.

Теперь «Пауза» после остановки рекламы спрашивает у Директа накопительное
число показов и списывает за них, и только затем возвращает остаток
заморозки. Лишнего обращения это не стоит — в Директ мы в этот момент и так
ходим останавливать рекламу.

Три случая разведены:
- обычный: списали, поставили паузу, вернули остаток;
- списание само закрыло кампанию по смете или сроку — уходит в «Показы
  откручены» по ВЫХОДУ 1, паузу поверх не ставим, пятого выхода снятия
  заморозки не появляется;
- не узнали число показов — рекламу остановили, но деньги НЕ отпускаем и
  говорим об этом клиенту.

Сторож принят красным: до починки списание давало 0.00 вместо 300.00 рублей
за 2500 показов.

Найдена соседняя дыра того же рода при «Остановлено, нет денег» — там
слушатель работает изнутри денежной транзакции, и обращение к Яндексу внутри
неё держало бы замок строки на время сетевого запроса. Разобрано в отчёте,
чинить отдельным заходом.

Прогон: 387 сторожей блока рекламы, 177 денежных, статанализ 0, стиль чист.
Схема не менялась.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:56:15 +03:00

410 lines
19 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\AdWallet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
// Откат после теста. Без него записи файла остаются в базе и роняют ЧУЖИЕ тесты:
// 01.08.2026 девятнадцать чужих проводок уронили денежную проверку приёмника МТС.
uses(DatabaseTransactions::class);
/**
* Пауза/возобновление кампании клиента (Директ 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,
]);
});
/**
* 🪤 Мина со стороны «включено»: рубильник, заданный в .env строкой («1», «true»),
* приходит из env строкой. Проверка `!== true` считала бы такой рубильник ВЫКЛЮЧЕННЫМ и
* молча не шла в Директ, а пауза приняла бы это за успех и разморозила деньги — при том,
* что реклама в Яндексе продолжает крутиться.
*/
it('goes to Direct when the switch is on as a string', function () {
config(['services.yandex_direct.enabled' => '1']);
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,
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 555,
]);
$this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
Http::assertSent(fn ($request) => str_contains($request->url(), '/json/v5/campaigns'));
});
/**
* Пауза, которая не дошла до Директа, — это не пауза.
*
* Ошибку Директа портал глотал в журнал, ставил кампании статус «на паузе» и БЕЗУСЛОВНО
* размораживал деньги. Итог: реклама в Яндексе продолжает крутиться и тратить, портал
* показывает «на паузе», а деньги за неё уже свободны и могут уйти на другую кампанию.
* Клиент уходит в минус молча.
*
* Честнее отказать: «не удалось остановить, попробуйте ещё раз».
*/
it('refuses to pause and keeps the money frozen when Direct rejects the suspend', 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(['error' => ['error_string' => 'Директ лёг']], 500)]);
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Пауза не дошла до Яндекса',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 555,
'paid_impressions' => 10000,
'client_cpm_rub' => '120.00',
]);
app(AdWalletService::class)
->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, '1200.00');
$this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")
->assertStatus(409);
// Статус не сменился, деньги остались зарезервированными под работающую рекламу.
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect($wallet->frozen_rub)->toBe('1200.00');
});
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(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(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00');
});
it('ВЫХОД 4: возобновление снова морозит неоткрученный остаток сметы', function () {
config(['services.yandex_direct.enabled' => false]);
$svc = app(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(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1000.00');
});
it('ВЫХОД 4: возобновление при нехватке денег отказывает понятно и оставляет паузу', function () {
config(['services.yandex_direct.enabled' => false]);
$svc = app(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);
});
// ШОВ: два теста выше проверяют половинки по отдельности — «пауза снимает заморозку»
// начинает с замороженной кампании, «возобновление снова морозит» начинает с кампании,
// которую НИКОГДА не морозили. Живая последовательность пауза → возобновление той же
// кампании не проверялась ни разу и падала дублем ключа брони.
it('ВЫХОД 4: пауза и следом возобновление той же кампании — клиент не видит ошибки', function () {
config(['services.yandex_direct.enabled' => false]);
$svc = app(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',
]);
$svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1000.00');
$this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
expect((string) AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00');
$this->postJson("/api/advertising/campaigns/{$campaign->id}/resume")->assertOk();
expect((string) AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1000.00');
});
/**
* 🔴 Пауза обязана СНАЧАЛА заплатить за уже показанное, и только потом отпускать деньги.
*
* Вопрос владельца 04.08.2026: «ставлю паузу за час до окончания и больше не запускаю —
* что происходит?» Ответ по прежнему коду был плохой: заморозка возвращалась целиком, а
* списание за показы делает СУТОЧНАЯ задача в 04:20, и она берёт только кампании со
* статусом «крутится». Кампанию на паузе она пропускает навсегда — значит до суток
* показов клиент получал бесплатно, а Яндексу за них платили мы.
*
* Дыра открывается ровно тогда, когда после паузы кампанию не возобновили: при
* возобновлении пропущенное списывается разницей.
*/
it('пауза списывает за уже показанное, прежде чем вернуть заморозку', function () {
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.token' => 'DIRTOKEN']);
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 777]]]]),
'*/json/v5/reports' => Http::response('2500', 200), // накопительно за всё время
]);
$svc = app(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,
'yandex_campaign_id' => 777,
'estimated_impressions' => 10000,
'paid_impressions' => 10000,
'charged_client_rub' => '0.00',
]);
$svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
$this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
// 2500 показов × 120 ₽ за тысячу = 300 ₽ — они обязаны уйти с баланса.
expect((string) $campaign->fresh()->charged_client_rub)->toBe('300.00')
->and((string) $wallet->balance_rub)->toBe('2700.00')
// Остаток сметы больше не заперт: кампания стоит и тратить не будет.
->and((string) $wallet->frozen_rub)->toBe('0.00');
});