Files
portal/app/tests/Feature/ClientTg/SweepStuckTest.php
T
Дмитрий f70527c3df fix,деньги: телеграм-реклама переведена на рекламный кошелёк с заморозкой — робот наконец платит МТС
🔴 Найдено чтением кода, а не по памяти: робот НИКОГДА не оплачивал кампанию.
finalize доводил боевой запуск до кассы кабинета и уходил (launched:false,
stoppedAt:'payment'), денежные кнопки ему были запрещены наглухо, а «реальная
оплата» отложена на «Сессию 6», которой не случилось. Песочницу при этом
выключили 02.08 в 07:40.

Итог на бою: клиент жал «Запустить» → у него списывалась ВСЯ смета по размеру
списка → в кабинете оставался неоплаченный черновик → на модерацию он не уходил
→ реклама не показывалась ни разу → деньги не возвращались никогда (статус
draft_ready терминальный, возврата не имеет).

То есть беда была не «клиент переплачивает разницу», как записали накануне, а
«клиент платит сто процентов ни за что». Слепки установленного у владельца
робота совпали с веткой до буквы — на боевой машине тот же код.

Владелец решил: боевой не трогать (стоит как стоит), роботу денежную кнопку
разрешить, но с потолком.

── Робот теперь платит ────────────────────────────────────────────────────────

submitWithPayment на шаге /payment: сперва ЧИТАЕТ сумму к оплате, потом гонит её
через гейт против меньшего из двух потолков (лимит кампании и общий потолок
робота), и только потом ищет кнопку и жмёт. Сумма не прочиталась — не платим:
не знаем, что списываем. Не ушли со /payment после клика — падаем громко, портал
по отказу отпустит заморозку.

Общий чёрный список денежных кнопок НЕ ослаблен: та же кнопка остаётся запретной
для всех прочих путей, включая пересдачу. Разрешение точечное.

Прочитанная сумма — это НАШИ расходы у МТС; она едет в портал полем
actualCostRub, которое до сих пор было пустой заготовкой.

── Кошелёк вместо общего баланса ──────────────────────────────────────────────

Порядок зеркалит сам МТС (билинг снят живьём 27–28.07, FLOW-FINDINGS «Задача
2.0»: кабинет резервирует сумму, окончательно списывает по факту показов,
остаток возвращает):

  запуск          → морозим смету на ad_wallets (канал telegram)
  робот заплатил  → фактическая сумма легла в кампанию (mts_cost_rub, v9.66)
  модерация «да»  → списываем по факту × наценка, остаток отпускаем
  модерация «нет» → отпускаем всё, ни рубля не списано
  сбой до кабинета → отпускаем всё

Заморозка была убрана 29.07 намеренно — тогда рассуждали «сумма известна в момент
запуска, морозить нечего». Рассуждение верно ровно до вопроса владельца: сумма
известна, а сколько человек из списка вообще есть в телеграме — нет.

Факта нет, а модерация одобрила — НЕ списываем ничего и кричим в журнал. Списать
«по оценке» значило бы вернуть ровно ту беду, ради которой всё и делалось.

Идемпотентность больше не самодельная: бронь уникальна по кампании, списание — по
ключу события. Прежнее «сальдо проводок» стало не нужно, CampaignChargeServiceTest
удалён — его предмет (charge/refund по общему балансу) больше не существует,
замена KoshelekTelegramaTest.

── Экран ──────────────────────────────────────────────────────────────────────

Карточка денег показывала общий баланс портала. После переезда это стало прямым
враньём: клиент видел бы «денег хватает» там, где запуск отвечает 409. Теперь
ручка отдаёт СВОБОДНЫЕ деньги кошелька (баланс минус заморозка) и заморозку
отдельной графой, а кнопка пополнения ведёт в рекламный кошелёк — прежняя клала
бы деньги в общий баланс, и запустить рекламу всё равно было бы нельзя.

── Проверено вырезанием, а не только зелёным ─────────────────────────────────

- убрал запрет «сумма не прочитана» — покраснели 2 датчика оплаты;
- вернул списание по смете вместо факта — датчик поймал 315 ₽ там, где должно
  быть 210 ₽.

Замеры: телеграм-модуль 330/330, вместе с рекламой и СМС 1078/1078, экраны
251 файл / 2033 теста / 0 падений, робот 160/160, статанализ 0, типы 0, формат чисто.

Сторож денег под боевой ролью переписан: класс «тихий ноль» закрылся сам —
AdWalletService ставит контекст клиента сам, а не надеется на вызывающего.

🔴 На боевой НЕ выкачено. Осталось открытым: показать клиенту строкой
«заморожено / списано по факту / возвращено» на карточке кампании; пересдача
по-прежнему шлёт «без оплаты» (кампания вернётся на модерацию неоплаченной);
числа 0,720 и 0,816 за показ с медиа так и не замерены живьём.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:31:21 +03:00

196 lines
9.8 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'); // заморозку не отпускали
});
it('running допускает needs_review, 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))->toBeFalse()
->and($c->canTransitionTo(Campaign::STATUS_LAUNCHED))->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');
});