3512479e32
Решение владельца 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>
271 lines
12 KiB
PHP
271 lines
12 KiB
PHP
<?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);
|
||
});
|