Files
portal/app/tests/Feature/ClientSms/SnapshotRegionTest.php
T
Дмитрий 65e270583c feat(смс-клиент): шлём только абонентам МТС, Билайна, Мегафона и Теле2
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или
виртуального оператора в рассылку не берётся, и человек видит честную причину,
а не молчаливую пропажу.

Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный
канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём.

Список — настройкой, а не в коде: client_sms_settings.allowed_operators
(схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию.
Правило живёт в одном месте — AllowedSmsOperators.

Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок
и своей базы оператор известен в момент заказа, у номеров, вписанных руками,
его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится
уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали».

Плата за имя не тронута (В-150): в коде два похожих списка операторов, и
связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей.

Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём
только по МТС)» — универсальный канал возит всех.

Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка
24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент
ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн».
ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние,
vue-tsc 5 чужих давних, pint чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:25:37 +03:00

389 lines
20 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\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<string, ?string> $byPhone номер → строка пояса ДаДаты («UTC+3»)
*/
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),
);
}
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();
});