$serves * @param array $price */ function motiv_beeline(array $serves, array $price = ['*' => 586]): BeelineSmsProvider { return new BeelineSmsProvider( login: '1834321', password: 'secret', serves: $serves, priceKopecks: $price, ); } /** @param array $providers */ function motiv_selector(array $providers): ClientSmsRecipientSelector { return new ClientSmsRecipientSelector( new SmsRouter($providers), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer), ); } /** * Запись канала Билайна из ФАЙЛА настроек при заданном положении выключателя. * Читаем файл заново, а не config(): проверяем именно то, что соберётся из * окружения на бою, — иначе выключателя как такового тест не касается. * * @return array */ 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 $config */ $config = require base_path('config/services.php'); unset($_SERVER['SMS_BEELINE_SERVES_MOTIV'], $_ENV['SMS_BEELINE_SERVES_MOTIV']); /** @var array $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'); });