Files
portal/app/tests/Feature/ClientTg/SweepStuckTest.php
T
Дмитрий a736ff087d feat(телеграм-реклама): Этап 3.3 — уборщик зависших кампаний + таймаут
Этап 3 «Жизненный цикл и модерация», задача 3.3. Робот в браузере может
оборваться на полпути (воркер убит, сеть отвалилась) — кампания навсегда
виснет в `running`, а замороженные деньги залипают. Уборщик добивает статус,
но КОНСЕРВАТИВНО с деньгами.

- SweepStuckTelegramCampaignsJob: раз в 10 минут ищет `running` старше 15 минут
  (штатный робот ≤ 420с). Развилка по факту создания черновика в кабинете:
    · нет mts_campaign_id (черновик не создан) → кампания заведомо не ушла →
      `failed` + возврат брони кошелька;
    · есть mts_campaign_id (черновик создан) → могла уйти на модерацию МТС →
      деньги вслепую НЕ возвращаем, `needs_review` до ручной сверки (задача 3.4).
  Кросс-тенант через pgsql_supplier (BYPASSRLS), правки под SET LOCAL
  app.current_tenant_id на дефолтном соединении; release гейтится на !sandbox,
  ключ совпадает с freeze. Повторная проверка status===running под lockForUpdate
  (гонка с finalize). Зеркалит связку ChargeTgNameFeeJob + RunTelegramCampaignJob.
- Campaign.php: константа STATUS_NEEDS_REVIEW; переходы running→needs_review
  (терминальный) и queued→failed (для failed() джоба при раннем сбое). Миграция
  не нужна — на колонке status нет CHECK, машина статусов в модели.
- RunTelegramCampaignJob: public int $timeout = 420 (робот ~300с + навигации);
  failed() теперь умеет queued→failed (переход добавлен в модель).
- console.php: расписание уборщика everyTenMinutes с heartbeat-трекингом.

TDD. Приёмка (моя область, чистый прогон): SweepStuckTest 7/7 (в т.ч. деньги —
с черновиком не тронуты, без черновика возвращены) + StatusMachine/Moderating/
ExternalId/RunCampaignJob/LaunchIdempotency/RobotResult/RobotRunner 49/49.
phpstan 0, deptrac 0, pint чисто.

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

138 lines
6.2 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\AdWallet;
use App\Models\AdWalletHold;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
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` дольше окна (робот-таймаут + запас) считается
* зависшей: воркер убили/упал молча. Уборщик добивает статус, но КОНСЕРВАТИВНО с
* деньгами:
* — нет `mts_campaign_id` (черновик в кабинете не создавался) → кампания заведомо
* НЕ ушла на модерацию → `failed` + возврат брони;
* — есть `mts_campaign_id` (черновик реально создан) → кампания МОГЛА уйти на
* модерацию → деньги вслепую НЕ возвращаем, помечаем `needs_review` (ручной
* разбор, пока не сверим статус в кабинете задачей 3.4).
* Свежая `running` (в пределах окна) не трогается.
*/
/** Кампания в заданном статусе; при $ageMinutes>0 «состаривает» updated_at. */
function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int $ageMinutes = 0): 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' => '0.00',
'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;
}
/** Кошелёк тенанта + активная бронь под кампанию. */
function sweepWalletWithHold(int $tenantId, int $campaignId, string $balance, string $freeze): void
{
AdWallet::query()->create([
'tenant_id' => $tenantId,
'balance_rub' => $balance,
'frozen_rub' => '0.00',
]);
app(AdWalletService::class)->freeze($tenantId, 'telegram', 'campaign', $campaignId, $freeze);
}
it('константа STATUS_NEEDS_REVIEW есть и равна needs_review', function () {
expect(Campaign::STATUS_NEEDS_REVIEW)->toBe('needs_review');
});
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); // без release
$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 = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30);
sweepWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
expect((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('0.00')
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_RELEASED);
});
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 = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, '2231150', 30);
sweepWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00')
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_ACTIVE);
});