Files
portal/app/tests/Feature/ClientTg/AdminRazborApiTest.php
T
Дмитрий 13ce301ab3 feat телеграм: разбор застрявших кампаний и человеческий язык отказов
Кампания, брошенная роботом на полпути, попадала в needs_review или
draft_ready — статусы, из которых не вело ни одного перехода. Замороженные
деньги клиента запирались навсегда, снять их мог только программист правкой
боевой базы. В бою 06.08.2026 так заперло 268,80 ₽ по кампании №14.

Теперь в админке есть карточка «Застрявшие кампании»: владелец видит номер
кампании в кабинете МТС, запертую сумму и уже уплаченную МТС сумму — и решает
сам. Кнопка «списать по факту» показывается ТОЛЬКО когда МТС уже уплачено;
иначе списывать было бы нечего, кроме сметы — ровно та беда, ради которой
заморозку и заводили.

Заодно: клиенту больше не показывают внутренние адреса и команды запуска —
причина отказа переводится на человеческий язык. Портал перестал принимать
пустой отчёт робота как успешный.

Проверено: модуль 354 теста, экраны 13 тестов, статанализ 0 ошибок.

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

157 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdWalletHold;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramTariffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Экран разбора застрявших кампаний (дефект Д-1).
*
* До 06.08.2026 кампания, брошенная роботом на полпути, застревала навсегда вместе с
* замороженными деньгами клиента — снять их можно было только правкой боевой базы.
* Здесь владелец видит все такие кампании и решает по каждой: реклама пошла (списать по
* факту кабинета) или не пошла (вернуть всё).
*
* Зона saas-admin/admin-db (crm_admin_user, srv_bypass) — кампании видны по всем клиентам.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('client_tg.sandbox', false);
});
function arCampaign(int $tenantId, string $status, ?string $mtsCost = null): Campaign
{
return Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'mts_campaign_id' => '2237812',
'mts_cost_rub' => $mtsCost,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 400,
'estimated_cost_rub' => '268.80',
'created_by' => 1,
]);
}
function arZastryavshaya(string $status = Campaign::STATUS_NEEDS_REVIEW, ?string $mtsCost = null): array
{
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = arCampaign($tenant->id, $status, $mtsCost);
tgKoshelekTopup($tenant->id, '1000.00');
app(TelegramCampaignChargeService::class)->freeze($campaign, '268.80');
return [$tenant, $campaign];
}
function arHoldActive(int $tenantId, int $campaignId): int
{
return AdWalletHold::where('tenant_id', $tenantId)
->where('channel', 'telegram')
->where('source_type', 'client_tg_campaign')
->where('source_id', $campaignId)
->where('status', AdWalletHold::STATUS_ACTIVE)
->count();
}
it('список показывает застрявшие кампании и НЕ показывает остальные', function () {
[$t1, $zastryala] = arZastryavshaya();
[, $chernovik] = arZastryavshaya(Campaign::STATUS_DRAFT_READY);
[, $namoderacii] = arZastryavshaya(Campaign::STATUS_MODERATING);
$r = $this->getJson('/api/admin/telegram/zastryavshie');
$r->assertStatus(200);
$ids = collect($r->json('campaigns'))->pluck('id')->all();
expect($ids)->toContain($zastryala->id)
->and($ids)->toContain($chernovik->id)
->and($ids)->not->toContain($namoderacii->id);
});
// 🔑 Решение принимает человек, сверяясь с кабинетом МТС. Значит на экране обязаны быть
// три числа: сколько заперто, сколько уже заплачено кабинету и по какому номеру искать
// кампанию в самом кабинете. Без них кнопки — гадание.
it('в списке есть всё, что нужно для решения', function () {
[$tenant, $campaign] = arZastryavshaya(mtsCost: '182.40');
$stroka = collect($this->getJson('/api/admin/telegram/zastryavshie')->json('campaigns'))
->firstWhere('id', $campaign->id);
expect($stroka['mts_campaign_id'])->toBe('2237812')
->and($stroka['estimated_cost_rub'])->toBe('268.80')
->and($stroka['mts_cost_rub'])->toBe('182.40')
->and($stroka['tenant_id'])->toBe($tenant->id)
->and($stroka)->toHaveKey('status');
});
it('разбор «вернуть» отпускает заморозку и закрывает кампанию', function () {
[$tenant, $campaign] = arZastryavshaya();
$this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'vernut'])
->assertStatus(200);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED)
->and(arHoldActive($tenant->id, $campaign->id))->toBe(0)
->and(tgKoshelekBalance($tenant->id))->toBe('1000.00');
});
it('разбор «списать» списывает по факту кабинета', function () {
[$tenant, $campaign] = arZastryavshaya(mtsCost: '182.40');
$this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'spisat'])
->assertStatus(200);
$ozhidaem = app(TelegramTariffService::class)->clientPriceFromCostRub('182.40');
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_LAUNCHED)
->and(tgKoshelekBalance($tenant->id))->toBe(bcsub('1000.00', $ozhidaem, 2));
});
// 🔴 Самое опасное нажатие: «списать», когда сумма кабинета неизвестна. Списать смету
// значило бы взять с клиента за размер списка, а не за рекламу. Отбиваем понятной
// фразой и деньги не трогаем.
it('«списать» без суммы кабинета — 422 понятной фразой, деньги целы', function () {
[$tenant, $campaign] = arZastryavshaya();
$r = $this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'spisat']);
$r->assertStatus(422);
expect($r->json('message'))->toContain('вернуть');
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and(arHoldActive($tenant->id, $campaign->id))->toBe(1)
->and(tgKoshelekBalance($tenant->id))->toBe('1000.00');
});
it('незнакомое решение — 422, кампания не тронута', function () {
[, $campaign] = arZastryavshaya();
$this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'udalit'])
->assertStatus(422);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW);
});
it('кампанию не в тупике разбирать нельзя — 422', function () {
[, $campaign] = arZastryavshaya(Campaign::STATUS_MODERATING);
$this->postJson("/api/admin/telegram/zastryavshie/{$campaign->id}/razbor", ['reshenie' => 'vernut'])
->assertStatus(422);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_MODERATING);
});
it('несуществующая кампания — 422, а не поломка', function () {
$this->postJson('/api/admin/telegram/zastryavshie/999999/razbor', ['reshenie' => 'vernut'])
->assertStatus(422);
});