Files
portal/app/tests/Feature/ClientSms/MotivTest.php
T

262 lines
13 KiB
PHP
Raw Normal View History

<?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');
});