Files
portal/app/tests/Feature/ClientSms/WatchStuckCampaignTest.php
T
Дмитрий c0a8da37d2 feat(смс-клиент): зависшая рассылка сама срывается и возвращает замороженные деньги
Строки листа 4.1 и 4.2, Этап 4 Task 1.

Беда, от которой сторожим: работник очереди умирает посреди отправки
(перезапуск сервера, обрыв связи). Снятие заморозки денег стоит ПОСЛЕДНИМ
шагом джоба отправки — до него он в этом случае не доходит. Итог на бою:
рассылка вечно «идёт», деньги клиента заморожены навсегда, в журнале тишина.

Команда client-sms:watch-stuck, каждые 15 минут. Движение меряется временем
последней записи в журнале рассылки: sent_count для этого не годится, он
проставляется только в самом конце. Порядок — решение владельца В-132:
сперва ОДНА попытка дожать (это безопасно, джоб пропускает уже отправленные
номера по ключу на номер), и только если и после неё не сдвинулась — срываем,
размораживаем остаток, ставим причину «сторож». Итог считаем из журнала.

Честно ждущая утра рассылка не трогается вовсе (строка 4.2): отличаем по
состоянию — ждущая waiting_window, зависшая sending. Ждущих дожимает
client-sms:resume-waiting.

Миграция 2026_08_01_100500 — две колонки: срок «зависла» в настройках
(правит владелец, строка 4.3) и отметка попытки дожать у рассылки. Прав не
требуют, наследуют привилегии таблиц; повторный накат переживают. Схема v9.17.

Проверено вырезанием, три выреза, все вернуты:
— убрал условие про состояние → покраснел тест 4.2, ждущую положили в очередь;
— убрал ветку «сперва дожать» → покраснел тест «кладётся в очередь ещё раз»;
— убрал пометку клиента → живой прогон под боевой ролью показал молчаливый сбой.

Живой прогон ПОД БОЕВОЙ РОЛЬЮ crm_app_user, парно:
с пометкой клиента — рассылка сорвана, заморозка 17.00 → 0.00;
без пометки — осталась «идёт», 17.00 зависли,
и в ОБОИХ случаях команда сказала «Сорвано: 1» и вернула успех.

Прогоны: ClientSms 249/249 (было 241, +8; 11 пачек, все с первой попытки),
приём лидов 17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался.
2026-07-29 09:44:42 +03:00

215 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\SendClientSmsCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsSettings;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строки листа 4.1 и 4.2: рассылка, которая идёт, но не двигается дольше срока,
* сама срывается и размораживает деньги — а рассылка, честно ждущая утра, НЕ трогается.
*
* Беда, от которой сторожим: работник очереди умирает посреди отправки (перезапуск
* сервера, обрыв). Снятие заморозки стоит ПОСЛЕДНИМ шагом джоба — до него он не
* доходит. Рассылка вечно «идёт», деньги клиента заморожены навсегда, в журнале тишина.
*
* 🪤 SharesSupplierPdo обязателен: сторож перечисляет рассылки служебным соединением,
* и без общего PDO оно не увидит незакоммиченных данных теста и найдёт НОЛЬ. Прогон
* при этом позеленеет там, где ждём ноль, — то есть соврёт в обе стороны (урок В-95).
*
* Помощники с префиксом stuck* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Телефоны только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
Carbon::setTestNow('2026-07-29 12:00:00');
config(['services.sms.sandbox' => true]);
Queue::fake();
});
afterEach(function () {
Carbon::setTestNow();
});
/** Рассылка в нужном состоянии и с нужным «возрастом» последнего движения. */
function stuckCampaign(
int $tenantId,
string $status,
string $updatedAt,
?string $retriedAt = null,
): ClientSmsCampaign {
$campaign = ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Проверка сторожа',
'body' => 'Текст',
'sender_name' => 'LIDERRA',
'source' => ClientSmsCampaign::SOURCE_MANUAL,
'status' => $status,
'idempotency_key' => 'stuck-'.uniqid(),
'segments' => 1,
'planned_count' => 2,
'sent_count' => 0,
'total_sms' => 2,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '17.00',
'watchdog_retried_at' => $retriedAt,
]);
// updated_at правим запросом: модель перезаписала бы его своим временем.
ClientSmsCampaign::whereKey($campaign->id)->update(['updated_at' => $updatedAt]);
return $campaign->fresh();
}
it('4.1 — рассылка без движения дольше срока сперва кладётся в очередь ещё раз, а не срывается', function () {
$tenant = Tenant::factory()->create();
$campaign = stuckCampaign($tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00');
$this->artisan('client-sms:watch-stuck')->assertSuccessful();
Queue::assertPushed(SendClientSmsCampaignJob::class, 1);
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_SENDING)
->and($campaign->watchdog_retried_at)->not->toBeNull();
});
it('4.1 — рассылка, не сдвинувшаяся и после попытки дожать, срывается с причиной «сторож»', function () {
$tenant = Tenant::factory()->create();
$campaign = stuckCampaign(
$tenant->id,
ClientSmsCampaign::STATUS_SENDING,
'2026-07-29 09:00:00',
'2026-07-29 10:00:00',
);
$this->artisan('client-sms:watch-stuck')->assertSuccessful();
Queue::assertNothingPushed();
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_WATCHDOG);
});
it('4.1 — попытке дожать дают время: свежая попытка рассылку не срывает', function () {
$tenant = Tenant::factory()->create();
$campaign = stuckCampaign(
$tenant->id,
ClientSmsCampaign::STATUS_SENDING,
'2026-07-29 09:00:00',
'2026-07-29 11:50:00',
);
$this->artisan('client-sms:watch-stuck')->assertSuccessful();
Queue::assertNothingPushed();
expect($campaign->fresh()->status)->toBe(ClientSmsCampaign::STATUS_SENDING);
});
it('4.2 — рассылка, честно ждущая утра, сторожем НЕ трогается', function () {
$tenant = Tenant::factory()->create();
$campaign = stuckCampaign($tenant->id, ClientSmsCampaign::STATUS_WAITING_WINDOW, '2026-07-25 09:00:00');
$this->artisan('client-sms:watch-stuck')->assertSuccessful();
Queue::assertNothingPushed();
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW)
->and($campaign->watchdog_retried_at)->toBeNull();
});
it('4.1 — движение считается по журналу: свежая запись спасает рассылку от сторожа', function () {
$tenant = Tenant::factory()->create();
$campaign = stuckCampaign($tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00');
ClientSmsMessage::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000001',
'status' => ClientSmsMessage::STATUS_FAKE_SENT,
'cost_rub' => '0.00',
'segments' => 1,
]);
ClientSmsMessage::where('campaign_id', $campaign->id)
->update(['created_at' => '2026-07-29 11:50:00']);
$this->artisan('client-sms:watch-stuck')->assertSuccessful();
Queue::assertNothingPushed();
expect($campaign->fresh()->status)->toBe(ClientSmsCampaign::STATUS_SENDING);
});
it('4.1 — срок берётся из настроек, а не из кода', function () {
$tenant = Tenant::factory()->create();
ClientSmsSettings::query()->update(['stuck_after_minutes' => 600]);
$campaign = stuckCampaign($tenant->id, ClientSmsCampaign::STATUS_SENDING, '2026-07-29 09:00:00');
$this->artisan('client-sms:watch-stuck')->assertSuccessful();
Queue::assertNothingPushed();
expect($campaign->fresh()->watchdog_retried_at)->toBeNull();
});
it('4.1 — сорванная рассылка отдаёт замороженные деньги обратно', function () {
config(['services.sms.sandbox' => false]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
$campaign = stuckCampaign(
$tenant->id,
ClientSmsCampaign::STATUS_SENDING,
'2026-07-29 09:00:00',
'2026-07-29 10:00:00',
);
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '17.00');
expect((string) AdWallet::where('tenant_id', $tenant->id)->value('frozen_rub'))->toBe('17.00');
$this->artisan('client-sms:watch-stuck')->assertSuccessful();
expect((string) AdWallet::where('tenant_id', $tenant->id)->value('frozen_rub'))->toBe('0.00');
});
it('4.1 — итог сорванной рассылки честный: сколько в журнале, столько и отправлено', function () {
$tenant = Tenant::factory()->create();
$campaign = stuckCampaign(
$tenant->id,
ClientSmsCampaign::STATUS_SENDING,
'2026-07-29 09:00:00',
'2026-07-29 10:00:00',
);
foreach (['79990000001', '79990000002'] as $phone) {
ClientSmsMessage::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => $phone,
'status' => ClientSmsMessage::STATUS_SENT,
'cost_rub' => '8.50',
'segments' => 1,
]);
}
// Записи старые — иначе рассылка считалась бы живой и сторож её не тронул бы.
ClientSmsMessage::where('campaign_id', $campaign->id)
->update(['created_at' => '2026-07-29 09:30:00']);
$this->artisan('client-sms:watch-stuck')->assertSuccessful();
$campaign->refresh();
expect($campaign->sent_count)->toBe(2)
->and((string) $campaign->actual_cost_rub)->toBe('17.00');
});