Files
portal/app/tests/Feature/ClientSms/SnapshotRegionTest.php
T
Дмитрий d61b552532 feat(смс-клиент): ДаДата даёт регион и оператора всем номерам, снимок дозревает
Этап 3 «Время и цена», Task 5. Строка листа 3.3 закрыта целиком.

Номер, про который мы ничего не знаем, перестал быть тупиком: теперь его судьбу решает
ДаДата — и решает ОДНИМ платным вопросом, потому что пояс и оператор приезжают в одном
ответе. Входов обогащения три, и все три ведут в одно и то же место.

Своя база: джоб обогащения контактов кладёт пояс рядом с оператором и догоняет номера,
загруженные до Этапа 3, — берёт контакт, у которого пусто хоть что-то одно. Без догона
такой номер после выката не получил бы СМС никогда.

Рассылка: новый EnrichClientSmsSnapshotRegionJob спрашивает про строки снимка без пояса и
пишет туда и пояс, и оператора. У номеров, вписанных руками, оператора не было вовсе —
это и есть закрытие старой жалобы В-69. Ставится из контроллера сразу после снимка и
только если такие строки есть; узнав регион, сам будит рассылку, чтобы созревшие номера
не ждали четверть часа зря. Будит ТОЛЬКО ждущую утра: у остановленной клиентом рассылки
статус другой, и повторный запуск затёр бы ей «остановлена» на «готово».

Авто-СМС: сделка без региона спрашивает ДаДату по номеру лида (В-100) — иначе заведённый
руками лид не получил бы СМС и после этой задачи. За номер платим один раз: узнанный пояс
несётся в самом отложенном задании, а «ответила, но пояса нет» помечается отметкой; заново
спрашиваем только если вопрос не состоялся — сбой сети или выбранный дневной лимит (В-104).

Дозревание: команда добора ставит окончательную причину строкам, которые ждут региона
дольше суток, и будит рассылку, чтобы та дописала причину в журнал и закончилась — деньги
размораживаются. В журнал пишет джоб отправки, он это и так умеет для всех пропущенных;
второго места, пишущего в журнал, не завёл (В-107).

Кого НЕ спрашиваем (В-105): строку, у которой пояс есть, а оператор пуст. Она и так уйдёт
универсальным каналом, вопрос был бы ради экономии на канале, а ДаДата — наши деньги:
рассылка на 20 000 номеров это до 12 000 ₽ при ещё не назначенном потолке расходов (В-90).
Платим только там, где без ответа сообщение не уйдёт вовсе.

Прогоны: СМС 217/217 (было 202, +15 новых), приём лидов 17/17, фронт 1663 зелёных,
phpstan 0, pint чисто. Миграций нет — колонки пояса завёл ещё Task 2.

Вырезанием проверено пятью вырезами: убрал запись пояса — покраснел «узнав регион, номер
уходит»; убрал суточный срок — покраснел «не узнали за сутки»; убрал вторую половину
условия пробуждения — покраснел денежный тест про заморозку; убрал вопрос ДаДате в
авто-СМС — покраснели три теста третьего входа; убрал догон старых номеров базы —
покраснел «догоняет уже загруженный номер».

Живьём на локальной базе (19:19 МСК, ДаДата заглушена, живой ключ не тронут): вписанные
руками номера узнали пояс и оператора, московский ушёл сразу, камчатский отложен до
10 утра его времени; номер, про который ДаДата молчит, через сутки получил причину и
рассылка стала «готово»; сделка без региона получила авто-СМС по ответу ДаДаты, а сделка,
про которую ДаДата молчит, ждёт дальше и в журнал не пишет. Стенд возвращён как был.

🪤 Урок В-106: повторный Http::fake() прежнюю заглушку не заменяет — отвечает первая, и
тест «а теперь ДаДата отвечает» молча проверяет старый ответ.

🟢 Запрет на выкат ветки СНЯТ (был из-за В-93): ни один источник больше не отправляет ноль.
Выкатывать всё равно рано — Этап 3 не закончен, цена считается по-старому.
2026-07-28 19:28:14 +03:00

344 lines
18 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\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,
));
}
function snapRegionEnrich(int $campaignId, int $tenantId): void
{
(new EnrichClientSmsSnapshotRegionJob($campaignId, $tenantId))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(SmsQuietHours::class),
app(OperatorNormalizer::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('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();
});