Files
portal/app/tests/Feature/ClientSms/SnapshotRegionTest.php
T
Дмитрий d4ccb5ac95 fix,смс: оператор и часовой пояс номера — из бесплатного справочника Россвязи
Модуль спрашивал и оператора, и регион ТОЛЬКО у платной ДаДаты, а без её
полезного ответа выходил молча. На бою это убило две трети модуля: рассылки
«по своей базе» и «списком руками» не отправляли ни одного сообщения —
причина skipped_unknown_operator, живой прогон 03.08.2026.

Теперь порядок такой: сперва ДаДата — только она знает про перенос номера к
другому оператору, — затем справочник phone_ranges, который дозаполняет
оставшееся пустым и никогда не затирает уже известное. Тем же справочником
страхуется приём лидов от поставщика.

Починено в трёх местах: обогащение своей базы номеров, догон снимка рассылки
и предпросмотр. Предпросмотр врал клиенту «ждут уточнения региона» про
номера, которые справочник знает наизусть, и смету на них не показывал.
Исчерпанный дневной лимит ДаДаты больше не обрывает догон целиком: бесплатный
справочник спрашивается всё равно, и остаток списка не ждёт следующего часа.

Сторож считает ЗАПРОСЫ: предпросмотр списка на 20 000 номеров, спрашивая
справочник по одному номеру, давал 20 044 запроса и 22 секунды на одно
нажатие кнопки. Пачками — 64 запроса и 4,6 секунды.

Тесты писались первыми и проверены красными; сторож пачечного запроса
проверен вырезанием. Модуль СМС 399 из 399, статанализ 0.
2026-08-03 12:05:13 +03:00

394 lines
21 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\Jobs\EnrichClientSmsSnapshotRegionJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsCampaignPhone;
use App\Models\ClientSmsMessage;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsRossvyazHint;
use App\Services\ClientSms\SmsQuietHours;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строка листа 3.3 до конца: про номер, вписанный руками, мы не знаем НИЧЕГО —
* ни в каком он регионе, ни чей он. И то, и другое спрашиваем у ДаДаты одним
* вопросом (решение владельца В-88), а до ответа номер не уходит и не теряется:
* он ждёт уточнения прямо в снимке (моё решение В-89).
*
* Не узнали за сутки — окончательная причина «не знаем, в каком регионе номер»,
* и рассылка на этом честно заканчивается: деньги размораживаются, причина видна.
*
* Тесты парные (урок В-54): рядом с «не ушло» обязан стоять прогон «а вот теперь
* ушло», иначе ноль мог получиться просто от поломки.
*
* Помощники с префиксом snapRegion* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* 🪤 SharesSupplierPdo — команда добора перечисляет рассылки служебным соединением
* (BYPASSRLS): без общего PDO оно не видит незакоммиченные данные теста (В-95).
*
* Синтетические номера 7999… — реальные НИКОГДА. ДаДата всюду заглушена: живой
* ключ не трогаем и наших денег не тратим.
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
config(['services.sms.sandbox' => true]);
// ДаДата включена (ключ задан) — иначе обогащение не стартует вовсе.
config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']);
Cache::flush(); // чистый дневной бюджет ДаДаты
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
// 🪤 Заглушка ставится ОДИН раз и читает изменяемый список: повторный вызов
// Http::fake() прежнюю заглушку НЕ заменяет — отвечает по-прежнему ПЕРВАЯ, и
// тест «а теперь ДаДата отвечает» молча проверял бы старый ответ (В-106).
$this->daDataByPhone = [];
$this->daDataProvider = 'МТС';
Http::fake(['cleaner.dadata.ru/*' => function ($request) {
$phone = (string) (($request->data()[0]) ?? '');
return Http::response([[
'qc' => 0,
'provider' => $this->daDataProvider,
'timezone' => $this->daDataByPhone[$phone] ?? null,
]], 200);
}]);
});
/** Момент UTC по человеческой записи. */
function snapRegionUtc(string $iso): CarbonImmutable
{
return CarbonImmutable::parse($iso, 'UTC');
}
/**
* Что ДаДата отвечает про каждый номер. Можно менять по ходу теста — заглушка
* читает этот список на каждый вопрос (см. ловушку В-106 в beforeEach).
*
* @param array<int|string, ?string> $byPhone номер → строка пояса ДаДаты («UTC+3»)
*
* Ключ int|string намеренно: PHP сам делает из ключа-номера целое число,
* поэтому обещание «только строка» было ложным.
*/
function snapRegionDaData(array $byPhone): void
{
test()->daDataByPhone = $byPhone;
}
/** Заказ рассылки по списку, вписанному руками. */
function snapRegionOrder(array $phones): int
{
return (int) test()->postJson('/api/sms/campaigns', [
'title' => 'Свой список',
'source' => 'manual',
'body' => 'Здравствуйте!',
'phones' => $phones,
])->assertCreated()->json('id');
}
/** Сделка с регионом (или без него). 🪤 Регион живёт в subject_code, НЕ в region_code (В-82). */
function snapRegionDeal(int $tenantId, string $phone, ?int $subjectCode): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => $phone,
'phone_operator' => 'МТС',
'subject_code' => $subjectCode,
'received_at' => now()->subDay(),
]));
}
/**
* Настоящий маршрут МТС вместо песочницы — нужен там, где проверяются деньги.
* 🪤 Подменять надо роутер в контейнере: канал выбирается ДВАЖДЫ — отборщиком при
* создании рассылки и читателем снимка при отправке (В-46).
*/
function snapRegionRealRoute(): void
{
$router = new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]);
app()->instance(SmsRouter::class, $router);
app()->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector(
$router,
new OperatorNormalizer,
new AllowedSmsOperators(new OperatorNormalizer),
));
}
function snapRegionEnrich(int $campaignId, int $tenantId): void
{
(new EnrichClientSmsSnapshotRegionJob($campaignId, $tenantId))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(SmsQuietHours::class),
app(OperatorNormalizer::class),
app(AllowedSmsOperators::class),
app(ClientSmsRossvyazHint::class),
);
}
it('3.3 — вписанный руками номер не уходит молча: заказ ставит вопрос ДаДате', function () {
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // полдень в Москве
snapRegionDaData(['79995550001' => 'UTC+3']);
Queue::fake(); // смотрим на снимок ДО того, как что-либо отработало
$id = snapRegionOrder(['79995550001']);
$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);
// И вопрос ДаДате поставлен — без него номер ждал бы вечно.
Queue::assertPushed(EnrichClientSmsSnapshotRegionJob::class);
});
it('3.3 — узнав регион и оператора, номер уходит в своё утро', function () {
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // 12:00 в Москве, 21:00 на Камчатке
snapRegionDaData([
'79995550001' => 'UTC+3', // Москва — окно открыто прямо сейчас
'79995550002' => 'UTC+12', // Камчатка — у неё вечер, подождёт до утра
]);
$id = snapRegionOrder(['79995550001', '79995550002']);
$rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone');
// ДаДата сказала и регион, и оператора — второго обращения за оператором нет.
expect($rows['79995550001']->tz_offset_minutes)->toBe(180)
->and($rows['79995550001']->operator)->toBe('mts')
->and($rows['79995550002']->tz_offset_minutes)->toBe(720)
->and($rows['79995550002']->send_after)->not->toBeNull();
// Москвич ушёл сразу, камчатский честно ждёт своего утра.
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001')
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
// Наступило камчатское утро — добор доводит рассылку до конца сам.
$this->travelTo(snapRegionUtc('2026-08-03 22:00'));
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('контрольный: без ответа ДаДаты номер не уходит, но и не теряется', function () {
// Без этого прогона предыдущий ничего не доказывает: «ушло» могло бы означать
// «уходит всегда, независимо от того, узнали мы регион или нет» (урок В-54).
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
// ДаДата отвечает, но пояса в ответе нет — как для номера, про который она не знает.
snapRegionDaData(['79995550001' => null]);
$id = snapRegionOrder(['79995550001']);
$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)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
// И добор его тоже не трогает: ждать нечего, пока регион не узнан.
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0);
});
it('3.3 — про номер, у которого пояс уже есть, ДаДату не спрашиваем (это наши деньги)', function () {
// Решение В-105: платный вопрос задаём ТОЛЬКО там, где без ответа сообщение не
// уйдёт вовсе. Лишний вопрос по рассылке на 20 000 номеров — это тысячи рублей.
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
snapRegionDaData(['79995550002' => 'UTC+12']);
Queue::fake();
$id = snapRegionOrder(['79995550001', '79995550002']);
// Первому пояс уже проставлен (например, прошлым прогоном обогащения).
ClientSmsCampaignPhone::where('campaign_id', $id)
->where('phone', '79995550001')
->update(['tz_offset_minutes' => 180]);
snapRegionEnrich($id, (int) $this->tenant->id);
Http::assertSentCount(1);
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->where('phone', '79995550002')
->value('tz_offset_minutes'))->toBe(720);
});
it('3.3 — не узнали за сутки: честная причина, и рассылка заканчивается', function () {
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
snapRegionDaData(['79995550001' => null]); // ДаДата про этот номер ничего не знает
$id = snapRegionOrder(['79995550001']);
// Сутки номер ждёт — молча, без причины: вдруг регион ещё узнается (В-89).
$this->travelTo(snapRegionUtc('2026-08-04 08:00')); // прошло 23 часа
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))->toBeNull()
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW);
// Сутки прошли — называем причину и заканчиваем рассылку.
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // прошло 25 часов
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$message = ClientSmsMessage::where('campaign_id', $id)->firstOrFail();
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))
->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
->and($message->status)->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
->and((string) $message->cost_rub)->toBe('0.00')
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('3.3 — узнанный за сутки регион побеждает: причину так и не поставили', function () {
// Парный к предыдущему (В-54): если бы «через сутки» ставилось всем подряд, этот
// прогон покраснел бы — номер узнал свой регион и обязан уйти, а не получить причину.
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
snapRegionDaData(['79995550001' => null]);
$id = snapRegionOrder(['79995550001']);
// Через час ДаДата уже отвечает — например, обновила справочник.
$this->travelTo(snapRegionUtc('2026-08-03 10:00'));
snapRegionDaData(['79995550001' => 'UTC+3']);
snapRegionEnrich($id, (int) $this->tenant->id);
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // сутки прошли
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))->toBeNull()
->and(ClientSmsMessage::where('campaign_id', $id)->value('status'))
->not->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE);
});
it('3.3 — деньги: заморозка снимается, когда ждать больше нечего', function () {
// В песочнице деньги не двигаются вовсе, поэтому здесь настоящий режим: иначе
// «заморозка снялась» доказывалось бы тем, что её и не было (В-81).
config(['services.sms.sandbox' => false]);
snapRegionRealRoute();
Http::fake([
'cleaner.dadata.ru/*' => fn () => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => null]], 200),
'*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]],
], 200),
]);
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
app(AdWalletService::class)->topup((int) $this->tenant->id, '1000.00', null, 'test');
snapRegionDeal((int) $this->tenant->id, '79995550001', 82); // Москва — уйдёт
snapRegionDeal((int) $this->tenant->id, '79995550003', null); // региона нет — подождёт
$id = (int) $this->postJson('/api/sms/campaigns', [
'title' => 'По сделкам',
'source' => 'deals',
'body' => 'Здравствуйте!',
'audience_days' => 30,
])->assertCreated()->json('id');
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->firstOrFail();
expect((string) $wallet->frozen_rub)->not->toBe('0.00'); // пока ждём — держим
$this->travelTo(snapRegionUtc('2026-08-04 10:00')); // сутки прошли
$this->artisan('client-sms:resume-waiting')->assertSuccessful();
$wallet->refresh();
$campaign = ClientSmsCampaign::find($id);
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and((string) $wallet->frozen_rub)->toBe('0.00')
// Списано ровно за отправленное, а не за смету на обоих.
->and((string) $campaign->actual_cost_rub)->not->toBe('0.00')
->and((string) $campaign->actual_cost_rub)->toBe(
bcadd((string) ClientSmsMessage::where('campaign_id', $id)->sum('cost_rub'), '0', 2)
);
});
it('4.14 — оператор не из четвёрки, названный ДаДатой, выбывает из снимка', function () {
// 🔴 Вторая точка решения (строка листа 4.14, решение владельца В-149).
// Сырое имя оператора есть только в ответе ДаДаты: в снимок ложится
// канонический ключ, и «Тинькофф Мобайл» стал бы там неотличим от «ещё не
// спрашивали». Значит приговор выносится в момент ответа, другого момента нет.
//
// Тест ПАРНЫЙ (урок В-54): рядом стоит тот же прогон с оператором из четвёрки.
// Без него «выбыл» доказывало бы разве что «выбывают все подряд».
$this->travelTo(snapRegionUtc('2026-08-03 09:00')); // полдень в Москве
snapRegionDaData(['79995550001' => 'UTC+3', '79995550002' => 'UTC+3']);
Queue::fake(); // обогащение зовём руками, чтобы смотреть ровно на него
$allowed = snapRegionOrder(['79995550001']);
// 🪤 Текст у второй рассылки ДРУГОЙ: одинаковый текст в те же десять минут
// портал считает случайным повтором и отвечает 409 (защита из строки 2.6).
$denied = (int) $this->postJson('/api/sms/campaigns', [
'title' => 'Свой список — второй',
'source' => 'manual',
'body' => 'Добрый день!',
'phones' => ['79995550002'],
])->assertCreated()->json('id');
$this->daDataProvider = 'МТС';
snapRegionEnrich($allowed, (int) $this->tenant->id);
$this->daDataProvider = 'Тинькофф Мобайл';
snapRegionEnrich($denied, (int) $this->tenant->id);
$allowedRow = ClientSmsCampaignPhone::where('campaign_id', $allowed)->firstOrFail();
$deniedRow = ClientSmsCampaignPhone::where('campaign_id', $denied)->firstOrFail();
// Абонент четвёрки: узнал пояс и оператора, причины пропуска нет.
expect($allowedRow->skip_reason)->toBeNull()
->and($allowedRow->tz_offset_minutes)->toBe(180)
->and($allowedRow->operator)->toBe('mts');
// Мелкий оператор: честная причина, и время ему больше не нужно.
expect($deniedRow->skip_reason)->toBe(ClientSmsMessage::SKIP_OPERATOR_DENIED)
->and($deniedRow->tz_offset_minutes)->toBeNull();
});
it('3.3 — выбранный дневной лимит ДаДаты не помечает номера окончательно', function () {
// Лимит выбран — это не «регион неизвестен навсегда», а «спросим завтра».
$this->travelTo(snapRegionUtc('2026-08-03 09:00'));
snapRegionDaData(['79995550001' => 'UTC+3']);
config(['services.dadata.daily_cap_rub' => 1]);
app(DaDataBudgetGuard::class)->recordSpend(200); // 2 ₽ > 1 ₽
Queue::fake();
$id = snapRegionOrder(['79995550001']);
snapRegionEnrich($id, (int) $this->tenant->id);
$row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail();
Http::assertNothingSent();
expect($row->tz_offset_minutes)->toBeNull()
->and($row->skip_reason)->toBeNull();
});