Files
portal/app/tests/Feature/ClientSms/MotivTest.php
T
Дмитрий a159bf73cb feat,смс: портал научился сети Мотива — маршрут через Билайна на выключателе
Номер абонента Мотива не доходил даже до каналов: словарь операторов о Мотиве
не знал, и номер выбывал на первой заставе «кому мы вообще шлём».

Что сделано:
· словарь знает ОБА сырых написания — торговое «Мотив» от ДаДаты и юридическое
  ООО "ЕКАТЕРИНБУРГ-2000" из реестра Россвязи, где слова «Мотив» нет ни разу
  замер по DEF-9xx.csv: 29 диапазонов, среди 89 имён реестра столкновений нет;
· Мотив добавлен к четвёрке владельца — сетей стало пять;
· маршрут через Билайна стоит на выключателе SMS_BEELINE_SERVES_MOTIV,
  умолчание ВЫКЛЮЧЕНО: до одобрения имени отправка вернёт 20230, а этот отказ
  окончательный — повтора нет, сообщение умрёт;
· цена по сети, а не одна на канал: 770 копеек за Мотива. Экран сверки тоже
  научен считать по сети — иначе занижал бы расход на 1,84 ₽ с сообщения;
· проверка ввода настроек и галки в админке пропускают Мотива: без этого
  владелец не смог бы его отметить, а заполненный список отменяет код;
· на клиентском экране «Имя отправителя» появилась пятая строка — скрыть её
  значило бы солгать умолчанием.

Тесты: модуль 548/548, было 530; 1801 проверка. Фронтенд 120/120, Sales 483/483.
Контрольные прогоны: убранное юридическое написание красит ровно четыре
зависящих от него теста, перевёрнутое умолчание выключателя — ровно один.

Не выкачено. Включать после того, как Билайн согласует имя — ориентир 24.08.2026.
2026-08-10 20:27:55 +03:00

262 lines
13 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\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsSender;
use App\Models\ClientSmsSettings;
use App\Models\Tenant;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\BeelineSmsProvider;
use App\Services\Sms\Providers\FakeSmsProvider;
use App\Services\Sms\SmsProvider;
use App\Services\Sms\SmsRouter;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Сеть Мотива (задача владельца 10.08.2026 «научить портал отправлять Мотив»).
*
* 🔑 Мотив достался не по выбору: в форме заявки на имя отправителя у Билайна
* галки «Билайн» и «Мотив» прибиты намертво (отмечены, снять нельзя). Раз он
* оплачен — надо им пользоваться.
*
* 🔴 ДО одобрения имени Билайном (заявка 10.08 14:53, срок до ~24.08) канал
* Билайна Мотива брать НЕ должен: отправка вернёт отказ 20230 «имя отправителя
* не одобрено оператором», а он у нас в списке ОКОНЧАТЕЛЬНЫХ — повтора не будет,
* сообщение умрёт. Поэтому маршрут через Билайна стоит на выключателе
* SMS_BEELINE_SERVES_MOTIV, и умолчание у него — ВЫКЛЮЧЕНО.
*
* 🪤 Отдельно от выключателя: сам номер Мотива в рассылку теперь берётся
* (AllowedSmsOperators). Пока Билайн его не возит, такой номер уходит
* универсальным каналом — ровно так же, как сегодня уходят номера Билайна,
* МегаФона и Теле2. Нет и универсала — честный отсев SKIP_NO_ROUTE.
*
* Помощники с префиксом motiv_* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Номера только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
/**
* Канал Билайна, собранный как в реестре: кого возит и почём.
*
* @param array<int, string> $serves
* @param array<string, int> $price
*/
function motiv_beeline(array $serves, array $price = ['*' => 586]): BeelineSmsProvider
{
return new BeelineSmsProvider(
login: '1834321',
password: 'secret',
serves: $serves,
priceKopecks: $price,
);
}
/** @param array<int, SmsProvider> $providers */
function motiv_selector(array $providers): ClientSmsRecipientSelector
{
return new ClientSmsRecipientSelector(
new SmsRouter($providers),
new OperatorNormalizer,
new AllowedSmsOperators(new OperatorNormalizer),
);
}
/**
* Запись канала Билайна из ФАЙЛА настроек при заданном положении выключателя.
* Читаем файл заново, а не config(): проверяем именно то, что соберётся из
* окружения на бою, — иначе выключателя как такового тест не касается.
*
* @return array<string, mixed>
*/
function motiv_reestr(?string $flag): array
{
if ($flag === null) {
unset($_SERVER['SMS_BEELINE_SERVES_MOTIV'], $_ENV['SMS_BEELINE_SERVES_MOTIV']);
} else {
$_SERVER['SMS_BEELINE_SERVES_MOTIV'] = $flag;
$_ENV['SMS_BEELINE_SERVES_MOTIV'] = $flag;
}
/** @var array<string, mixed> $config */
$config = require base_path('config/services.php');
unset($_SERVER['SMS_BEELINE_SERVES_MOTIV'], $_ENV['SMS_BEELINE_SERVES_MOTIV']);
/** @var array<string, mixed> $beeline */
$beeline = $config['sms']['providers']['beeline'];
return $beeline;
}
it('номер Мотива больше не отсеивается на первой заставе', function (string $raw) {
// Первая застава — «кому мы вообще шлём» (В-149). До этой правки Мотива в
// четвёрке владельца не было, и номер выбывал ЗДЕСЬ, не дойдя до каналов.
expect((new AllowedSmsOperators(new OperatorNormalizer))->accepts($raw))->toBeTrue();
})->with([
['Мотив'],
['ООО "ЕКАТЕРИНБУРГ-2000"'],
]);
it('выключатель опущен — Билайн Мотива не возит', function () {
expect(motiv_reestr(null)['serves'])->toBe(['beeline'])
->and(motiv_reestr('false')['serves'])->toBe(['beeline']);
});
it('выключатель поднят — Билайн берёт Мотива вдобавок к своим', function () {
expect(motiv_reestr('true')['serves'])->toBe(['beeline', 'motiv']);
});
it('у Мотива своя цена в реестре, а не общая цена канала', function () {
// 🔴 6,24 ₽ без НДС по прайсу Билайна × 1,23464 (НДС 22% + надбавка 1,2%)
// = 7,70 ₽. Считай мы Мотива по общей цене канала — занижали бы каждое
// сообщение почти на два рубля, и сверка молчала бы.
expect(motiv_reestr(null)['price_kopecks']['motiv'])->toBe(770);
});
it('маршрутизатор при опущенном выключателе Мотиву канала не даёт', function () {
expect((new SmsRouter([motiv_beeline(['beeline'])]))->pick('motiv'))->toBeNull();
});
it('маршрутизатор при поднятом выключателе ведёт Мотива в Билайна', function () {
$router = new SmsRouter([motiv_beeline(['beeline', 'motiv'])]);
expect($router->pick('motiv'))->toBeInstanceOf(BeelineSmsProvider::class)
->and($router->pick('beeline'))->toBeInstanceOf(BeelineSmsProvider::class);
});
it('канал называет цену по сети, не сбивая цену соседа', function () {
// Обе цены в ОДНОЙ проверке: иначе не докажешь, что, научив канал Мотиву,
// не сбил цену самому Билайну.
$provider = motiv_beeline(['beeline', 'motiv'], ['motiv' => 770, '*' => 586]);
expect($provider->priceKopecks('motiv'))->toBe(770)
->and($provider->priceKopecks('beeline'))->toBe(586);
});
it('выключатель опущен и универсала нет — отсев честный, «не из наших сетей»', function () {
// 🪤 Причина именно SKIP_NO_ROUTE, а не SKIP_UNKNOWN_OPERATOR: оператор
// НАЗВАН, просто везти его некому. Перепутай их — и в отчёте владельцу
// наша выключенная сеть выглядела бы как «ДаДата не ответила» (В-68).
$tenant = Tenant::factory()->create();
$plan = motiv_selector([motiv_beeline(['beeline'])])->build([
['phone' => '79990000101', 'operator' => 'Мотив'],
], $tenant->id);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped['79990000101'])->toBe(ClientSmsMessage::SKIP_NO_ROUTE);
});
it('выключатель опущен, но универсал есть — номер Мотива уходит им, как Билайн и МегаФон сегодня', function () {
$tenant = Tenant::factory()->create();
$plan = motiv_selector([motiv_beeline(['beeline']), new FakeSmsProvider])->build([
['phone' => '79990000102', 'operator' => 'ООО "ЕКАТЕРИНБУРГ-2000"'],
], $tenant->id);
expect($plan->skipped)->toBe([])
->and($plan->sendable)->toHaveCount(1)
->and($plan->sendable[0]['operator'])->toBe('motiv');
});
it('выключатель поднят — номер Мотива уходит именно Билайном', function () {
$tenant = Tenant::factory()->create();
$plan = motiv_selector([motiv_beeline(['beeline', 'motiv']), new FakeSmsProvider])->build([
['phone' => '79990000103', 'operator' => 'Мотив'],
], $tenant->id);
expect($plan->skipped)->toBe([])
->and($plan->sendable[0]['provider'])->toBeInstanceOf(BeelineSmsProvider::class);
});
it('своё имя клиента без Мотива в списке — номер не уходит вовсе', function () {
// Поведение НЕ меняем, а закрепляем: у клиента со своим именем имя в сети
// Мотива не согласовано, и подписать его рассылку нашим «Liderra.ru»
// значило бы незаметно подменить бренд, за который он заплатил.
$tenant = Tenant::factory()->create();
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(),
]);
$plan = motiv_selector([motiv_beeline(['beeline', 'motiv'])])->build([
['phone' => '79990000104', 'operator' => 'Мотив'],
], $tenant->id);
expect($plan->sendable)->toBeEmpty()
->and($plan->skipped['79990000104'])->toBe(ClientSmsMessage::SKIP_NO_SENDER);
});
it('сверка считает Мотива по его цене, а не по общей цене канала', function () {
// 🔴 Единственный наш прибор «оператор считает так же, как мы». Читай он и
// дальше только общую цену канала, рассылка из билайновских и мотивовских
// номеров показывала бы заниженный расход — и расхождение со счётом
// Билайна выглядело бы как ошибка ОПЕРАТОРА, а не наша.
$tenant = Tenant::factory()->create();
config(['services.sms.providers.beeline.price_kopecks' => ['motiv' => 770, '*' => 586]]);
$campaign = ClientSmsCampaign::create([
'tenant_id' => $tenant->id,
'title' => 'Мотив и Билайн в одной рассылке',
'body' => 'Текст рассылки',
'sender_name' => 'LIDERRA',
'source' => ClientSmsCampaign::SOURCE_MANUAL,
'status' => ClientSmsCampaign::STATUS_DONE,
'idempotency_key' => 'motiv-'.uniqid(),
'segments' => 1,
'planned_count' => 2,
'sent_count' => 2,
'total_sms' => 2,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => '17.00',
]);
foreach ([['79990000105', 'beeline'], ['79990000106', 'motiv']] as [$phone, $operator]) {
ClientSmsMessage::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => $phone,
'operator' => $operator,
'provider_key' => 'beeline',
'status' => ClientSmsMessage::STATUS_SENT,
'segments' => 1,
'cost_rub' => '8.50',
]);
}
$row = collect($this->getJson('/api/admin/sms/reconcile')->json('rows'))
->firstWhere('campaign_id', $campaign->id);
// 5,86 + 7,70 = 13,56 ₽. По общей цене канала вышло бы 11,72 — на 1,84 меньше.
expect($row)->not->toBeNull()
->and($row['our_cost'])->toBe('13.5600');
});
it('владелец может отметить Мотива в настройках', function () {
// 🔴 Без этого правка кода не даёт НИЧЕГО на бою: если в настройках список
// уже заполнен, значение из кода не читается совсем (AllowedSmsOperators),
// а проверка ввода отвергала бы Мотива с ошибкой — владелец не смог бы его
// добавить ни через экран, ни запросом.
$this->putJson('/api/admin/sms/settings', [
// Два поля обязательны на этом адресе всегда — без них до проверки
// операторов дело не дойдёт вовсе.
'name_fee_rub_per_operator' => '2500.00',
'name_debt_grace_days' => 7,
'allowed_operators' => ['mts', 'beeline', 'megafon', 'tele2', 'motiv'],
])->assertOk();
expect(ClientSmsSettings::first()->allowed_operators)->toContain('motiv');
});