Files
portal/app/tests/Feature/ClientSms/RecipientSelectorTest.php
T
Дмитрий 5b7d6e1bab fix(смс-клиент): «не определён оператор» вместо неправды «номер не из МТС»
Строка приёмочного листа 2.3. Раньше номер с НЕИЗВЕСТНЫМ оператором и номер
чужого оператора давали одну причину, и клиент читал про свой номер неправду:
«не из МТС» — хотя чей он, мы не знаем. Теперь причины две, и обе правдивы.

Так приходит большинство таких номеров: вписанные руками — без оператора
всегда, номера «своей базы» — до того, как ДаДата его проставит.

Причина живёт в четырёх местах, а не в трёх, как считал план: отборщик,
читатель снимка (журнал рассылки) и ДВА словаря подписей на экране. Счётчик в
контроллере складывает любые причины — править нечего.

Два капкана, оба доказаны вырезанием, а не рассуждением:

Порядок проверок. Отсеивать номер сразу, как только оператор не распознан (так
велел план), нельзя: универсальный канал берёт и номер без оператора, и такие
номера перестали бы уходить — рассылка молча уменьшилась бы при зелёных тестах.
Сперва спрашиваем маршрутизатор, причину называем только после отказа.
Поведение не изменилось ни на волос — изменилась надпись.

Деление. Словарь операторов отдаёт пустоту и когда оператора нет, и когда имя
есть, но словарь его не знает («Тинькофф Мобайл»). Делим по сырому значению,
иначе получилось бы новое враньё в другую сторону.

Тесты: +5 (три причины врозь, сторож универсального канала, журнал рассылки —
чтобы предпросмотр и журнал не расходились в словах). ClientSms 165/165, приём
лидов 17/17, фронт 1656 зелёных, phpstan 0, pint и eslint чисто.

Живой прогон: одна сводка сразу показывает и правду, и контроль — «Уйдёт 1 СМС
— 9.00 ₽ · Не уйдёт: не определён оператор — не знаем, куда слать — 1 · номер
не из МТС (пока шлём только по МТС) — 1». В журнале рассылки та же правда.

Экран пока НЕ подсказывает, что оператор ещё выясняется — отдельная работа,
записана в «Чего эта работа НЕ делает» п.16.
2026-07-28 10:58:45 +03:00

221 lines
9.2 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\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);
}
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()` отдаёт пустую строку И когда оператора нет, И когда
// имя есть, но нашего словаря он не знает («Тинькофф Мобайл», МВНО). Делить
// по этой пустоте нельзя: про названного оператора «не определён» — новое
// враньё, только в другую сторону. Делим по СЫРОМУ значению.
$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_NO_ROUTE]);
});
it('2.3 — сторож: при универсальном канале номер без оператора УХОДИТ, а не отсеивается', function () {
$tenant = Tenant::factory()->create();
// 🪤 В-67, сторож к порядку проверок. Универсальный канал (СМС-центр,
// `serves: ['*']`, в песочнице — заглушка) берёт любой номер, в том числе без
// оператора. Если отсеивать «оператор не распознан» ДО вопроса маршрутизатору
// — как предлагал план Task 7 — такие номера перестанут уходить, и рассылка
// молча уменьшится. Сначала спрашиваем маршрут, причину называем только когда
// он отказал.
$universal = new ClientSmsRecipientSelector(
new SmsRouter([new FakeSmsProvider]),
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);
});