Files
portal/app/tests/Feature/ClientTg/SweepStuckTest.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

201 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\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramCampaignChargeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// SweepStuckTelegramCampaignsJob перечисляет кампании через pgsql_supplier
// (BYPASSRLS). В тестах шарим PDO, иначе данные, созданные на дефолтном
// соединении внутри транзакции RefreshDatabase, не видны supplier-соединению.
uses(RefreshDatabase::class, SharesSupplierPdo::class);
/**
* Задача 3.3 — уборщик зависших кампаний + `$timeout` робота.
*
* Кампания в статусе `running` дольше окна (робот-таймаут + запас) считается
* зависшей: воркер убили/упал молча. Уборщик добивает статус, но КОНСЕРВАТИВНО с
* деньгами (с 03.08.2026 — заморозка на РЕКЛАМНОМ кошельке `ad_wallets`
* через TelegramCampaignChargeService — рекламного кошелька с бронью больше нет):
* — нет `mts_campaign_id` (черновик в кабинете не создавался) → кампания заведомо
* НЕ ушла на модерацию → `failed` + возврат списанной сметы на баланс;
* — есть `mts_campaign_id` (черновик реально создан) → кампания МОГЛА уйти на
* модерацию → деньги вслепую НЕ возвращаем, помечаем `needs_review` (ручной
* разбор, пока не сверим статус в кабинете задачей 3.4);
* — `moderating` дольше окна `client_tg.moderation_stuck_hours` → тоже
* `needs_review` БЕЗ возврата (могла реально показываться).
* Свежая `running` (в пределах окна) не трогается. В песочнице денег не списывали —
* возврат не производится.
*/
/** Кампания в заданном статусе; при $ageMinutes>0 «состаривает» updated_at. */
function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int $ageMinutes = 0, string $estimatedCost = '0.00'): Campaign
{
$c = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'mts_campaign_id' => $mtsId,
'ad_text' => 'Свип-тест',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 0,
'estimated_cost_rub' => $estimatedCost,
'created_by' => 1,
]);
if ($ageMinutes > 0) {
// Прямой апдейт updated_at минуя Eloquent-touch (иначе штамп «сейчас»).
DB::table('client_tg_campaigns')->where('id', $c->id)
->update(['updated_at' => now()->subMinutes($ageMinutes)]);
}
return $c;
}
/** Тенант с деньгами на РЕКЛАМНОМ кошельке + tenant-контекст под его id. */
function sweepTenant(string $balance): Tenant
{
$t = Tenant::factory()->create(['balance_rub' => '0.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$t->id);
tgKoshelekTopup($t->id, $balance);
return $t;
}
it('константа STATUS_NEEDS_REVIEW есть и равна needs_review', function () {
expect(Campaign::STATUS_NEEDS_REVIEW)->toBe('needs_review');
});
it('зависшая queued (деньги списаны, джоб не стартовал) → failed + возврат сметы', function () {
config()->set('client_tg.sandbox', false);
$tenant = sweepTenant('5000.00');
// queued без mts_campaign_id, состарена больше окна QUEUED_STUCK_AFTER_MINUTES (60).
$c = sweepCampaign($tenant->id, Campaign::STATUS_QUEUED, null, 61, '315.00');
app(TelegramCampaignChargeService::class)->freeze($c, '315.00');
expect(tgKoshelekFrozen($tenant->id))->toBe('315.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
->and(tgKoshelekFrozen($tenant->id))->toBe('0.00') // заморозка отпущена
->and(tgKoshelekBalance($tenant->id))->toBe('5000.00'); // ни рубля не списано
});
it('свежая queued (в пределах окна) не трогается — джоб может её вот-вот запустить', function () {
config()->set('client_tg.sandbox', false);
$tenant = sweepTenant('5000.00');
$c = sweepCampaign($tenant->id, Campaign::STATUS_QUEUED, null, 5, '315.00');
app(TelegramCampaignChargeService::class)->freeze($c, '315.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_QUEUED)
->and(tgKoshelekFrozen($tenant->id))->toBe('315.00'); // заморозку не отпускали
});
// 🔴 Этот тест до 06.08.2026 закреплял «needs_review — терминальный», то есть охранял
// сам дефект Д-1: заморозка клиента запиралась навсегда, снять её можно было только
// правкой боевой базы руками. В бою так заперло 268,80 ₽. Выход теперь есть — ручной
// разбор (TelegramRazborService), и он ведёт ровно в два места.
it('running допускает needs_review, а из него ведёт ручной разбор', function () {
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING);
expect($c->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW))->toBeTrue();
$c->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
expect($c->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($c->canTransitionTo(Campaign::STATUS_FAILED))->toBeTrue() // «рекламы не было»
->and($c->canTransitionTo(Campaign::STATUS_LAUNCHED))->toBeTrue() // «реклама пошла»
->and($c->canTransitionTo(Campaign::STATUS_QUEUED))->toBeFalse(); // но заново не запускаем
});
it('таймаут джоба (420с) больше робот-таймаута', function () {
$job = new RunTelegramCampaignJob(1, 1);
expect($job->timeout)->toBe(420);
});
it('failed() переводит queued в failed', function () {
config()->set('client_tg.sandbox', true); // без возврата
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_QUEUED);
(new RunTelegramCampaignJob($c->id, $t->id))->failed(new RuntimeException('boom'));
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED);
});
it('зависшая running без mts_campaign_id → failed и деньги вернулись', function () {
config()->set('client_tg.sandbox', false);
$t = sweepTenant('5000.00');
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30, '315.00');
app(TelegramCampaignChargeService::class)->freeze($c, '315.00');
expect(tgKoshelekFrozen($t->id))->toBe('315.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
// failed + заморозка отпущена → все деньги снова свободны.
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
->and(tgKoshelekFrozen($t->id))->toBe('0.00')
->and(tgKoshelekBalance($t->id))->toBe('5000.00');
});
it('свежая running не трогается', function () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 0);
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_RUNNING);
});
it('зависшая running С mts_campaign_id → needs_review, деньги НЕ вернулись', function () {
config()->set('client_tg.sandbox', false);
$t = sweepTenant('5000.00');
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, '2231150', 30, '315.00');
app(TelegramCampaignChargeService::class)->freeze($c, '315.00');
expect(tgKoshelekFrozen($t->id))->toBe('315.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
// needs_review, баланс остаётся сниженным (нет возврата), ноль проводок возврата.
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and(tgKoshelekFrozen($t->id))->toBe('315.00'); // заморозку не отпускали
});
it('зависшая moderating → needs_review, деньги НЕ вернулись', function () {
config()->set('client_tg.sandbox', false);
config()->set('client_tg.moderation_stuck_hours', 1);
$t = sweepTenant('5000.00');
$c = sweepCampaign($t->id, Campaign::STATUS_MODERATING, '2231150', 120, '315.00');
app(TelegramCampaignChargeService::class)->freeze($c, '315.00');
expect(tgKoshelekFrozen($t->id))->toBe('315.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
// needs_review, деньги вслепую не возвращаем (кампания могла показываться).
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and(tgKoshelekFrozen($t->id))->toBe('315.00'); // заморозку не отпускали
});
it('песочница: зависшая running → failed, денег не касаемся', function () {
config()->set('client_tg.sandbox', true);
$t = sweepTenant('5000.00');
// В песочнице деньги не списывали — charge отсутствует.
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30, '315.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
// Статус добит, но возврат не запускается (нечего и незачем) — 0 проводок, баланс цел.
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
->and(tgKoshelekFrozen($t->id))->toBe('0.00');
});