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

310 lines
14 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\AdWallet;
use App\Models\ClientSmsAutoRule;
use App\Models\ClientSmsMessage;
use App\Models\ClientSmsOptout;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
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 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\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
/**
* Авто-СМС на каждый НОВЫЙ лид (Task 15) — Eloquent observer поверх боевого
* потока создания сделок + денежно-критичный джоб.
*
* ⚠️ Observer dispatch'ит джоб через ->afterCommit(). Под RefreshDatabase внешняя
* тестовая транзакция НИКОГДА не коммитится, поэтому afterCommit-джоб при обычном
* dispatch не исполняется во время теста (Laravel gotcha). Поэтому:
* — факт, что observer СТАВИТ джоб на свежий лид, проверяем через Queue::fake
* (QueueFake пишет job сразу, минуя afterCommit-отсрочку);
* — поведение самого джоба проверяем прямым вызовом handle() (как SendJobTest).
*
* Синтетические номера 7999… — реальные НИКОГДА (в реальном режиме это боевая
* отправка).
*/
uses(RefreshDatabase::class);
// Час фиксируем на весь файл: с Этапа 3 авто-СМС отдаётся только в окно 10–20 по
// местному времени получателя. Без этого прогон, запущенный вечером, красил бы тесты
// про деньги и стоп-лист — и красил бы по совершенно другой причине.
beforeEach(function () {
Carbon::setTestNow(CarbonImmutable::parse('2026-08-04 09:00:00', 'UTC')); // 12:00 в Москве
});
afterEach(function () {
Carbon::setTestNow();
});
/** Правило авто-СМС тенанта (одно на тенанта — UNIQUE tenant_id). */
function makeAutoRule(int $tenantId, bool $enabled, string $body = 'Здравствуйте!'): ClientSmsAutoRule
{
return ClientSmsAutoRule::create([
'tenant_id' => $tenantId,
'enabled' => $enabled,
'body' => $body,
'sender_name' => 'liderra.ru',
'updated_by' => null,
]);
}
/**
* Свежий лид (в пределах суток) БЕЗ срабатывания DealSmsObserver — чтобы кейсы
* поведения джоба гоняли РОВНО один явный вызов (иначе observer поставил бы второй
* джоб, резолвящий селектор из контейнера, и он бы конфликтовал/блокировал по
* идемпотентности). Сам observer тестируется отдельно (см. тест ниже с Queue::fake).
*/
function makeFreshDeal(int $tenantId, string $phone, ?string $operator = 'МТС'): Deal
{
return Deal::withoutEvents(fn () => Deal::factory()->create([
'tenant_id' => $tenantId,
'phone' => $phone,
'phone_operator' => $operator,
// Регион нужен с Этапа 3: номеру, про который мы не знаем, где он живёт, СМС
// не уходит вовсе (В-85). Предмет этих тестов — деньги, стоп-лист и повторы,
// поэтому регион ставим самый обычный. 82 = Москва.
'subject_code' => 82,
'received_at' => now(),
]));
}
/** Селектор с настоящим МТС-роутером — детерминированный маршрут для реального режима. */
function autoRealMtsSelector(): ClientSmsRecipientSelector
{
return new ClientSmsRecipientSelector(
new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]),
new OperatorNormalizer,
);
}
/** Успешный ответ МТС (омни-адаптер api.mts.ru), см. MtsSmsProvider::send. */
function autoMtsSuccessResponse(): array
{
return [
'code' => 0,
'data' => ['submitResults' => [['msid' => '79990000001', 'messageID' => 'MSG-AUTO-1', 'code' => 'OK']]],
];
}
/** Прямой вызов джоба (как SendJobTest) — обходит afterCommit-отсрочку RefreshDatabase. */
function runAutoJob(int $dealId, int $tenantId, ?ClientSmsRecipientSelector $selector = null): void
{
(new SendAutoSmsForDealJob($dealId, $tenantId))->handle(
$selector ?? app(ClientSmsRecipientSelector::class),
app(ClientSmsPricing::class),
app(AdWalletService::class),
app(SmsQuietHours::class),
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
);
}
it('observer ставит авто-СМС джоб на ГЕНУИННО новый лид', function () {
config(['services.sms.sandbox' => true]);
Queue::fake();
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
// Напрямую (НЕ makeFreshDeal) — чтобы DealSmsObserver сработал; Queue::fake ловит dispatch.
$deal = Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '79990000001',
'phone_operator' => 'МТС',
'received_at' => now(),
]);
Queue::assertPushed(
SendAutoSmsForDealJob::class,
fn (SendAutoSmsForDealJob $job) => $job->dealId === (int) $deal->id
&& $job->tenantId === (int) $tenant->id,
);
});
it('джоб шлёт ровно одну авто-СМС на новый лид (sandbox → fake_sent, deal_id, campaign_id null)', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id);
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
$m = $messages->first();
expect($m->deal_id)->toBe((int) $deal->id)
->and($m->campaign_id)->toBeNull()
->and($m->status)->toBe(ClientSmsMessage::STATUS_FAKE_SENT)
->and((string) $m->cost_rub)->toBe('0.00');
});
it('идемпотентность: повторный запуск джоба на тот же лид не плодит второе сообщение', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id);
runAutoJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->where('deal_id', $deal->id)->count())->toBe(1);
});
it('правило выключено → авто-СМС не уходит', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: false);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id);
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0);
});
it('исторический лид → observer пропускает, джоб не ставится', function () {
config(['services.sms.sandbox' => true]);
Carbon::setTestNow('2026-07-25 12:00:00');
Queue::fake();
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
Deal::factory()->create([
'tenant_id' => $tenant->id,
'phone' => '79990000009',
'phone_operator' => 'МТС',
'received_at' => now()->subDays(3),
]);
Queue::assertNotPushed(SendAutoSmsForDealJob::class);
Carbon::setTestNow();
});
it('стоп-лист: джоб журналирует skipped_optout и не шлёт', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
makeAutoRule($tenant->id, enabled: true);
ClientSmsOptout::create(['tenant_id' => $tenant->id, 'phone' => '79990000001']);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id);
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
$m = $messages->first();
expect($m->status)->toBe(ClientSmsMessage::SKIP_OPTOUT)
->and($m->deal_id)->toBe((int) $deal->id)
->and($m->campaign_id)->toBeNull()
->and((string) $m->cost_rub)->toBe('0.00');
});
it('реальный режим: уходит через МТС, списывает cost по external_key, повтор не списывает дважды', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(autoMtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', null, 'test');
makeAutoRule($tenant->id, enabled: true);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id, autoRealMtsSelector());
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
$m = $messages->first();
expect($m->status)->toBe(ClientSmsMessage::STATUS_SENT)
->and($m->provider_key)->toBe('mts')
->and($m->provider_message_id)->toBe('MSG-AUTO-1')
->and($m->deal_id)->toBe((int) $deal->id)
->and($m->campaign_id)->toBeNull()
->and((string) $m->cost_rub)->toBe('9.00'); // объём 1 → ступень min_qty=1 → 9.00
// Деньги списаны: 1000 − 9.00.
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('991.00');
// Повторный запуск — не плодит и не списывает (deal_id-guard + external_key).
runAutoJob((int) $deal->id, (int) $tenant->id, autoRealMtsSelector());
expect(ClientSmsMessage::where('tenant_id', $tenant->id)->where('deal_id', $deal->id)->count())->toBe(1)
->and((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('991.00');
});
it('реальный режим: денег не хватает → skipped_no_funds, ничего не шлёт, баланс не меняется', function () {
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response(autoMtsSuccessResponse(), 200)]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5.00', null, 'test'); // < 9.00
makeAutoRule($tenant->id, enabled: true);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id, autoRealMtsSelector());
$messages = ClientSmsMessage::where('tenant_id', $tenant->id)->get();
expect($messages)->toHaveCount(1);
expect($messages->first()->status)->toBe(ClientSmsMessage::SKIP_NO_FUNDS)
->and($messages->first()->deal_id)->toBe((int) $deal->id);
// Ничего не ушло провайдеру, баланс цел.
Http::assertNothingSent();
expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('5.00');
});
it('autoRule возвращает цену одного авто-СМС по тарифу (чтобы клиент видел, во что встанет)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
// короткий текст → 1 сегмент → объём 1 → ступень min_qty=1 = 9.00
$this->postJson('/api/sms/auto-rule', ['enabled' => true, 'body' => 'Спасибо за заявку!'])
->assertOk()
->assertJsonPath('estimated_cost_rub', '9.00');
$this->getJson('/api/sms/auto-rule')
->assertOk()
->assertJsonPath('estimated_cost_rub', '9.00');
});
it('эндпоинты autoRule/saveAutoRule: сохранение и чтение, снимок имени отправителя', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$save = $this->postJson('/api/sms/auto-rule', [
'enabled' => true,
'body' => 'Спасибо за заявку!',
]);
$save->assertOk()
->assertJsonPath('enabled', true)
->assertJsonPath('body', 'Спасибо за заявку!')
->assertJsonPath('sender_name', 'liderra.ru'); // снимок эффективного имени
$get = $this->getJson('/api/sms/auto-rule');
$get->assertOk()
->assertJsonPath('enabled', true)
->assertJsonPath('body', 'Спасибо за заявку!')
->assertJsonPath('sender_name', 'liderra.ru');
});