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), ); } 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'); });