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(); });