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

178 lines
9.6 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\ResubmitTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\PrichinaDlyaKlienta;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
/**
* Джоб ПЕРЕСДАЧИ отклонённой Telegram-кампании (связка Laravel → робот mode:'resubmit').
* Робот ЗАМОКАН. Пересдача ЧИНИТ ту же кампанию в кабинете (через «Исправить»), НЕ
* создаёт новую и НЕ грузит аудиторию заново. Песочница → submitMode=draft (не шлём);
* бой → submitMode=live (робот жмёт «без оплаты»). Приёмка —
* docs/superpowers/2026-07-28-resubmit-svyazka-ACCEPT.md.
*/
beforeEach(function () {
config()->set('client_tg.sandbox', true);
});
/** Кампания, поставленная контроллером в очередь на пересдачу (queued + id кабинета). */
function tgResubmitQueued(int $tenantId, array $overrides = []): Campaign
{
return Campaign::query()->create(array_merge([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_QUEUED,
'mts_campaign_id' => '2231134',
'ad_text' => 'Исправленный текст',
'ad_link' => 'https://t.me/new_channel',
'ord_category' => 'Размещение рекламы',
'moderator_file_path' => 'client_tg/moderator/licenziya.pdf',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 400,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
], $overrides));
}
/** Подменяет раннер моком, возвращающим $result; захватывает params. */
function mockResubmitRunner(RobotResult $result, ?array &$captured = null): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturnUsing(function (array $params) use ($result, &$captured) {
$captured = $params;
return $result;
});
app()->instance(TelegramRobotRunner::class, $mock);
}
it('песочница: queued → робот ok+resubmitted=false → draft_ready', function () {
$tenant = Tenant::factory()->create();
$campaign = tgResubmitQueued($tenant->id);
mockResubmitRunner(new RobotResult(ok: true, resubmitted: false, campaignId: '2231134'), $captured);
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY);
});
it('передаёт роботу mode=resubmit, id кабинета, submitMode, текст/ссылку/ОРД и документ', function () {
$tenant = Tenant::factory()->create();
$campaign = tgResubmitQueued($tenant->id);
$captured = null;
mockResubmitRunner(new RobotResult(ok: true, resubmitted: false, campaignId: '2231134'), $captured);
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect($captured)->not->toBeNull()
->and($captured['mode'])->toBe('resubmit')
->and($captured['campaignId'])->toBe('2231134')
->and($captured['submitMode'])->toBe('draft')
->and($captured['adText'])->toBe('Исправленный текст')
->and($captured['buttonUrl'])->toBe('https://t.me/new_channel')
->and($captured['ordCategory'])->toBe('Размещение рекламы')
->and($captured['moderatorFile'])->toBe('client_tg/moderator/licenziya.pdf');
});
it('бой: робот ok+resubmitted=true → moderating, id кабинета сохранён, submitMode=live', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
$campaign = tgResubmitQueued($tenant->id);
$captured = null;
mockResubmitRunner(new RobotResult(ok: true, resubmitted: true, campaignId: '2231134'), $captured);
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_MODERATING)
->and($fresh->mts_campaign_id)->toBe('2231134')
->and($captured['submitMode'])->toBe('live');
});
it('отказ робота (есть id кабинета) → needs_review, refund НЕ произошёл, списание держится, причина записана', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
tgKoshelekTopup($tenant->id, '5000.00');
$campaign = tgResubmitQueued($tenant->id, ['estimated_cost_rub' => '1000.00']);
// «Уже заморожено»: морозим клиентскую смету на рекламном кошельке.
app(TelegramCampaignChargeService::class)->freeze($campaign, (string) $campaign->estimated_cost_rub);
expect(tgKoshelekFrozen($tenant->id))->toBe('1000.00');
mockResubmitRunner(RobotResult::failed('Не удалось войти в «Исправить»', 'resubmit-open'));
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
// Причина в карточке — человеческая (перевод внутренней), см. PrichinaDlyaKlienta.
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($fresh->status_reason)->toBe(PrichinaDlyaKlienta::perevesti('Не удалось войти в «Исправить»'))
->and($fresh->status_reason)->not->toBeNull();
// Refund НЕ произошёл (кампания могла уйти на модерацию) — списание держим,
// баланс не восстановлен, проводки возврата нет.
expect(tgKoshelekFrozen($tenant->id))->toBe('1000.00');
});
it('перманентный сбой в queued (есть id кабинета) → failed + refund произошёл, баланс восстановлен (робот кабинет не трогал)', function () {
// Регресс I-1: у пересдачи queued ВСЕГДА с mts_campaign_id, но в queued Фаза A не
// закоммитила running → кабинет не тронут → безопасно failed + refund. Без правки
// кампания зависала бы в queued со списанными деньгами (уборщик queued не метёт).
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
tgKoshelekTopup($tenant->id, '5000.00');
$campaign = tgResubmitQueued($tenant->id, ['estimated_cost_rub' => '1000.00']); // status queued + mts_campaign_id
// «Уже заморожено»: морозим клиентскую смету на рекламном кошельке.
app(TelegramCampaignChargeService::class)->freeze($campaign, (string) $campaign->estimated_cost_rub);
expect(tgKoshelekFrozen($tenant->id))->toBe('1000.00');
(new ResubmitTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('воркер убит'));
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED);
// Refund произошёл — смета вернулась на общий баланс, проводка возврата записана.
expect(tgKoshelekFrozen($tenant->id))->toBe('0.00');
});
it('перманентный сбой в running (есть id кабинета) → needs_review, refund НЕ произошёл, списание держится (мог тронуть кабинет)', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
tgKoshelekTopup($tenant->id, '5000.00');
$campaign = tgResubmitQueued($tenant->id, ['status' => Campaign::STATUS_RUNNING, 'estimated_cost_rub' => '1000.00']);
// «Уже заморожено»: морозим клиентскую смету на рекламном кошельке.
app(TelegramCampaignChargeService::class)->freeze($campaign, (string) $campaign->estimated_cost_rub);
expect(tgKoshelekFrozen($tenant->id))->toBe('1000.00');
(new ResubmitTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('таймаут робота'));
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW);
// Refund НЕ произошёл — списание держим до ручной сверки, баланс не восстановлен.
expect(tgKoshelekFrozen($tenant->id))->toBe('1000.00');
});
it('идемпотентно: не-queued кампания — джоб no-op, робота не зовёт', function () {
$tenant = Tenant::factory()->create();
$campaign = tgResubmitQueued($tenant->id, ['status' => Campaign::STATUS_DRAFT_READY]);
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldNotReceive('run');
app()->instance(TelegramRobotRunner::class, $mock);
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY);
});