Files
portal/app/tests/Feature/ClientTg/ResubmitJobTest.php
T
Дмитрий 7a2b052f0d feat(телеграм-реклама): связка Laravel → робот mode:resubmit — пересдача чинит ту же кампанию
Хвост «связка» из STATE. Раньше пересдача отклонённой кампании создавала в кабинете
МТС НОВУЮ кампанию (RunTelegramCampaignJob, очищала mts_campaign_id). Теперь портал
поручает роботу ЧИНИТЬ ту же кампанию: робот заходит в неё через «Исправить» по
mts_campaign_id, вносит правки и переотправляет на модерацию «без оплаты» (0 ₽).
Робот-режим mode:'resubmit' уже проверен живьём (коммит d50a93e5). Выбор поведения
(«чинить ту же», не «создавать новую») подтверждён владельцем.

Что сделано:
- RobotResult: поле resubmitted (bool) + разбор из JSON робота.
- TelegramRobotRunner::taskPayload: пробрасывает submitMode (draft|live для resubmit).
- ResubmitTelegramCampaignJob (новый): RLS через tenantTx (SET LOCAL current_tenant_id),
  идемпотентность по queued, денежно-статусная логика F5 как в RunTelegramCampaignJob.
  Успех: бой (resubmitted) → moderating, песочница → draft_ready. Отказ с mts_campaign_id
  → needs_review без возврата брони.
- Контроллер resubmit: mts_campaign_id СОХРАНЯЕТСЯ (не очищаем), guard на пустой id → 422,
  dispatch ResubmitTelegramCampaignJob.

Код-ревью (субагент) поймало Important-баг, исправлено:
- I-1: failed() был скопирован из эталона, где «queued ⇒ нет mts_campaign_id». У пересдачи
  queued ВСЕГДА с id → при перманентном сбое в queued срабатывала ветка hasMtsId →
  queued→needs_review (перехода НЕТ в TRANSITIONS) → кампания зависала в queued с
  замороженной бронью, уборщик её не метёт. Фикс: queued → failed + release (робот кабинет
  не трогал — Фаза A не закоммитила running); running — прежняя осторожная логика.
- M-1: успех в бою с resubmitted=false (аномалия контракта) давал терминальный draft_ready
  с зависшей бронью → needs_review (бронь под ручную сверку).

Приёмка: ResubmitJobTest + ResubmitTest вместе 15/15 (51 проверка); pint/phpstan(0)/
deptrac(0) чисто; схема БД не менялась. Детали и приёмочный лист:
docs/superpowers/2026-07-28-resubmit-svyazka-ACCEPT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:16:48 +03:00

164 lines
8.3 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\AdWallet;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
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, бронь НЕ возвращается, причина записана', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
$campaign = tgResubmitQueued($tenant->id);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
mockResubmitRunner(RobotResult::failed('Не удалось войти в «Исправить»', 'resubmit-open'));
ResubmitTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($fresh->status_reason)->toBe('Не удалось войти в «Исправить»');
// Бронь НЕ вернули (могла уйти на модерацию) — frozen остаётся.
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('1000.00');
});
it('перманентный сбой в queued (есть id кабинета) → failed + бронь ВЕРНУЛАСЬ (робот кабинет не трогал)', function () {
// Регресс I-1: у пересдачи queued ВСЕГДА с mts_campaign_id, но в queued Фаза A не
// закоммитила running → кабинет не тронут → безопасно failed + release. Без правки
// кампания зависала бы в queued с замороженной бронью (уборщик queued не метёт).
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
$campaign = tgResubmitQueued($tenant->id); // status queued + mts_campaign_id
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
(new ResubmitTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('воркер убит'));
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь вернулась
});
it('перманентный сбой в running (есть id кабинета) → needs_review, бронь НЕ вернулась (мог тронуть кабинет)', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
$campaign = tgResubmitQueued($tenant->id, ['status' => Campaign::STATUS_RUNNING]);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
(new ResubmitTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('таймаут робота'));
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->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);
});