Files
portal/app/tests/Feature/ClientSms/SendJobTest.php
T
Дмитрий 1dece3a2b4 feat(смс-клиент): хвост «Отказ: liderra.ru/s/…» в тексте — цена считается уже с ним
Галочка «добавить возможность отказа» в рассылке, по умолчанию включена (спека §9.2).
Токен свой у каждого получателя, поэтому текст собирается на каждый номер отдельно
в момент отправки. Длина хвоста постоянная — число кусков и цена считаются один раз,
при создании рассылки, УЖЕ с хвостом: клиент видит ту длину, за которую заплатит.

Заготовка хвоста живёт в одном месте (сервис ссылок отказа), длина берётся из неё же —
число в код не вписано, разойтись расчёту и отправке нечем. Предпросмотр отдаёт второе
число (сколько кусков было бы без хвоста), чтобы экран мог сказать прямо: хвост перевёл
текст на второй кусок.

Строки приёмочного листа 1.10-1.13 (серверная часть). Защита проверена вырезанием:
считать цену без хвоста — 2 красных теста, не дописывать хвост при отправке — 1.

ClientSms 146/146, приём лидов 17/17, phpstan по своим файлам чисто.
2026-07-27 18:22:26 +03:00

332 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\ClientSms\ClientSmsUnsubscribeLinkService;
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),
app(ClientSmsUnsubscribeLinkService::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,
app(ClientSmsUnsubscribeLinkService::class),
);
$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),
app(ClientSmsUnsubscribeLinkService::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,
app(ClientSmsUnsubscribeLinkService::class),
);
$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, app(ClientSmsUnsubscribeLinkService::class),
);
} catch (RuntimeException) {
// ожидаемо: заход упал посреди рассылки
}
// Заход 2 (ретрай): рабочий провайдер — дошлёт остаток.
(new SendClientSmsCampaignJob($campaign->id, $tenant->id))->handle(
app(ClientSmsAudienceBuilder::class), realMtsSelector(), app(ClientSmsPricing::class), $wallet,
app(ClientSmsUnsubscribeLinkService::class),
);
// Оба номера отправлены — и ОБА оплачены (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),
app(ClientSmsUnsubscribeLinkService::class),
);
expect(ClientSmsMessage::count())->toBe(0);
});