Files
portal/app/tests/Feature/ClientSms/AutoSmsWindowTest.php
T
Дмитрий 2460623ae0 feat(смс-клиент): ночной лид получает авто-СМС утром и ровно одно
Этап 3 «Время и цена», Task 4. Строка листа 3.6, дозакрыта 3.1.

Авто-СМС на новый лид больше не будит людей ночью. Джоб спрашивает про час у того же
SmsQuietHours, что и рассылка, и если у ПОЛУЧАТЕЛЯ окно закрыто — переставляет сам себя
на момент его открытия. В журнал при этом не пишет ничего: идемпотентность у авто-СМС —
по наличию любой записи по этому лиду, и запись «ждём утра» навсегда закрыла бы лиду
дорогу (класс поломки В-49). «Ровно одно» держит прежняя защита: три запуска подряд дают
одно сообщение.

Проверка стоит ПОСЛЕ отбора, а не сразу после идемпотентности, как предлагал план.
Сначала выясняется, уйдёт ли номер вообще (стоп-лист, дубль, нет маршрута), и только
потом — когда именно. Иначе номеру из стоп-листа вместо окончательной причины досталось
бы «ждём региона», и человек ждал бы отправки, которой не будет никогда (В-67).
Контрольный тест это стережёт: перенос проверки вперёд его роняет.

Москву при пустом регионе не подставляем. План велел «нет региона — считаем московским»,
но эту запись владелец отменил (В-85, журнал В-98): вставь я её дословно, камчатский лид,
пришедший в московский вечер, получил бы СМС в четыре утра. Сделка без региона сутки ждёт
уточнения — джоб переставляет себя раз в час и молчит, — потом получает одну честную
запись skipped_unknown_region. Подписи причины заведены в обоих словарях экрана сразу
(ловушка В-70), хотя журнала авто-СМС на портале пока нет вовсе — это существующая дыра,
названа отдельно (В-102).

Старым тестам авто-СМС проставлен регион в фикстурах и зафиксирован час: тот же ремонт,
что в В-93 — боевое правило не ослаблял.

Прогоны: СМС 202/202 (было 195, 7 новых тестов), приём лидов 17/17, фронт 1663 зелёных,
phpstan 0, pint чисто, типы — те же 8 чужих давних. Миграций нет.

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

Живьём на локальной базе (18:15 в Москве, 03:15 на Камчатке): московский лид ушёл сразу,
камчатский отложен на 22:00 UTC — это десять утра следующего дня у него; свежая сделка без
региона отложена на час, двухдневная получила причину. Затем открыл окно камчатской и
запустил трижды — сообщение одно. Стенд возвращён как был.

🪤 Урок В-101: под тестовой очередью «переставить себя» исполняется немедленно — джоб зовёт
себя без конца и прогон виснет. В таких тестах обязателен Queue::fake().

⚠️ Ветку по-прежнему нельзя выкатывать до Task 5 (В-93).
2026-07-28 18:23:15 +03:00

254 lines
12 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 Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
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, Камчатка — 41.
*/
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): void
{
(new SendAutoSmsForDealJob($dealId, $tenantId))->handle(
app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(SmsQuietHours::class),
);
}
/** Момент 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();
});