Files
portal/app/tests/Feature/ClientSms/RecipientSelectorTest.php
T
Дмитрий 3512479e32
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(смс): своё имя есть, но у этой сети не согласовано — номер не уходит вовсе
Решение владельца 05.08.2026. Прежний вариант (подписать нашим «Liderra.ru»)
подменял бренд, за который клиент заплатил, причём незаметно: журнал сообщений
имя отправителя не хранит вовсе. Так уже работает отдел продаж
(SmsRecipientSelector → SKIP_NO_SENDER), и надпись на клиентском экране
(«нет имени отправителя для оператора») давно лежала готовая — её просто никогда
не показывали.

Два случая разведены НАМЕРЕННО и закреплены отдельными тестами:
  • своего имени нет вовсе (или приостановлено) — шлём от имени Лидерры, как
    и раньше. Смешай их — и рассылка встала бы у ВСЕХ разом: на бою своих имён
    заведено НОЛЬ;
  • своё имя есть, но у этой сети не согласовано — номер не уходит.

Отсев в ОТБОРЩИКЕ, а не при отправке: деньги не замораживаются, и причина видна
клиенту в предпросмотре — до оплаты.

🪤 Полный прогон поймал мою ошибку, которую выборочные тесты не видели: имя
спрашивалось ПО КАЖДОМУ номеру — 20 000 запросов в базу, отбор 20 000 получателей
вырос с 20 до 29 секунд и упёрся в сторожа скорости. Добавлена памятка на тенанта
(та же защита, что у AllowedSmsOperators, который прямо об этом предупреждает).
После лечения — 1,47 с.

Два теста часовой давности переписаны: они закрепляли ПРЕЖНЕЕ решение и после
смены решения стали неправдой. Теперь проверяют и «в канал не ушло ничего», и
«деньги целы».

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

Проверено: 4621 тест, упал один чужой (ExampleTest — в рабочей папке не собран
фронтенд). Статанализ 0. Стиль чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:21:01 +03:00

271 lines
12 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\ClientSmsSender;
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) — здесь стоп-лист, дубли
* внутри рассылки, маршрут и — с 05.08.2026 — имя отправителя.
*
* Отсев по имени добавлен решением владельца: если у клиента СВОЁ имя есть, но у
* этой сети не согласовано, номер не уходит вовсе. Раньше сюда это не заглядывало
* («Фаза 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();
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(),
]);
$plan = client_sms_selector()->build(
[['phone' => '79990000041', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped)->toBe(['79990000041' => ClientSmsMessage::SKIP_NO_SENDER]);
});
it('своего имени нет вовсе — номер уходит от имени Лидерры, как и раньше', function () {
// Страховка от главной беды этой правки: на бою своих имён заведено НОЛЬ.
// Заблокируй мы и этот случай — рассылка встала бы у всех разом.
$tenant = Tenant::factory()->create();
$plan = client_sms_selector()->build(
[['phone' => '79990000042', 'operator' => 'mts']],
$tenant->id,
);
expect($plan->skipped)->toBeEmpty()
->and($plan->sendable)->toHaveCount(1);
});
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);
});