Files
portal/app/tests/Feature/ClientSms/WatchStuckCampaignTest.php
T

215 lines
8.8 KiB
PHP
Raw Normal View History

<?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');
});