215 lines
8.8 KiB
PHP
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');
|
||
|
|
});
|