Files
portal/app/tests/Feature/ClientSms/RecipientSelectorTest.php
T
Дмитрий 65e270583c feat(смс-клиент): шлём только абонентам МТС, Билайна, Мегафона и Теле2
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или
виртуального оператора в рассылку не берётся, и человек видит честную причину,
а не молчаливую пропажу.

Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный
канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём.

Список — настройкой, а не в коде: client_sms_settings.allowed_operators
(схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию.
Правило живёт в одном месте — AllowedSmsOperators.

Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок
и своей базы оператор известен в момент заказа, у номеров, вписанных руками,
его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится
уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали».

Плата за имя не тронута (В-150): в коде два похожих списка операторов, и
связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей.

Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём
только по МТС)» — универсальный канал возит всех.

Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка
24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент
ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн».
ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние,
vue-tsc 5 чужих давних, pint чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:25:37 +03:00

229 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\Models\ClientSmsMessage;
use App\Models\ClientSmsOptout;
use App\Models\SmsGlobalOptout;
use App\Models\Tenant;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\FakeSmsProvider;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Отбор получателей клиентской СМС-рассылки (Task 5). Получатели уже несут
* оператора (его определил ClientSmsAudienceBuilder) — здесь только стоп-лист,
* дубли внутри рассылки и маршрут. Никакого отсева по каналу отправителя —
* Фаза 1 всегда шлёт от имени liderra.ru.
*
* Роутер собран руками (не app()-синглтон): в песочнице singleton — заглушка,
* которая маршрутизирует всё подряд, и тест ничего бы не проверил.
*/
uses(RefreshDatabase::class);
function client_sms_selector(): ClientSmsRecipientSelector
{
$router = new SmsRouter([
new MtsSmsProvider(token: 'test', serves: ['mts'], priceKopecks: ['*' => 0]),
]);
return new ClientSmsRecipientSelector($router, new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer));
}
it('стоп-лист побеждает даже подходящий маршрут', function () {
$tenant = Tenant::factory()->create();
ClientSmsOptout::create([
'tenant_id' => $tenant->id,
'phone' => '79990000001',
]);
$plan = client_sms_selector()->build(
[['phone' => '79990000001', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000001' => ClientSmsMessage::SKIP_OPTOUT]);
});
it('номер МТС уходит в отправку через МТС-провайдера', function () {
$tenant = Tenant::factory()->create();
$plan = client_sms_selector()->build(
[['phone' => '79990000002', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->skipped)->toBeEmpty()
->and($plan->sendable)->toHaveCount(1)
->and($plan->sendable[0]['phone'])->toBe('79990000002')
->and($plan->sendable[0]['operator'])->toBe('mts')
->and($plan->sendable[0]['provider'])->not->toBeNull()
->and($plan->sendable[0]['provider']->key())->toBe('mts');
});
it('оператор без маршрута помечается skipped_no_route', function () {
$tenant = Tenant::factory()->create();
$plan = client_sms_selector()->build(
[['phone' => '79990000003', 'operator' => 'beeline']],
$tenant->id,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000003' => ClientSmsMessage::SKIP_NO_ROUTE]);
});
it('2.3 — номер без оператора получает причину «не определён оператор», а не «не из МТС»', function () {
$tenant = Tenant::factory()->create();
// Оператора в данных НЕТ: так приходят номера, вписанные руками, и номера
// «своей базы», которым ДаДата ещё не проставила оператора. Раньше им
// писалось «не из МТС» — а мы не знаем, чей это номер (строка листа 2.3).
$planNull = client_sms_selector()->build(
[['phone' => '79990000005', 'operator' => null]],
$tenant->id,
);
$planEmpty = client_sms_selector()->build(
[['phone' => '79990000007', 'operator' => ' ']],
$tenant->id,
);
expect($planNull->skipped)->toBe(['79990000005' => ClientSmsMessage::SKIP_UNKNOWN_OPERATOR])
->and($planEmpty->skipped)->toBe(['79990000007' => ClientSmsMessage::SKIP_UNKNOWN_OPERATOR]);
});
it('2.3 — оператор назван, но канала для него нет: это «не из МТС», а не «не определён»', function () {
$tenant = Tenant::factory()->create();
// 🪤 В-68: `canonical()` отдаёт пустую строку И когда оператора нет, И когда
// имя есть, но нашего словаря он не знает («Тинькофф Мобайл», МВНО). Делить
// по этой пустоте нельзя: про названного оператора «не определён» — новое
// враньё, только в другую сторону. Делим по СЫРОМУ значению.
//
// 🔴 Изменение 30.07 (журнал В-155, строка листа 4.14): мелкий оператор
// теперь выбывает РАНЬШЕ — на списке разрешённых, а не на маршруте, и
// причина у него своя. «Нет маршрута» осталось для оператора ИЗ четвёрки,
// которого сейчас некому везти: здесь роутер собран без универсального
// канала, и билайновский номер до него не доезжает.
$planKnown = client_sms_selector()->build(
[['phone' => '79990000006', 'operator' => 'beeline']],
$tenant->id,
);
$planMvno = client_sms_selector()->build(
[['phone' => '79990000008', 'operator' => 'Тинькофф Мобайл']],
$tenant->id,
);
expect($planKnown->skipped)->toBe(['79990000006' => ClientSmsMessage::SKIP_NO_ROUTE])
->and($planMvno->skipped)->toBe(['79990000008' => ClientSmsMessage::SKIP_OPERATOR_DENIED]);
});
it('2.3 — сторож: при универсальном канале номер без оператора УХОДИТ, а не отсеивается', function () {
$tenant = Tenant::factory()->create();
// 🪤 В-67, сторож к порядку проверок. Универсальный канал (СМС-центр,
// `serves: ['*']`, в песочнице — заглушка) берёт любой номер, в том числе без
// оператора. Если отсеивать «оператор не распознан» ДО вопроса маршрутизатору
// — как предлагал план Task 7 — такие номера перестанут уходить, и рассылка
// молча уменьшится. Сначала спрашиваем маршрут, причину называем только когда
// он отказал.
$universal = new ClientSmsRecipientSelector(
new SmsRouter([new FakeSmsProvider]),
new OperatorNormalizer,
new AllowedSmsOperators(new OperatorNormalizer),
);
$plan = $universal->build(
[['phone' => '79990000009', 'operator' => null]],
$tenant->id,
);
expect($plan->skipped)->toBeEmpty()
->and($plan->sendable)->toHaveCount(1)
->and($plan->sendable[0]['provider']->key())->toBe('fake');
});
it('дубль внутри одной рассылки считается один раз', function () {
$tenant = Tenant::factory()->create();
$plan = client_sms_selector()->build(
[
['phone' => '79990000004', 'operator' => 'mts'],
['phone' => '79990000004', 'operator' => 'mts'],
],
$tenant->id,
);
expect($plan->sendable)->toHaveCount(1)
->and($plan->skipped)->toBe(['79990000004' => ClientSmsMessage::SKIP_DUPLICATE]);
});
it('номер из общего стоп-листа портала не получает рассылку', function () {
$tenant = Tenant::factory()->create();
SmsGlobalOptout::create(['phone' => '79990000010', 'reason' => 'жалоба оператору']);
$plan = client_sms_selector()->build(
[['phone' => '79990000010', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000010' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
});
it('общий стоп-лист закрывает номер у ВСЕХ тенантов сразу', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
SmsGlobalOptout::create(['phone' => '79990000011']);
foreach ([$tenantA->id, $tenantB->id] as $tenantId) {
$plan = client_sms_selector()->build(
[['phone' => '79990000011', 'operator' => 'mts']],
$tenantId,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000011' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
}
});
it('общий стоп-лист проверяется РАНЬШЕ тенантского — причина видна именно общая', function () {
$tenant = Tenant::factory()->create();
SmsGlobalOptout::create(['phone' => '79990000012']);
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000012']);
$plan = client_sms_selector()->build(
[['phone' => '79990000012', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->skipped)->toBe(['79990000012' => ClientSmsMessage::SKIP_GLOBAL_OPTOUT]);
});
it('стоп-лист чужого тенанта не глушит тот же номер у своего', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
ClientSmsOptout::create([
'tenant_id' => $tenantB->id,
'phone' => '79990000007',
]);
$plan = client_sms_selector()->build(
[['phone' => '79990000007', 'operator' => 'mts']],
$tenantA->id,
);
expect($plan->skipped)->toBeEmpty()
->and($plan->sendable)->toHaveCount(1);
});