Files
portal/app/tests/Feature/ClientSms/SendJobTest.php
T
Дмитрий 1d8723f285 feat(смс-клиент): Калининград и Камчатка получают СМС каждый в своё утро
Этап 3 «Время и цена», Task 2. Строки листа 3.1, 3.2 и первая половина 3.3.

У каждой строки снимка получателей появились две вещи: часовой пояс человека и момент,
раньше которого сообщение отдавать нельзя. Считается это ОДИН раз, при создании рассылки:
снимок сильнее всего (В-39), а на 20 000 номерах пересчёт на каждом витке джоба был бы
20 000 лишних расчётов.

Три состояния строки, и их важно не путать:
  — пояс известен, ждать нечего  → отдаём прямо сейчас;
  — пояс известен, время не пришло → ждёт своего утра;
  — пояса нет                     → ждёт уточнения региона и НЕ уходит вовсе (В-85).

Пустой пояс больше не означает «шлём по Москве». Он означает «мы не знаем, где человек
живёт», и такому номеру СМС не уходит. Про него при этом НЕ пишется в журнал рассылки
«нет маршрута»: мы его даже не пробовали отправить, и врать про него нельзя.

Рассылка, у которой часть номеров ещё ждёт, получает статус «ждёт утра» вместо «готово»,
и заморозка денег с неё не снимается — смета считалась на всех, оставшимся деньги ещё
понадобятся. Счётчик отправленного при этом обновляется: он считается из журнала, то есть
всегда правда, и человеку нужно видеть «отправлено 340 из 900» сразу, а не завтра (В-94).

Регион сделки читается из subject_code, а НЕ из region_code: последний в бою не пишет никто,
а в dev там демо-значения, и мы бы считали половину страны Тюменью — молча (В-82).

Прогоны: СМС 191/191 (было 186, 5 новых тестов), приём лидов 17/17, phpstan 0, pint чисто.
Вырезанием проверено дважды: убрал проверку «пояс известен» — покраснел тест про номер без
региона; убрал проверку «время пришло» — покраснели три теста про окно. Живьём на локальной
базе: две сделки (Москва и Камчатка) плюс пять демо-сделок без региона → ушёл один москвич,
Камчатке проставлено ожидание до 22:00 UTC (10 утра её времени), пятеро ждут региона,
рассылка висит «ждёт утра». Стенд возвращён как был.

В-93: у 19 старых тестов покраснение было закономерным — у их номеров нет региона. Боевой код
не ослаблял: фикстурам проставил регион и зафиксировал время прогона, иначе тесты зависели бы
от часа запуска. ⚠️ Ветку нельзя выкатывать между этой задачей и Task 5: пока ДаДата не начнёт
давать регион всем номерам, рассылка по своей базе и по списку руками отправит ноль.

Запись схемы v9.13.
2026-07-28 15:46:53 +03:00

346 lines
16 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\ClientSmsSnapshotReader;
use App\Services\ClientSms\ClientSmsSnapshotWriter;
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);
// Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем — иначе
// тест зависел бы от часа, в который его запустили (вечером все номера ждали бы утра).
// 09:00 UTC = полдень в Москве, окно открыто.
beforeEach(function () {
$this->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);
$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');
$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);
});