Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php
T
Дмитрий 07a17b2767 fix: пополнил кошелёк — реклама оживает; охрана очередей больше не слепнет от одной строки
Т-Б3 приёмки телеграма и падающая задача incidents:watch-failures.

1. Реклама, вставшая из-за нехватки денег, была в тупике: ручка
   возобновления принимала только `paused`, ручка запуска — только
   `draft`, а на экране у такой кампании не было ни одной кнопки.
   Клиент пополнял кошелёк и не мог вернуть рекламу ничем.
   Найдено чтением кода, БЕЗ траты живых денег и без остановки
   рекламы Яндекса — ровно то, ради чего карточка откладывалась.

2. incidents:watch-failures падал на бою каждые 10 минут с 05.08
   09:30. Причина: не отправилось письмо тревоги, упавшее письмо
   легло в failed_jobs, а в его слепке — нулевой байт от закрытых
   полей объекта PHP. Postgres такую строку хранит, но текст из неё
   не достаёт и роняет весь запрос. Одна строка глушила всю охрану
   очередей и вебхуков поставщика, а в журнале был только «код 1».

Сторожа обеих починок видены красными до правки.

NB: настоящий нулевой байт из своих же комментариев убран — с ним
git считал оба файла двоичными и переставал показывать различия.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:26:46 +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');
});