Files
portal/app/tests/Feature/ClientSms/SendJobTest.php
T
Дмитрий c47c3fb4d5 fix(смс-клиент): недосписание при повторе, срок для рассылки по сделкам, заслонка баланса + понятный интерфейс
Корректность/деньги:
- джоб рассылки: списание ПОШТУЧНО и атомарно с записью в журнал, ключ идемпотентности на номер, факт из БД — нет недосписания при падении посреди рассылки и повторе
- валидация: срок обязателен при source=deals, иначе выборка по всей истории сделок
- «Отправить» гаснет при нехватке свободного баланса; index отдаёт баланс/заморозку
- помесячный джоб имени: устойчив к пустым настройкам, сигнал в лог при автоотключении за долг
- цена одного авто-СМС + подтверждение при включении авто-рассылки
- загрузка контактов сообщает число нераспознанных номеров

Понятность для клиента:
- «СМС» вместо «сегментов», «пробный режим» вместо «песочницы», человеческие причины пропуска, шаги 1-2-3, выгода своего имени, факт вместо оценки в таблице, имя и текст в подтверждении

Тесты: бэкенд ClientSms 83/83, фронт СМС 50/50, приём лидов 10/10; pint/stan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 10:27:14 +03:00

325 lines
14 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\SendClientSmsCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsContact;
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsOptout;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsProvider;
use App\Services\Sms\SmsRouter;
use App\Services\Sms\SmsSendResult;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
/**
* Джоб отправки клиентской СМС-рассылки (Task 6) — денежно-критичный.
*
* Очередь в бою бежит под ролью НЕ-BYPASSRLS: джоб сам ставит
* SET LOCAL app.current_tenant_id перед каждой денежной/читающей операцией.
* Деньги — только AdWalletService (bcmath, рубли), сеть — только под Http::fake.
* Синтетические номера 7999… — реальные НИКОГДА (в реальном режиме это боевая
* отправка).
*/
uses(RefreshDatabase::class);
/** Кампания в статусе queued со снимком цены/сегментов (как её создаёт контроллер). */
function makeSendJobCampaign(int $tenantId, int $totalSms): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Рассылка',
'body' => 'Здравствуйте!',
'sender_name' => 'liderra.ru',
'source' => ClientSmsCampaign::SOURCE_BASE,
'audience_days' => null,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'segments' => 1,
'planned_count' => $totalSms,
'sent_count' => 0,
'total_sms' => $totalSms,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '0.00',
'actual_cost_rub' => null,
'created_by' => null,
]);
}
/** Валидный успешный ответ МТС (омни-адаптер api.mts.ru), см. MtsSmsProvider::send. */
function mtsSuccessResponse(): array
{
return [
'code' => 0,
'data' => [
'submitResults' => [
['msid' => '79990000001', 'messageID' => 'MSG-OK-1', 'code' => 'OK'],
],
],
];
}
/** Селектор с настоящим МТС-роутером — детерминированный маршрут для реального режима. */
function realMtsSelector(): ClientSmsRecipientSelector
{
return new ClientSmsRecipientSelector(
new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]),
new OperatorNormalizer,
);
}
it('sandbox: отправляет через заглушку, стоп-лист пропускает, деньги НЕ трогает', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000009', 'name' => 'В', 'operator' => 'МТС']);
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000009']);
$campaign = makeSendJobCampaign($tenant->id, 2);
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
);
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and($campaign->sent_count)->toBe(2)
->and((string) $campaign->actual_cost_rub)->toBe('0.00');
$messages = ClientSmsMessage::where('campaign_id', $campaign->id)->get()->keyBy('phone');
expect($messages->get('79990000001')->status)->toBe(ClientSmsMessage::STATUS_FAKE_SENT)
->and($messages->get('79990000002')->status)->toBe(ClientSmsMessage::STATUS_FAKE_SENT)
->and($messages->get('79990000009')->status)->toBe(ClientSmsMessage::SKIP_OPTOUT)
->and((string) $messages->get('79990000001')->cost_rub)->toBe('0.00');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('1000.00')
->and((string) $wallet->frozen_rub)->toBe('0.00');
});
it('реальный режим: списывает по факту, снимает заморозку, номера уходят через МТС', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(mtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000003', 'name' => 'В', 'operator' => 'МТС']);
$campaign = makeSendJobCampaign($tenant->id, 3);
// Заморозка на этапе создания рассылки (смета = цена × получатели × сегменты).
$estimate = '25.50'; // 8.50 × 3 × 1
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, $estimate);
$walletBefore = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $walletBefore->frozen_rub)->toBe('25.50');
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
realMtsSelector(),
app(ClientSmsPricing::class),
$wallet,
);
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and($campaign->sent_count)->toBe(3)
->and((string) $campaign->actual_cost_rub)->toBe('25.50');
$messages = ClientSmsMessage::where('campaign_id', $campaign->id)->get();
expect($messages)->toHaveCount(3);
foreach ($messages as $m) {
expect($m->status)->toBe(ClientSmsMessage::STATUS_SENT)
->and($m->provider_key)->toBe('mts')
->and($m->provider_message_id)->toBe('MSG-OK-1')
->and((string) $m->cost_rub)->toBe('8.50');
}
$walletAfter = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $walletAfter->balance_rub)->toBe('974.50') // 1000 25.50
->and((string) $walletAfter->frozen_rub)->toBe('0.00'); // заморозка снята
});
it('идемпотентность: повторный запуск не плодит сообщения и не трогает баланс (sandbox)', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС']);
$campaign = makeSendJobCampaign($tenant->id, 2);
$run = fn () => (new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
);
$run();
$countAfterFirst = ClientSmsMessage::where('campaign_id', $campaign->id)->count();
$run();
$countAfterSecond = ClientSmsMessage::where('campaign_id', $campaign->id)->count();
expect($countAfterFirst)->toBe(2)
->and($countAfterSecond)->toBe(2);
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and($campaign->sent_count)->toBe(2); // не удвоилось
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('1000.00');
});
it('идемпотентность реального режима: второй запуск не списывает повторно (external_key)', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(mtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС']);
$campaign = makeSendJobCampaign($tenant->id, 2);
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '17.00'); // 8.50 × 2
$run = fn () => (new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class),
realMtsSelector(),
app(ClientSmsPricing::class),
$wallet,
);
$run();
$balanceAfterFirst = (string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
$run();
$balanceAfterSecond = (string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
expect($balanceAfterFirst)->toBe('983.00') // 1000 17.00
->and($balanceAfterSecond)->toBe('983.00') // без повторного списания
->and(ClientSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
});
it('факт списывается ПОЛНОСТЬЮ при падении посреди рассылки и повторе — бизнес не теряет деньги', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(mtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС']);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС']);
$campaign = makeSendJobCampaign($tenant->id, 2);
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '17.00'); // 8.50 × 2
// Заход 1: провайдер отправляет ПЕРВЫЙ номер, затем падает нежданной ошибкой
// (НЕ SmsSendException — джоб не ловит) → прерывается ДО финализации.
// Первый номер уже ушёл и должен быть оплачен, иначе при повторе он не спишется.
$flaky = new class implements SmsProvider
{
public int $calls = 0;
public function key(): string
{
return 'mts';
}
public function servesOperators(): array
{
return ['mts'];
}
public function priceKopecks(string $operator): int
{
return 0;
}
public function send(SmsOutgoing $message): SmsSendResult
{
$this->calls++;
if ($this->calls >= 2) {
throw new RuntimeException('обрыв связи с МТС посреди рассылки');
}
return new SmsSendResult('MSG-FLAKY-1', $message->segments, 0, CarbonImmutable::now());
}
};
$flakySelector = new ClientSmsRecipientSelector(new SmsRouter([$flaky]), new OperatorNormalizer);
try {
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class), $flakySelector, app(ClientSmsPricing::class), $wallet,
);
} catch (RuntimeException) {
// ожидаемо: заход упал посреди рассылки
}
// Заход 2 (ретрай): рабочий провайдер — дошлёт остаток.
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class), realMtsSelector(), app(ClientSmsPricing::class), $wallet,
);
// Оба номера отправлены — и ОБА оплачены (2 × 8.50 = 17.00), а не только один.
$sent = ClientSmsMessage::where('campaign_id', $campaign->id)
->whereIn('status', [ClientSmsMessage::STATUS_SENT, ClientSmsMessage::STATUS_FAKE_SENT])
->count();
expect($sent)->toBe(2);
$walletAfter = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $walletAfter->balance_rub)->toBe('983.00') // 1000 17.00, НЕ 991.50
->and((string) $walletAfter->frozen_rub)->toBe('0.00');
$campaign->refresh();
expect($campaign->sent_count)->toBe(2)
->and((string) $campaign->actual_cost_rub)->toBe('17.00');
});
it('нет кампании: джоб тихо выходит без исключения', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
$job = new SendClientSmsCampaignJob(999999, $tenant->id);
$job->handle(
app(ClientSmsAudienceBuilder::class),
app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
);
expect(ClientSmsMessage::count())->toBe(0);
});