Files
portal/app/tests/Feature/ClientSms/AutoSmsWindowTest.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

370 lines
17 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\SendAutoSmsForDealJob;
use App\Models\ClientSmsAutoRule;
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsOptout;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\SmsQuietHours;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataPhoneClient;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
/**
* Строка листа 3.6: лид пришёл ночью — авто-СМС уходит не ночью, а утром, и ровно
* одно. Плюс вторая половина строки 3.1 (окно действует и для авто-СМС).
*
* Пары тестов обязательны (урок В-54): прогон «ночью не ушло» сам по себе ничего не
* доказывает — ноль мог получиться от поломки. Рядом стоит прогон «а в полдень ушло»,
* где всё то же самое, кроме часа.
*
* 🔴 Регион у сделки может отсутствовать вовсе (сделка заведена руками). Москву при
* этом НЕ подставляем — владелец это отменил (В-85): пока не знаем, где живёт человек,
* СМС не уходит. Но и дверь не закрываем сразу (В-99): пишем причину только через
* сутки, до тех пор джоб просто переставляет себя.
*
* 🪤 Код субъекта здесь НЕ автомобильный (конституционный порядок): Москва — 82,
* Камчатка — 27. Ошибиться легко, и тест тогда молча проверяет не тот пояс.
*
* Помощники с префиксом autoWin* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config(['services.sms.sandbox' => true]);
});
/** Правило авто-СМС тенанта (одно на тенанта — UNIQUE tenant_id). */
function autoWinRule(int $tenantId): ClientSmsAutoRule
{
return ClientSmsAutoRule::create([
'tenant_id' => $tenantId,
'enabled' => true,
'body' => 'Здравствуйте!',
'sender_name' => 'liderra.ru',
'updated_by' => null,
]);
}
/**
* Сделка без срабатывания DealSmsObserver — чтобы в тесте бежал РОВНО один явный
* вызов джоба. 🪤 Регион живёт в subject_code (1..89), НЕ в region_code (В-82);
* Москва — 82, Камчатка — 27.
*/
function autoWinDeal(int $tenantId, ?int $subjectCode, ?CarbonImmutable $receivedAt = null): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => '79990000001',
'phone_operator' => 'МТС',
'subject_code' => $subjectCode,
'received_at' => $receivedAt ?? CarbonImmutable::now(),
]));
}
/** Прямой вызов джоба (как AutoSmsTest) — обходит afterCommit-отсрочку RefreshDatabase. */
function autoWinRunJob(int $dealId, int $tenantId, ?int $tz = null, bool $asked = false): void
{
(new SendAutoSmsForDealJob($dealId, $tenantId, $tz, $asked))->handle(
app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(SmsQuietHours::class),
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
);
}
/** Включает ДаДату и задаёт её ответ про пояс (заглушка — живой ключ не трогаем). */
function autoWinDaData(?string $timezone): void
{
config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']);
Cache::flush(); // чистый дневной бюджет
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0,
'provider' => 'МТС',
'timezone' => $timezone,
]], 200)]);
}
/** Момент UTC по человеческой записи. */
function autoWinUtc(string $iso): CarbonImmutable
{
return CarbonImmutable::parse($iso, 'UTC');
}
it('ночной лид: в журнале пусто, а джоб переставлен на утро того же места', function () {
// 23:00 UTC = 02:00 ночи в Москве (пояс +3).
Carbon::setTestNow(autoWinUtc('2026-08-03 23:00:00'));
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: 82);
autoWinRunJob((int) $deal->id, (int) $tenant->id);
// Ничего не записано: с номером ещё НИЧЕГО не произошло, а запись «ждём утра»
// навсегда закрыла бы этому лиду дорогу (идемпотентность — по наличию записи).
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0);
// 10:00 по Москве = 07:00 UTC того же дня.
Queue::assertPushed(
SendAutoSmsForDealJob::class,
fn (SendAutoSmsForDealJob $job) => $job->dealId === (int) $deal->id
&& $job->delay !== null
&& CarbonImmutable::parse($job->delay)->equalTo(autoWinUtc('2026-08-04 07:00:00')),
);
Carbon::setTestNow();
});
it('КОНТРОЛЬ: тот же лид в полдень уходит сразу и никуда не переставляется', function () {
// 09:00 UTC = 12:00 дня в Москве — окно открыто.
Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00'));
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: 82);
autoWinRunJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(1)
->and(ClientSmsMessage::where('tenant_id', $tenant->id)->first()->status)
->toBe(ClientSmsMessage::STATUS_FAKE_SENT);
Queue::assertNothingPushed();
Carbon::setTestNow();
});
it('утро наступило: отложенный джоб отрабатывает и шлёт РОВНО одно сообщение', function () {
// 🪤 Queue::fake ОБЯЗАТЕЛЕН: в тестах очередь синхронная, и «переставить себя на
// утро» она исполнит НЕМЕДЛЕННО, проглотив задержку, — джоб позовёт сам себя без
// конца. В бою очередь redis и задержка соблюдается (В-101).
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
// Ночью: лид пришёл, ничего не ушло.
Carbon::setTestNow(autoWinUtc('2026-08-03 23:00:00'));
$deal = autoWinDeal($tenant->id, subjectCode: 82);
autoWinRunJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0);
// Утро: отложенный джоб проснулся. И следом пришёл второй — задвоение
// (отложенный + повторная постановка). Сообщение обязано остаться одно.
Carbon::setTestNow(autoWinUtc('2026-08-04 07:00:00'));
autoWinRunJob((int) $deal->id, (int) $tenant->id);
autoWinRunJob((int) $deal->id, (int) $tenant->id);
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1)
->and($messages->first()->status)->toBe(ClientSmsMessage::STATUS_FAKE_SENT)
->and($messages->first()->deal_id)->toBe((int) $deal->id);
Carbon::setTestNow();
});
it('Камчатка: московский вечер — это камчатская ночь, СМС ждёт камчатского утра', function () {
// 17:00 UTC = 20:00 в Москве (окно уже закрылось) и 05:00 следующего дня на Камчатке (+12).
Carbon::setTestNow(autoWinUtc('2026-08-04 17:00:00'));
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: 27); // Камчатский край (+12)
autoWinRunJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0);
// 10:00 камчатского утра 05.08 = 22:00 UTC 04.08.
Queue::assertPushed(
SendAutoSmsForDealJob::class,
fn (SendAutoSmsForDealJob $job) => $job->delay !== null
&& CarbonImmutable::parse($job->delay)->equalTo(autoWinUtc('2026-08-04 22:00:00')),
);
Carbon::setTestNow();
});
it('регион неизвестен: СМС не уходит и причина ещё НЕ записана — дверь открыта', function () {
// Полдень по Москве: дело не в часе, а в том, что мы не знаем, где живёт человек.
Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00'));
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: null); // сделка заведена руками
autoWinRunJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0);
Queue::assertPushed(
SendAutoSmsForDealJob::class,
fn (SendAutoSmsForDealJob $job) => $job->delay !== null
&& CarbonImmutable::parse($job->delay)->equalTo(autoWinUtc('2026-08-04 10:00:00')),
);
Carbon::setTestNow();
});
it('регион не узнали за сутки: одна честная запись «не знаем регион», денег не берём', function () {
Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00'));
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: null, receivedAt: autoWinUtc('2026-08-03 08:00:00'));
autoWinRunJob((int) $deal->id, (int) $tenant->id);
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
expect($messages->first()->status)->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION)
->and($messages->first()->deal_id)->toBe((int) $deal->id)
->and($messages->first()->campaign_id)->toBeNull()
->and((string) $messages->first()->cost_rub)->toBe('0.00');
// Больше не возвращаемся: причина окончательная.
Queue::assertNothingPushed();
Carbon::setTestNow();
});
it('окончательная причина сильнее ожидания: стоп-лист называется сразу, а не «ждём региона»', function () {
// Номер в стоп-листе И без региона. Человеку важна причина, которая не изменится
// (урок В-67: сначала решаем «уйдёт или нет», и только потом — как это назвать).
Carbon::setTestNow(autoWinUtc('2026-08-03 23:00:00'));
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000001']);
$deal = autoWinDeal($tenant->id, subjectCode: null);
autoWinRunJob((int) $deal->id, (int) $tenant->id);
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1)
->and($messages->first()->status)->toBe(ClientSmsMessage::SKIP_OPTOUT);
Queue::assertNothingPushed();
Carbon::setTestNow();
});
/*
|--------------------------------------------------------------------------
| Третий вход обогащения (В-100): регион спрашиваем у ДаДаты по номеру лида.
|--------------------------------------------------------------------------
| Сделка, заведённая в кабинете руками, региона не имеет вовсе — и без этого
| куска авто-СМС ей не уходила бы никогда. Спрашиваем тем же клиентом и под тем
| же дневным лимитом расходов, что и остальные обогащения (решение владельца В-88).
| Платим за номер ОДИН раз: ответ несём в самом задании (моё решение В-104).
*/
it('3.3 — сделка без региона: спрашиваем ДаДату по номеру лида и шлём по её ответу', function () {
// 09:00 UTC = полдень в Москве: если ДаДата скажет «UTC+3», окно открыто.
Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00'));
autoWinDaData('UTC+3');
Queue::fake(); // страховка от В-101: если пояс не узнается, джоб зовёт себя без конца
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: null);
autoWinRunJob((int) $deal->id, (int) $tenant->id);
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1)
->and($messages->first()->status)->toBe(ClientSmsMessage::STATUS_FAKE_SENT);
Http::assertSentCount(1); // один номер — один платный вопрос
Queue::assertNothingPushed();
Carbon::setTestNow();
});
it('контрольный: ДаДата не знает регион — лид ждёт дальше, в журнале пусто', function () {
// Без этого прогона предыдущий ничего не доказывает: «ушло» могло бы означать
// «уходит всегда, спросили мы или нет» (урок В-54).
Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00'));
autoWinDaData(null); // ответ есть, пояса в нём нет
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: null);
autoWinRunJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0);
// Ждём дальше — но с отметкой «уже спрашивали»: платить второй раз за тот же
// номер незачем, ДаДата ответит то же самое (В-104).
Queue::assertPushed(
SendAutoSmsForDealJob::class,
fn (SendAutoSmsForDealJob $job) => $job->daDataAsked === true && $job->tzOffsetMinutes === null,
);
Carbon::setTestNow();
});
it('3.3 — за один номер платим один раз: с отметкой «уже спрашивали» ДаДату не дёргаем', function () {
Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00'));
autoWinDaData('UTC+3');
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: null);
autoWinRunJob((int) $deal->id, (int) $tenant->id, asked: true);
Http::assertNothingSent();
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0);
Carbon::setTestNow();
});
it('3.3 — узнанный пояс несётся в отложенное задание, а не спрашивается заново', function () {
// 17:00 UTC = 05:00 утра на Камчатке (UTC+12): окно ещё закрыто, ждём десяти утра.
Carbon::setTestNow(autoWinUtc('2026-08-03 17:00:00'));
autoWinDaData('UTC+12');
Queue::fake();
$tenant = Tenant::factory()->create();
autoWinRule($tenant->id);
$deal = autoWinDeal($tenant->id, subjectCode: null);
autoWinRunJob((int) $deal->id, (int) $tenant->id);
Http::assertSentCount(1);
Queue::assertPushed(
SendAutoSmsForDealJob::class,
fn (SendAutoSmsForDealJob $job) => $job->tzOffsetMinutes === 720
&& $job->delay !== null
&& CarbonImmutable::parse($job->delay)->equalTo(autoWinUtc('2026-08-03 22:00:00')),
);
Carbon::setTestNow();
});