2026-07-25 20:59:56 +03:00
|
|
|
|
<?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;
|
2026-07-29 15:25:37 +03:00
|
|
|
|
use App\Services\ClientSms\AllowedSmsOperators;
|
2026-07-25 20:59:56 +03:00
|
|
|
|
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
2026-07-28 05:37:16 +03:00
|
|
|
|
use App\Services\ClientSms\ClientSmsSnapshotReader;
|
|
|
|
|
|
use App\Services\ClientSms\ClientSmsSnapshotWriter;
|
2026-07-25 20:59:56 +03:00
|
|
|
|
use App\Services\Sms\OperatorNormalizer;
|
|
|
|
|
|
use App\Services\Sms\Providers\MtsSmsProvider;
|
2026-07-26 10:27:14 +03:00
|
|
|
|
use App\Services\Sms\SmsOutgoing;
|
|
|
|
|
|
use App\Services\Sms\SmsProvider;
|
2026-07-25 20:59:56 +03:00
|
|
|
|
use App\Services\Sms\SmsRouter;
|
2026-07-26 10:27:14 +03:00
|
|
|
|
use App\Services\Sms\SmsSendResult;
|
|
|
|
|
|
use Carbon\CarbonImmutable;
|
2026-07-25 20:59:56 +03:00
|
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
2026-07-29 08:34:02 +03:00
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2026-07-25 20:59:56 +03:00
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Джоб отправки клиентской СМС-рассылки (Task 6) — денежно-критичный.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Очередь в бою бежит под ролью НЕ-BYPASSRLS: джоб сам ставит
|
|
|
|
|
|
* SET LOCAL app.current_tenant_id перед каждой денежной/читающей операцией.
|
|
|
|
|
|
* Деньги — только AdWalletService (bcmath, рубли), сеть — только под Http::fake.
|
|
|
|
|
|
* Синтетические номера 7999… — реальные НИКОГДА (в реальном режиме это боевая
|
|
|
|
|
|
* отправка).
|
|
|
|
|
|
*/
|
|
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
|
|
|
2026-07-28 15:46:53 +03:00
|
|
|
|
// Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем — иначе
|
|
|
|
|
|
// тест зависел бы от часа, в который его запустили (вечером все номера ждали бы утра).
|
|
|
|
|
|
// 09:00 UTC = полдень в Москве, окно открыто.
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
|
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-25 20:59:56 +03:00
|
|
|
|
/** Кампания в статусе 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'],
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Ставит канал, которым будут идти сообщения, и отдаёт его.
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🪤 Канал теперь выбирается ДВАЖДЫ: при создании рассылки — отборщиком, при
|
|
|
|
|
|
* отправке — читателем снимка. Читатель берёт роутер из контейнера, поэтому
|
|
|
|
|
|
* подменять надо именно роутер, а не один отборщик: иначе снимок соберётся,
|
|
|
|
|
|
* а отправится ноль, и денежный тест окажется пустым, оставаясь зелёным.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function bindSendJobRoute(?SmsProvider $provider = null): SmsRouter
|
|
|
|
|
|
{
|
|
|
|
|
|
$router = new SmsRouter([$provider ?? new MtsSmsProvider('x', ['mts'], ['*' => 0])]);
|
|
|
|
|
|
app()->instance(SmsRouter::class, $router);
|
|
|
|
|
|
|
|
|
|
|
|
return $router;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Снимок получателей — то, что в бою делает контроллер при создании рассылки
|
|
|
|
|
|
* (строки листа 2.1–2.2). Джоб аудиторию больше не собирает: без снимка
|
|
|
|
|
|
* рассылке просто некому уходить.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function makeSendJobSnapshot(ClientSmsCampaign $campaign): void
|
2026-07-25 20:59:56 +03:00
|
|
|
|
{
|
2026-07-29 15:25:37 +03:00
|
|
|
|
$selector = new ClientSmsRecipientSelector(app(SmsRouter::class), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer));
|
2026-07-28 05:37:16 +03:00
|
|
|
|
$recipients = app(ClientSmsAudienceBuilder::class)->build($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
app(ClientSmsSnapshotWriter::class)->write(
|
|
|
|
|
|
(int) $campaign->tenant_id,
|
|
|
|
|
|
(int) $campaign->id,
|
|
|
|
|
|
$selector->build($recipients, (int) $campaign->tenant_id),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Запуск джоба в его нынешнем виде: читатель снимка + цены + кошелёк. */
|
|
|
|
|
|
function runSendJob(int $campaignId, int $tenantId, ?AdWalletService $wallet = null): void
|
|
|
|
|
|
{
|
|
|
|
|
|
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
|
|
|
|
|
|
app(ClientSmsSnapshotReader::class),
|
|
|
|
|
|
app(ClientSmsPricing::class),
|
|
|
|
|
|
$wallet ?? app(AdWalletService::class),
|
2026-07-25 20:59:56 +03:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('sandbox: отправляет через заглушку, стоп-лист пропускает, деньги НЕ трогает', function () {
|
|
|
|
|
|
config(['services.sms.sandbox' => true]);
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
|
|
|
|
|
|
|
2026-07-28 15:46:53 +03:00
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000009', 'name' => 'В', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000009']);
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeSendJobCampaign($tenant->id, 2);
|
2026-07-28 05:37:16 +03:00
|
|
|
|
makeSendJobSnapshot($campaign);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
runSendJob($campaign->id, $tenant->id);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
|
|
|
|
|
$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)
|
2026-07-28 20:28:00 +03:00
|
|
|
|
->and((string) $messages->get('79990000001')->cost_rub)->toBe('0.00')
|
|
|
|
|
|
// Журнал помнит, СКОЛЬКО СМС ушло этим сообщением: длинное письмо — это два,
|
|
|
|
|
|
// и накопленный за месяц объём считается в СМС, а не в строках (В-108).
|
|
|
|
|
|
->and($messages->get('79990000001')->segments)->toBe((int) $campaign->segments);
|
2026-07-25 20:59:56 +03: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');
|
|
|
|
|
|
|
2026-07-28 15:46:53 +03:00
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000003', 'name' => 'В', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
bindSendJobRoute();
|
2026-07-25 20:59:56 +03:00
|
|
|
|
$campaign = makeSendJobCampaign($tenant->id, 3);
|
2026-07-28 05:37:16 +03:00
|
|
|
|
makeSendJobSnapshot($campaign);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
|
|
|
|
|
// Заморозка на этапе создания рассылки (смета = цена × получатели × сегменты).
|
|
|
|
|
|
$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');
|
|
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
runSendJob($campaign->id, $tenant->id, $wallet);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
|
|
|
|
|
$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');
|
|
|
|
|
|
|
2026-07-28 15:46:53 +03:00
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
|
|
|
|
|
$campaign = makeSendJobCampaign($tenant->id, 2);
|
2026-07-28 05:37:16 +03:00
|
|
|
|
makeSendJobSnapshot($campaign);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
$run = fn () => runSendJob($campaign->id, $tenant->id);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
|
|
|
|
|
$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');
|
|
|
|
|
|
|
2026-07-28 15:46:53 +03:00
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
bindSendJobRoute();
|
2026-07-25 20:59:56 +03:00
|
|
|
|
$campaign = makeSendJobCampaign($tenant->id, 2);
|
2026-07-28 05:37:16 +03:00
|
|
|
|
makeSendJobSnapshot($campaign);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '17.00'); // 8.50 × 2
|
|
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
$run = fn () => runSendJob($campaign->id, $tenant->id, $wallet);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
|
|
|
|
|
$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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-26 10:27:14 +03: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');
|
|
|
|
|
|
|
2026-07-28 15:46:53 +03:00
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
|
|
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
|
2026-07-26 10:27:14 +03:00
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
bindSendJobRoute();
|
2026-07-26 10:27:14 +03:00
|
|
|
|
$campaign = makeSendJobCampaign($tenant->id, 2);
|
2026-07-28 05:37:16 +03:00
|
|
|
|
makeSendJobSnapshot($campaign);
|
2026-07-26 10:27:14 +03:00
|
|
|
|
$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());
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-07-28 05:37:16 +03:00
|
|
|
|
bindSendJobRoute($flaky);
|
2026-07-26 10:27:14 +03:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-07-28 05:37:16 +03:00
|
|
|
|
runSendJob($campaign->id, $tenant->id, $wallet);
|
2026-07-26 10:27:14 +03:00
|
|
|
|
} catch (RuntimeException) {
|
|
|
|
|
|
// ожидаемо: заход упал посреди рассылки
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Заход 2 (ретрай): рабочий провайдер — дошлёт остаток.
|
2026-07-28 05:37:16 +03:00
|
|
|
|
bindSendJobRoute();
|
|
|
|
|
|
runSendJob($campaign->id, $tenant->id, $wallet);
|
2026-07-26 10:27:14 +03:00
|
|
|
|
|
|
|
|
|
|
// Оба номера отправлены — и ОБА оплачены (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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-25 20:59:56 +03:00
|
|
|
|
it('нет кампании: джоб тихо выходит без исключения', function () {
|
|
|
|
|
|
config(['services.sms.sandbox' => true]);
|
|
|
|
|
|
|
|
|
|
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
|
|
|
2026-07-28 05:37:16 +03:00
|
|
|
|
runSendJob(999999, $tenant->id);
|
2026-07-25 20:59:56 +03:00
|
|
|
|
|
|
|
|
|
|
expect(ClientSmsMessage::count())->toBe(0);
|
|
|
|
|
|
});
|
2026-07-29 08:34:02 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* В-125 — блокер выката: снимок читался ВНЕ пометки клиента.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Почему это нельзя проверить «в лоб». На бою очередь ходит в базу ролью, которая
|
|
|
|
|
|
* подчиняется изоляции по клиенту, и без пометки база отдаёт ноль строк — молча, без
|
|
|
|
|
|
* ошибки. Локально и в тестах мы ходим суперпользователем, для которого изоляции не
|
|
|
|
|
|
* существует: замер показал 1 строку у суперпользователя и 0 у боевой роли на ОДНОЙ И
|
|
|
|
|
|
* ТОЙ ЖЕ строке снимка. Значит поймать дыру «по количеству отправленных» тест не может
|
|
|
|
|
|
* физически — он всегда зелёный.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Поэтому проверяем не результат, а ПОРЯДОК: каждый запрос к снимку обязан идти внутри
|
|
|
|
|
|
* той же открытой транзакции, в которой уже выставлена пометка клиента. Уровень
|
|
|
|
|
|
* вложенности транзакции — то, что отличает «пометка стоит здесь и сейчас» от «пометка
|
|
|
|
|
|
* стояла раньше, в другой, уже закрытой транзакции»: SET LOCAL живёт только до её конца.
|
|
|
|
|
|
*/
|
|
|
|
|
|
it('В-125 — снимок читается только под пометкой клиента, иначе на бою тихий ноль', 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' => 'МТС', 'tz_offset_minutes' => 180]);
|
|
|
|
|
|
|
|
|
|
|
|
$campaign = makeSendJobCampaign($tenant->id, 1);
|
|
|
|
|
|
makeSendJobSnapshot($campaign);
|
|
|
|
|
|
|
|
|
|
|
|
$reads = [];
|
|
|
|
|
|
$ctxLevel = null;
|
|
|
|
|
|
|
|
|
|
|
|
DB::listen(function ($query) use (&$reads, &$ctxLevel) {
|
|
|
|
|
|
$level = DB::transactionLevel();
|
|
|
|
|
|
|
|
|
|
|
|
// Транзакция, в которой ставили пометку, закрылась — пометки больше нет.
|
|
|
|
|
|
if ($ctxLevel !== null && $level < $ctxLevel) {
|
|
|
|
|
|
$ctxLevel = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (str_contains($query->sql, 'app.current_tenant_id')) {
|
|
|
|
|
|
$ctxLevel = $level;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (str_contains($query->sql, 'client_sms_campaign_phones')
|
|
|
|
|
|
&& str_starts_with(ltrim(strtolower($query->sql)), 'select')) {
|
|
|
|
|
|
$reads[] = $ctxLevel !== null;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
runSendJob($campaign->id, $tenant->id);
|
|
|
|
|
|
|
|
|
|
|
|
// Сначала убеждаемся, что сравниваем не ноль с нулём: снимок реально читали и
|
|
|
|
|
|
// номер реально ушёл. Без этого тест был бы зелёным на пустом месте (урок В-46).
|
|
|
|
|
|
expect($reads)->not->toBeEmpty()
|
|
|
|
|
|
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
|
|
|
|
|
|
->where('status', ClientSmsMessage::STATUS_FAKE_SENT)->count())->toBe(1);
|
|
|
|
|
|
|
|
|
|
|
|
expect(array_filter($reads, fn ($underContext) => $underContext === false))->toBe([]);
|
|
|
|
|
|
});
|