Files
portal/app/tests/Feature/ClientSms/SendJobTest.php
T
Дмитрий 5d423be226 feat(смс-клиент): джоб отправки — деньги через кошелёк, песочница, retry
SendClientSmsCampaignJob: single-tenant под crm_app_user (SET LOCAL перед
каждой операцией; сеть вне транзакций). Списание/снятие заморозки через
AdWalletService (канал sms, рубли, идемпотентно по external_key). Песочница
не двигает деньги. failed() снимает заморозку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 20:59:56 +03:00

245 lines
10 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\SmsRouter;
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' => 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);
});