Files
portal/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php
T
Дмитрий 2590c01477 test: половина грязи рекламных тестов убрана — остаток в базе со 116 до 44
Восьми файлам рекламной папки поставлен откат после теста. Проверено остатком
в базе снаружи: папка оставляла 116 записей арендаторов, теперь 44. Папка
целиком зелёная - 351 из 351. Полный прогон после уборки: 4111 тестов,
4107 зелёных, 4 пропущено, ноль падений - значит на эту грязь никто не
опирался. Побочно прогон стал БЫСТРЕЕ: 12,9 минуты против 16,5.

Один файл намеренно оставлен как был, причина записана прямо в нём:
CampaignBannerEndpointsTest нарочно ловит отказ базы, а Postgres после отказа
внутри транзакции глушит все следующие команды. С откатом файл падает -
чинить надо сам тест, а не обёртку.

Датчик пришлось расшифровывать, и это стоит помнить. Числа сперва выходили
с минусами: файл, который базы не касается вовсе, «уносил» девять записей.
Прямым замером выяснилось, что КАЖДЫЙ прогон начинается с пересборки базы -
было 6 записей, стало 0 после теста про расписание. Значит разница «до/после»
при переборе файлов = оставил этот минус оставил предыдущий. Расшифровал
цепочкой, сумма сошлась со 116 ровно - на этом расшифровка и проверяется.
Поимённый расклад по файлам перенесён в промт.

Грабля на будущее, тоже в промте: uses() обязан стоять НИЖЕ подключений
классов. Подключение действует с места объявления и ниже, поэтому вызов
выше падает «класс не найден» - и падает сразу вся папка. Полное имя
с ведущей чертой не спасает: pint САМ превращает его в подключение внизу
и ломает работавший файл.

Остаток долга измерен и НЕ закрыт: ~20 файлов оставляют по одной-две записи,
среди них миграционные, которым откат может быть противопоказан по сути.
По всей папке Feature без отката 96 файлов из 580 - корень общий,
в app/tests/Pest.php строка RefreshDatabase закомментирована.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:46:14 +03:00

363 lines
16 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');
});