Files
portal/app/tests/Feature/ClientSms/AutoSmsTest.php
T
Дмитрий 026e71a283 feat(смс-клиент): цена по накоплению за месяц, а не по объёму одного заказа
Этап 3 «Время и цена», Task 6. Строки листа 3.8, 3.9, 3.10, 3.14.

Клиент, разбивший месяц на десять рассылок по сто номеров, платил по самой дорогой
ступени, хотя отправил тысячу. Авто-СМС и вовсе всегда считалась по самой дорогой:
в ступень уезжало число сегментов одного сообщения. Теперь ступень берётся для
«сколько отправлено в этом месяце плюс объём самого заказа», и спрашивают об этом
все три места сразу — предпросмотр, создание рассылки и авто-СМС на новый лид.

Счётчик живёт в одном месте (ClientSmsVolumeCounter): его зовёт цена, а в Task 7 позовёт
экран. Отдельного накопительного счётчика в базе не завожу намеренно — счётчик,
разъехавшийся с журналом, опаснее лишнего запроса; журнал правдив, потому что деньги
списываются той же записью.

Считаем в СМС, а не в сообщениях (В-108). Длинное письмо — это два СМС, и платит клиент
за два; считая строки журнала, мы держали бы его на дорогой ступени дольше обещанного, а
цифра на экране «в этом месяце отправлено N СМС» разошлась бы со списанными деньгами.
Ради этого в журнале появилась колонка segments (схема v9.14) и частичный индекс под
единственный запрос счётчика. Пусто у старых записей = одно СМС (В-109).

Граница месяца — по Москве, а не по Гринвичу (В-110): 31 июля 21:30 UTC это уже 1 августа
в Москве. Не путать с окном 10–20 — там время местное у получателя, здесь московское у
клиента.

Цена по-прежнему фиксируется в момент создания и джобом не пересчитывается (3.14).

🔴 Мина, найденная самопроверкой (В-114): авто-СМС считала бы объём месяца ВНЕ изоляции по
клиенту. В запросе экрана контекст ставит middleware, а в очереди — никто, и на бою
счётчик вернул бы честный ноль: клиента молча посчитали бы по самой дорогой ступени, без
единой ошибки в журнале. Тестами не ловится — на стенде изоляция не применяется. Счёт
переехал внутрь tenant-транзакции, как и деньги в том же джобе.

🧹 Убран прежний estimateRub (В-113): он считал смету по ступени для объёма одного заказа,
без накопленного, и больше не звался. Оставленный «на всякий случай» второй расчёт цены —
это место, которое однажды позовут, и цифры разъедутся.

Прогоны: СМС 229/229 (пачками по 3–4 файла — целиком локальная база уже не тянет, В-112),
приём лидов 17/17, фронт 1663 зелёных, phpstan 0 своих, pint чисто.

Вырезанием проверено четырежды: вернул старый расчёт в контроллер — покраснел тест через
настоящий запрос экрана; убрал запись числа СМС в журнал — покраснел тест отправки;
засчитал песочные — счётчик дал 12 вместо 1; перенёс границу месяца на Гринвич — покраснел
тест границы.

Живьём на локальной базе (20:17 МСК, песочница, ДаДата заглушена): предпросмотр до
накопления 9.00 ₽, после 5 000 отправленных — 8.00 ₽; песочная рассылка ушла, в журнале
«СМС=1», счётчик месяца остался нулём; у прежней рассылки цена так и осталась 9.00 ₽, а
новый заказ уже шёл бы по 8.00 ₽. Стенд возвращён как был. Реальное списание по
накопленной ступени доказано тестом, а не живьём: в песочнице деньги не двигаются (В-81).

🪤 Урок В-111: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в
тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку.
2026-07-28 20:28:00 +03:00

342 lines
15 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');
});
it('3.8 — авто-СМС считается по ступени с учётом отправленного за месяц', function () {
// Прежде сюда уезжало число сегментов одного сообщения: авто-СМС всегда шла по
// самой дорогой ступени, сколько бы клиент ни отправил за месяц. Парный прогон —
// тест выше, где накопленного нет и цена честные 9.00 (урок В-54).
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);
// В этом месяце клиент уже отправил 5 000 СМС — он давно на дешёвой ступени.
ClientSmsMessage::create([
'tenant_id' => $tenant->id,
'phone' => '79995559999',
'status' => ClientSmsMessage::STATUS_SENT,
'segments' => 5000,
'cost_rub' => '0.00',
]);
$deal = makeFreshDeal($tenant->id, '79990000001');
runAutoJob((int) $deal->id, (int) $tenant->id, autoRealMtsSelector());
$m = ClientSmsMessage::where('tenant_id', $tenant->id)->where('deal_id', $deal->id)->firstOrFail();
expect($m->status)->toBe(ClientSmsMessage::STATUS_SENT)
->and((string) $m->cost_rub)->toBe('8.00') // а не 9.00, как было
->and($m->segments)->toBe(1) // журнал помнит объём (В-108)
->and((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('992.00');
});