Files
portal/app/tests/Feature/ClientSms/AutoSmsTest.php
T
Дмитрий c47c3fb4d5 fix(смс-клиент): недосписание при повторе, срок для рассылки по сделкам, заслонка баланса + понятный интерфейс
Корректность/деньги:
- джоб рассылки: списание ПОШТУЧНО и атомарно с записью в журнал, ключ идемпотентности на номер, факт из БД — нет недосписания при падении посреди рассылки и повторе
- валидация: срок обязателен при source=deals, иначе выборка по всей истории сделок
- «Отправить» гаснет при нехватке свободного баланса; index отдаёт баланс/заморозку
- помесячный джоб имени: устойчив к пустым настройкам, сигнал в лог при автоотключении за долг
- цена одного авто-СМС + подтверждение при включении авто-рассылки
- загрузка контактов сообщает число нераспознанных номеров

Понятность для клиента:
- «СМС» вместо «сегментов», «пробный режим» вместо «песочницы», человеческие причины пропуска, шаги 1-2-3, выгода своего имени, факт вместо оценки в таблице, имя и текст в подтверждении

Тесты: бэкенд ClientSms 83/83, фронт СМС 50/50, приём лидов 10/10; pint/stan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 10:27:14 +03:00

288 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\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\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
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);
/** Правило авто-СМС тенанта (одно на тенанта — 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,
'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),
);
}
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');
});