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, Камчатка — 27. */ 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, ?int $tz = null, bool $asked = false): void { (new SendAutoSmsForDealJob($dealId, $tenantId, $tz, $asked))->handle( app(ClientSmsRecipientSelector::class), app(ClientSmsPricing::class), app(AdWalletService::class), app(SmsQuietHours::class), app(DaDataPhoneClient::class), app(DaDataBudgetGuard::class), ); } /** Включает ДаДату и задаёт её ответ про пояс (заглушка — живой ключ не трогаем). */ function autoWinDaData(?string $timezone): void { config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']); Cache::flush(); // чистый дневной бюджет Http::fake(['cleaner.dadata.ru/*' => Http::response([[ 'qc' => 0, 'provider' => 'МТС', 'timezone' => $timezone, ]], 200)]); } /** Момент 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(); }); /* |-------------------------------------------------------------------------- | Третий вход обогащения (В-100): регион спрашиваем у ДаДаты по номеру лида. |-------------------------------------------------------------------------- | Сделка, заведённая в кабинете руками, региона не имеет вовсе — и без этого | куска авто-СМС ей не уходила бы никогда. Спрашиваем тем же клиентом и под тем | же дневным лимитом расходов, что и остальные обогащения (решение владельца В-88). | Платим за номер ОДИН раз: ответ несём в самом задании (моё решение В-104). */ it('3.3 — сделка без региона: спрашиваем ДаДату по номеру лида и шлём по её ответу', function () { // 09:00 UTC = полдень в Москве: если ДаДата скажет «UTC+3», окно открыто. Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00')); autoWinDaData('UTC+3'); Queue::fake(); // страховка от В-101: если пояс не узнается, джоб зовёт себя без конца $tenant = Tenant::factory()->create(); autoWinRule($tenant->id); $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::STATUS_FAKE_SENT); Http::assertSentCount(1); // один номер — один платный вопрос Queue::assertNothingPushed(); Carbon::setTestNow(); }); it('контрольный: ДаДата не знает регион — лид ждёт дальше, в журнале пусто', function () { // Без этого прогона предыдущий ничего не доказывает: «ушло» могло бы означать // «уходит всегда, спросили мы или нет» (урок В-54). Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00')); autoWinDaData(null); // ответ есть, пояса в нём нет 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); // Ждём дальше — но с отметкой «уже спрашивали»: платить второй раз за тот же // номер незачем, ДаДата ответит то же самое (В-104). Queue::assertPushed( SendAutoSmsForDealJob::class, fn (SendAutoSmsForDealJob $job) => $job->daDataAsked === true && $job->tzOffsetMinutes === null, ); Carbon::setTestNow(); }); it('3.3 — за один номер платим один раз: с отметкой «уже спрашивали» ДаДату не дёргаем', function () { Carbon::setTestNow(autoWinUtc('2026-08-04 09:00:00')); autoWinDaData('UTC+3'); Queue::fake(); $tenant = Tenant::factory()->create(); autoWinRule($tenant->id); $deal = autoWinDeal($tenant->id, subjectCode: null); autoWinRunJob((int) $deal->id, (int) $tenant->id, asked: true); Http::assertNothingSent(); expect(ClientSmsMessage::where('tenant_id', $tenant->id)->count())->toBe(0); Carbon::setTestNow(); }); it('3.3 — узнанный пояс несётся в отложенное задание, а не спрашивается заново', function () { // 17:00 UTC = 05:00 утра на Камчатке (UTC+12): окно ещё закрыто, ждём десяти утра. Carbon::setTestNow(autoWinUtc('2026-08-03 17:00:00')); autoWinDaData('UTC+12'); Queue::fake(); $tenant = Tenant::factory()->create(); autoWinRule($tenant->id); $deal = autoWinDeal($tenant->id, subjectCode: null); autoWinRunJob((int) $deal->id, (int) $tenant->id); Http::assertSentCount(1); Queue::assertPushed( SendAutoSmsForDealJob::class, fn (SendAutoSmsForDealJob $job) => $job->tzOffsetMinutes === 720 && $job->delay !== null && CarbonImmutable::parse($job->delay)->equalTo(autoWinUtc('2026-08-03 22:00:00')), ); Carbon::setTestNow(); });