travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); }); /** Кампания в статусе 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 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 { $selector = new ClientSmsRecipientSelector(app(SmsRouter::class), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer)); $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), ); } 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' => 'МТС', '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]); ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000009']); $campaign = makeSendJobCampaign($tenant->id, 2); makeSendJobSnapshot($campaign); runSendJob($campaign->id, $tenant->id); $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') // Журнал помнит, СКОЛЬКО СМС ушло этим сообщением: длинное письмо — это два, // и накопленный за месяц объём считается в СМС, а не в строках (В-108). ->and($messages->get('79990000001')->segments)->toBe((int) $campaign->segments); $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' => 'МТС', '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]); bindSendJobRoute(); $campaign = makeSendJobCampaign($tenant->id, 3); makeSendJobSnapshot($campaign); // Заморозка на этапе создания рассылки (смета = цена × получатели × сегменты). $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'); runSendJob($campaign->id, $tenant->id, $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' => 'МТС', 'tz_offset_minutes' => 180]); ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]); $campaign = makeSendJobCampaign($tenant->id, 2); makeSendJobSnapshot($campaign); $run = fn () => runSendJob($campaign->id, $tenant->id); $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' => 'МТС', 'tz_offset_minutes' => 180]); ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]); bindSendJobRoute(); $campaign = makeSendJobCampaign($tenant->id, 2); makeSendJobSnapshot($campaign); $wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '17.00'); // 8.50 × 2 $run = fn () => runSendJob($campaign->id, $tenant->id, $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' => 'МТС', 'tz_offset_minutes' => 180]); ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]); bindSendJobRoute(); $campaign = makeSendJobCampaign($tenant->id, 2); makeSendJobSnapshot($campaign); $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()); } }; bindSendJobRoute($flaky); try { runSendJob($campaign->id, $tenant->id, $wallet); } catch (RuntimeException) { // ожидаемо: заход упал посреди рассылки } // Заход 2 (ретрай): рабочий провайдер — дошлёт остаток. bindSendJobRoute(); runSendJob($campaign->id, $tenant->id, $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(); runSendJob(999999, $tenant->id); expect(ClientSmsMessage::count())->toBe(0); }); /** * В-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([]); });