true]); // 🔴 Ключа ДаДаты НЕТ — это и есть состояние боевого сервера. config(['services.dadata.api_key' => '', 'services.dadata.secret' => '']); Cache::flush(); // чистый дневной бюджет $this->tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); }); /** Строка-журнал импорта справочника; её id нужен каждому диапазону. */ function rossvyazSmsImportId(): int { return (int) DB::table('phone_ranges_imports')->insertGetId([ 'source_url' => 'https://rossvyaz.gov.ru/test', 'checksum_sha256' => str_repeat('b', 64), 'status' => 'completed', 'imported_at' => now(), ]); } /** * Диапазон номеров в справочнике Россвязи. По умолчанию — питерский МегаФон * (субъект 83, это UTC+3, то есть 180 минут). * * @param array $overrides */ function rossvyazSmsRange(array $overrides = []): void { DB::table('phone_ranges')->insert(array_merge([ 'def_code' => 921, 'from_num' => 5550000, 'to_num' => 5559999, 'operator' => 'МегаФон', 'region' => 'Санкт-Петербург', 'subject_code' => 83, 'imported_at' => now(), 'import_id' => rossvyazSmsImportId(), ], $overrides)); } /** Прогон обогащения своей базы номеров. */ function rossvyazSmsEnrichContacts(int $tenantId): void { (new EnrichClientSmsContactsOperatorJob($tenantId))->handle( app(DaDataPhoneClient::class), app(DaDataBudgetGuard::class), app(ClientSmsRossvyazHint::class), ); } /** Заказ рассылки по списку, вписанному руками. */ function rossvyazSmsOrder(array $phones): int { return (int) test()->postJson('/api/sms/campaigns', [ 'title' => 'Свой список', 'source' => 'manual', 'body' => 'Здравствуйте!', 'phones' => $phones, ])->assertCreated()->json('id'); } // ─────────────────────────── своя база номеров ─────────────────────────── it('своя база: без ДаДаты оператора и пояс даёт справочник Россвязи', function () { rossvyazSmsRange(); $c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79215555123']); rossvyazSmsEnrichContacts($this->tenant->id); // Оператор кладётся СЫРЫМ именем — ровно так же, как его клала ДаДата: // к каноническому ключу его приводит отборщик получателей, а не справочник. expect($c->fresh()->operator)->toBe('МегаФон') ->and($c->fresh()->tz_offset_minutes)->toBe(180); }); it('контрольный — своя база: номера нет в справочнике, оператор остаётся пустым', function () { // Без этого прогона предыдущий ничего не доказывает: «проставился» мог бы // означать «проставляется всегда, чему бы справочник ни отвечал». rossvyazSmsRange(); // только def_code=921 $c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79991234567']); rossvyazSmsEnrichContacts($this->tenant->id); expect($c->fresh()->operator)->toBeNull() ->and($c->fresh()->tz_offset_minutes)->toBeNull(); }); it('своя база: уже известного оператора справочник не затирает', function () { // У номера в справочнике МегаФон, а у нас про него записан МТС — значит номер // перенесён к другому оператору, и справочник об этом не знает. Знание, за // которое уже заплачено, сильнее догадки по диапазону. rossvyazSmsRange(); $c = ClientSmsContact::create([ 'tenant_id' => $this->tenant->id, 'phone' => '79215555123', 'operator' => 'МТС', ]); rossvyazSmsEnrichContacts($this->tenant->id); expect($c->fresh()->operator)->toBe('МТС') ->and($c->fresh()->tz_offset_minutes)->toBe(180); // а пояса не было — его дополнили }); it('своя база: ответ ДаДаты сильнее справочника — номер перенесён к другому оператору', function () { config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']); Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => 'UTC+3']], 200)]); rossvyazSmsRange(); // справочник говорит «МегаФон» $c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79215555123']); rossvyazSmsEnrichContacts($this->tenant->id); expect($c->fresh()->operator)->toBe('МТС'); }); // ───────────────────────── список, вписанный руками ───────────────────────── it('списком руками: без ДаДаты справочник даёт пояс и оператора — номер уходит', function () { // Полдень в Петербурге: окно 10–20 открыто, ждать номеру нечего. $this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); rossvyazSmsRange(); $id = rossvyazSmsOrder(['79215555123']); $row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail(); expect($row->tz_offset_minutes)->toBe(180) ->and($row->skip_reason)->toBeNull() ->and($row->operator)->toBe('megafon') // в снимке лежит канонический ключ ->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1); }); it('контрольный — списком руками: номера нет в справочнике, он ждёт и не теряется', function () { $this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); rossvyazSmsRange(); // только def_code=921 $id = rossvyazSmsOrder(['79991234567']); $row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail(); expect($row->tz_offset_minutes)->toBeNull() ->and($row->skip_reason)->toBeNull() // не потерян и не помечен зря ->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0); }); it('списком руками: предпросмотр не пугает клиента зря — про известный номер он всё знает сразу', function () { // Предпросмотр считается ДО создания рассылки, и догону снимка тут взяться // неоткуда. Пока справочник не спрашивали здесь же, человек видел «ждут // уточнения региона» про номер, который справочник знает наизусть, — и // смету на этот номер тоже не видел. $this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); rossvyazSmsRange(); $body = test()->postJson('/api/sms/preview', [ 'title' => 'Свой список', 'source' => 'manual', 'body' => 'Здравствуйте!', 'phones' => ['79215555123'], ])->assertOk()->json(); expect($body['sendable_count'])->toBe(1) ->and($body['waiting_region_count'])->toBe(0) ->and($body['skipped'])->toBe([]); }); it('контрольный — предпросмотр: про номер вне справочника честно говорит, что ждёт региона', function () { $this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); rossvyazSmsRange(); // только def_code=921 $body = test()->postJson('/api/sms/preview', [ 'title' => 'Свой список', 'source' => 'manual', 'body' => 'Здравствуйте!', 'phones' => ['79991234567'], ])->assertOk()->json(); expect($body['waiting_region_count'])->toBe(1); }); it('пара регионов в справочнике даёт пояс, если обе половины в одном часе', function () { // 🔴 Найдено приёмкой на бою: у Москвы и Питера в справочнике Россвязи регион // записан ПАРОЙ — «г. Москва и Московская область», — и кода субъекта у таких // строк нет вовсе. Это самые крупные по числу абонентов диапазоны страны, и без // разбора пары оператор определялся, а пояс нет — то есть СМС всё равно не уходила. // // Разбирать пару можно потому, что обе её половины живут в ОДНОМ часе. Там, где // это не так, честнее ответить «не знаю» — про это отдельный прогон ниже. rossvyazSmsRange([ 'def_code' => 916, 'from_num' => 1000000, 'to_num' => 9999999, 'operator' => 'ПАО "МТС"', 'region' => 'г. Москва и Московская область', 'subject_code' => null, ]); expect(app(ClientSmsRossvyazHint::class)->for('79161234567')) ->toBe(['operator' => 'ПАО "МТС"', 'tz' => 180]); }); it('пара регионов из РАЗНЫХ часов пояса не даёт — говорим честно «не знаю»', function () { // Выдуманная пара Москва + Красноярск: полтора часовых пояса между ними. Угадать // тут нельзя, и молча выбрать один из двух — значит отправить человеку СМС ночью. rossvyazSmsRange([ 'def_code' => 917, 'from_num' => 1000000, 'to_num' => 9999999, 'operator' => 'ПАО "МТС"', 'region' => 'г. Москва и Красноярский край', 'subject_code' => null, ]); expect(app(ClientSmsRossvyazHint::class)->for('79171234567')) ->toBe(['operator' => 'ПАО "МТС"', 'tz' => null]); }); it('пара через звёздочку разбирается так же — так пишет сам справочник', function () { // «Архангельская область * Ненецкий автономный округ» — второй разделитель, // которым Россвязь склеивает пару. Обе половины в одном часе. rossvyazSmsRange([ 'def_code' => 911, 'from_num' => 1000000, 'to_num' => 9999999, 'operator' => 'ПАО "МегаФон"', 'region' => 'Архангельская область * Ненецкий автономный округ', 'subject_code' => null, ]); expect(app(ClientSmsRossvyazHint::class)->for('79111234567')['tz'])->toBe(180); }); it('выбранный дневной лимит ДаДаты не мешает справочнику ответить — он бесплатный', function () { // Рассылка на 20 000 номеров стоила бы у ДаДаты 12 000 ₽ при дневном потолке // 10 000 ₽: остаток списка упирается в лимит каждый раз, когда список большой. // Раньше на этом всё вставало до следующего запуска. Справочник денег не стоит, // и не спросить его — значит держать людей в очереди без всякой причины. $this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']); Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => 'UTC+3']], 200)]); // Дневной лимит выбираем ДО заказа, а справочник пополняем ПОСЛЕ. Тогда номер // ложится в снимок без пояса и достаётся догону. Сделай наоборот — его обслужил // бы сам заказ, и тест прошёл бы, ничего не проверив (так и вышло с первой // попытки этого теста). config(['services.dadata.daily_cap_rub' => 1]); app(DaDataBudgetGuard::class)->recordSpend(200); // 2 ₽ > 1 ₽ $id = rossvyazSmsOrder(['79215555123']); expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('tz_offset_minutes'))->toBeNull(); rossvyazSmsRange(); // справочник пополнили (new EnrichClientSmsSnapshotRegionJob($id, (int) $this->tenant->id))->handle( app(DaDataPhoneClient::class), app(DaDataBudgetGuard::class), app(SmsQuietHours::class), app(OperatorNormalizer::class), app(AllowedSmsOperators::class), app(ClientSmsRossvyazHint::class), ); $row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail(); // Оператор и пояс — из справочника, ДаДату не дёргали ни разу. expect($row->tz_offset_minutes)->toBe(180) ->and($row->operator)->toBe('megafon'); Http::assertNothingSent(); }); it('пачкой и по одному справочник отвечает ОДИНАКОВО — в том числе на вложенных диапазонах', function () { // 🔴 Пачечный запрос — второй экземпляр того же правила выбора («побеждает самый // узкий накрывающий диапазон»). Два экземпляра одного правила расходятся молча, // поэтому сторож сравнивает их ответы на том самом краю, где правило и работает. $importId = rossvyazSmsImportId(); rossvyazSmsRange([ 'def_code' => 495, 'from_num' => 1000000, 'to_num' => 9999999, 'operator' => 'Ростелеком', 'region' => 'Московская область', 'subject_code' => 56, 'import_id' => $importId, ]); rossvyazSmsRange([ 'def_code' => 495, 'from_num' => 2000000, 'to_num' => 2009999, 'operator' => 'МГТС', 'region' => 'Москва', 'subject_code' => 82, 'import_id' => $importId, ]); $hint = app(ClientSmsRossvyazHint::class); $phones = ['74952005000', '74953005000', '79991234567']; $one = []; foreach ($phones as $phone) { $one[$phone] = $hint->for($phone); } expect($hint->forMany($phones))->toBe($one) // И заодно проверяем, что узкий диапазон действительно выиграл, — иначе // «совпало» могло бы означать «оба ошибаются одинаково». ->and($one['74952005000']['operator'])->toBe('МГТС') ->and($one['74953005000']['operator'])->toBe('Ростелеком') ->and($one['79991234567']['operator'])->toBeNull(); }); it('предпросмотр большого списка спрашивает справочник пачками, а не по номеру', function () { // Справочник бесплатный, но не даровой: вопрос про КАЖДЫЙ номер отдельно — это // 20 000 запросов в одном нажатии кнопки, и человек у экрана столько не ждёт. // 🔑 Прибор — ЗАПРОСЫ, а не время: время на стенде плавает и порогом врало бы. rossvyazSmsRange(); $phones = []; for ($i = 0; $i < 20000; $i++) { $phones[] = '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT); } $queries = 0; DB::listen(function () use (&$queries): void { $queries++; }); $started = microtime(true); $res = test()->postJson('/api/sms/preview', [ 'title' => 'Свой список', 'source' => 'manual', 'body' => 'Здравствуйте!', 'phones' => $phones, ]); $elapsed = microtime(true) - $started; fwrite(STDERR, sprintf("\n[Н.1] предпросмотр 20 000 номеров списком: %d запросов, %.2f с\n", $queries, $elapsed)); $res->assertOk(); expect($queries)->toBeLessThan(200); }); it('списком руками: догон снимка тоже работает без ДаДаты', function () { // Строка снимка могла остаться без пояса — например, справочник пополнили // уже после заказа. Догон обязан её подобрать, а не ждать ключа ДаДаты. $this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); $id = rossvyazSmsOrder(['79215555123']); // справочник ещё пуст — номер ждёт expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('tz_offset_minutes'))->toBeNull(); rossvyazSmsRange(); // справочник пополнили (new EnrichClientSmsSnapshotRegionJob($id, (int) $this->tenant->id))->handle( app(DaDataPhoneClient::class), app(DaDataBudgetGuard::class), app(SmsQuietHours::class), app(OperatorNormalizer::class), app(AllowedSmsOperators::class), app(ClientSmsRossvyazHint::class), ); $row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail(); expect($row->tz_offset_minutes)->toBe(180) ->and($row->operator)->toBe('megafon'); });