Files
portal/app/tests/Feature/ClientSms/SendJobTest.php
T

513 lines
24 KiB
PHP
Raw Normal View History

<?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\ClientSmsSender;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\AllowedSmsOperators;
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\DB;
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, 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([]);
});
/**
* Имя отправителя выбирается ПО ОПЕРАТОРУ получателя, а не одно на всех.
*
* До 05.08.2026 джоб слал `campaign.sender_name` каждому каналу, а умолчанием
* этого поля служило `services.sms.mts.naming` — имя, согласованное с МТС.
* Теле2 отвечал на него `invalid_source_address` (живой отказ 04.08.2026).
*/
// Без объявленного типа возврата НАМЕРЕННО: с ним анализатор видит только
// интерфейс и теряет поле $seenSenderNames, ради которого канал и заведён.
function recordingSenderProvider(string $key, array $serves)
{
return new class($key, $serves) implements SmsProvider
{
/** @var list<string> */
public array $seenSenderNames = [];
public function __construct(private string $k, private array $serves) {}
public function key(): string
{
return $this->k;
}
public function servesOperators(): array
{
return $this->serves;
}
public function priceKopecks(string $operator): int
{
return 0;
}
public function send(SmsOutgoing $message): SmsSendResult
{
$this->seenSenderNames[] = $message->senderName;
return new SmsSendResult('MSG-SENDER-1', $message->segments, 0, CarbonImmutable::now());
}
};
}
it('имя клиента согласовано у ДРУГОГО оператора — каналу отдаём пустое, чтобы он подписался своим', function () {
config(['services.sms.sandbox' => false]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
// Своё имя согласовано только у Теле2, а шлём в сеть МТС.
ClientSmsSender::create([
'tenant_id' => $tenant->id,
'name' => 'mybrand.ru',
'name_type' => ClientSmsSender::TYPE_WEBSITE,
'status' => ClientSmsSender::STATUS_ACTIVE,
'operators' => ['tele2'],
'monthly_fee_rub' => '0.00',
'consent_at' => now(),
]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'name' => 'А', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
$provider = recordingSenderProvider('mts', ['mts']);
bindSendJobRoute($provider);
$campaign = makeSendJobCampaign($tenant->id, 1);
makeSendJobSnapshot($campaign);
runSendJob($campaign->id, $tenant->id);
// Сначала убеждаемся, что сравниваем не пустоту с пустотой: номер реально ушёл.
expect($provider->seenSenderNames)->toHaveCount(1);
// И имя МТС-кампании клиенту не подставили — канал подпишется своим.
expect($provider->seenSenderNames[0])->toBe('');
});
it('имя клиента согласовано у ЭТОГО оператора — им и подписываем', function () {
config(['services.sms.sandbox' => false]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
ClientSmsSender::create([
'tenant_id' => $tenant->id,
'name' => 'mybrand.ru',
'name_type' => ClientSmsSender::TYPE_WEBSITE,
'status' => ClientSmsSender::STATUS_ACTIVE,
'operators' => ['mts'],
'monthly_fee_rub' => '0.00',
'consent_at' => now(),
]);
ClientSmsContact::create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'name' => 'Б', 'operator' => 'МТС', 'tz_offset_minutes' => 180]);
$provider = recordingSenderProvider('mts', ['mts']);
bindSendJobRoute($provider);
$campaign = makeSendJobCampaign($tenant->id, 1);
makeSendJobSnapshot($campaign);
runSendJob($campaign->id, $tenant->id);
expect($provider->seenSenderNames)->toBe(['mybrand.ru']);
});