true]); // ДаДата включена (ключ задан) — иначе обогащение не стартует вовсе. config(['services.dadata.api_key' => 'test-key', 'services.dadata.secret' => 'test-secret']); Cache::flush(); // чистый дневной бюджет ДаДаты $this->tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); // 🪤 Заглушка ставится ОДИН раз и читает изменяемый список: повторный вызов // Http::fake() прежнюю заглушку НЕ заменяет — отвечает по-прежнему ПЕРВАЯ, и // тест «а теперь ДаДата отвечает» молча проверял бы старый ответ (В-106). $this->daDataByPhone = []; $this->daDataProvider = 'МТС'; Http::fake(['cleaner.dadata.ru/*' => function ($request) { $phone = (string) (($request->data()[0]) ?? ''); return Http::response([[ 'qc' => 0, 'provider' => $this->daDataProvider, 'timezone' => $this->daDataByPhone[$phone] ?? null, ]], 200); }]); }); /** Момент UTC по человеческой записи. */ function snapRegionUtc(string $iso): CarbonImmutable { return CarbonImmutable::parse($iso, 'UTC'); } /** * Что ДаДата отвечает про каждый номер. Можно менять по ходу теста — заглушка * читает этот список на каждый вопрос (см. ловушку В-106 в beforeEach). * * @param array $byPhone номер → строка пояса ДаДаты («UTC+3») * * Ключ int|string намеренно: PHP сам делает из ключа-номера целое число, * поэтому обещание «только строка» было ложным. */ function snapRegionDaData(array $byPhone): void { test()->daDataByPhone = $byPhone; } /** Заказ рассылки по списку, вписанному руками. */ function snapRegionOrder(array $phones): int { return (int) test()->postJson('/api/sms/campaigns', [ 'title' => 'Свой список', 'source' => 'manual', 'body' => 'Здравствуйте!', 'phones' => $phones, ])->assertCreated()->json('id'); } /** Сделка с регионом (или без него). 🪤 Регион живёт в subject_code, НЕ в region_code (В-82). */ function snapRegionDeal(int $tenantId, string $phone, ?int $subjectCode): Deal { return Deal::withoutEvents(fn () => Deal::factory()->create([ 'tenant_id' => $tenantId, 'phone' => $phone, 'phone_operator' => 'МТС', 'subject_code' => $subjectCode, 'received_at' => now()->subDay(), ])); } /** * Настоящий маршрут МТС вместо песочницы — нужен там, где проверяются деньги. * 🪤 Подменять надо роутер в контейнере: канал выбирается ДВАЖДЫ — отборщиком при * создании рассылки и читателем снимка при отправке (В-46). */ function snapRegionRealRoute(): void { $router = new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]); app()->instance(SmsRouter::class, $router); app()->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector( $router, new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer), )); } function snapRegionEnrich(int $campaignId, int $tenantId): void { (new EnrichClientSmsSnapshotRegionJob($campaignId, $tenantId))->handle( app(DaDataPhoneClient::class), app(DaDataBudgetGuard::class), app(SmsQuietHours::class), app(OperatorNormalizer::class), app(AllowedSmsOperators::class), app(ClientSmsRossvyazHint::class), ); } it('3.3 — вписанный руками номер не уходит молча: заказ ставит вопрос ДаДате', function () { $this->travelTo(snapRegionUtc('2026-08-03 09:00')); // полдень в Москве snapRegionDaData(['79995550001' => 'UTC+3']); Queue::fake(); // смотрим на снимок ДО того, как что-либо отработало $id = snapRegionOrder(['79995550001']); $row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail(); // Номер в снимке есть, причины пропуска у него нет, но и пояса нет — он ЖДЁТ. expect($row->tz_offset_minutes)->toBeNull() ->and($row->skip_reason)->toBeNull() ->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0); // И вопрос ДаДате поставлен — без него номер ждал бы вечно. Queue::assertPushed(EnrichClientSmsSnapshotRegionJob::class); }); it('3.3 — узнав регион и оператора, номер уходит в своё утро', function () { $this->travelTo(snapRegionUtc('2026-08-03 09:00')); // 12:00 в Москве, 21:00 на Камчатке snapRegionDaData([ '79995550001' => 'UTC+3', // Москва — окно открыто прямо сейчас '79995550002' => 'UTC+12', // Камчатка — у неё вечер, подождёт до утра ]); $id = snapRegionOrder(['79995550001', '79995550002']); $rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone'); // ДаДата сказала и регион, и оператора — второго обращения за оператором нет. expect($rows['79995550001']->tz_offset_minutes)->toBe(180) ->and($rows['79995550001']->operator)->toBe('mts') ->and($rows['79995550002']->tz_offset_minutes)->toBe(720) ->and($rows['79995550002']->send_after)->not->toBeNull(); // Москвич ушёл сразу, камчатский честно ждёт своего утра. expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1) ->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001') ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW); // Наступило камчатское утро — добор доводит рассылку до конца сам. $this->travelTo(snapRegionUtc('2026-08-03 22:00')); $this->artisan('client-sms:resume-waiting')->assertSuccessful(); expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2) ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE); }); it('контрольный: без ответа ДаДаты номер не уходит, но и не теряется', function () { // Без этого прогона предыдущий ничего не доказывает: «ушло» могло бы означать // «уходит всегда, независимо от того, узнали мы регион или нет» (урок В-54). $this->travelTo(snapRegionUtc('2026-08-03 09:00')); // ДаДата отвечает, но пояса в ответе нет — как для номера, про который она не знает. snapRegionDaData(['79995550001' => null]); $id = snapRegionOrder(['79995550001']); $row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail(); expect($row->tz_offset_minutes)->toBeNull() ->and($row->skip_reason)->toBeNull() // не потерян и не помечен зря ->and(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0) ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW); // И добор его тоже не трогает: ждать нечего, пока регион не узнан. $this->artisan('client-sms:resume-waiting')->assertSuccessful(); expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(0); }); it('3.3 — про номер, у которого пояс уже есть, ДаДату не спрашиваем (это наши деньги)', function () { // Решение В-105: платный вопрос задаём ТОЛЬКО там, где без ответа сообщение не // уйдёт вовсе. Лишний вопрос по рассылке на 20 000 номеров — это тысячи рублей. $this->travelTo(snapRegionUtc('2026-08-03 09:00')); snapRegionDaData(['79995550002' => 'UTC+12']); Queue::fake(); $id = snapRegionOrder(['79995550001', '79995550002']); // Первому пояс уже проставлен (например, прошлым прогоном обогащения). ClientSmsCampaignPhone::where('campaign_id', $id) ->where('phone', '79995550001') ->update(['tz_offset_minutes' => 180]); snapRegionEnrich($id, (int) $this->tenant->id); Http::assertSentCount(1); expect(ClientSmsCampaignPhone::where('campaign_id', $id)->where('phone', '79995550002') ->value('tz_offset_minutes'))->toBe(720); }); it('3.3 — не узнали за сутки: честная причина, и рассылка заканчивается', function () { $this->travelTo(snapRegionUtc('2026-08-03 09:00')); snapRegionDaData(['79995550001' => null]); // ДаДата про этот номер ничего не знает $id = snapRegionOrder(['79995550001']); // Сутки номер ждёт — молча, без причины: вдруг регион ещё узнается (В-89). $this->travelTo(snapRegionUtc('2026-08-04 08:00')); // прошло 23 часа $this->artisan('client-sms:resume-waiting')->assertSuccessful(); expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))->toBeNull() ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW); // Сутки прошли — называем причину и заканчиваем рассылку. $this->travelTo(snapRegionUtc('2026-08-04 10:00')); // прошло 25 часов $this->artisan('client-sms:resume-waiting')->assertSuccessful(); $message = ClientSmsMessage::where('campaign_id', $id)->firstOrFail(); expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason')) ->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION) ->and($message->status)->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION) ->and((string) $message->cost_rub)->toBe('0.00') ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE); }); it('3.3 — узнанный за сутки регион побеждает: причину так и не поставили', function () { // Парный к предыдущему (В-54): если бы «через сутки» ставилось всем подряд, этот // прогон покраснел бы — номер узнал свой регион и обязан уйти, а не получить причину. $this->travelTo(snapRegionUtc('2026-08-03 09:00')); snapRegionDaData(['79995550001' => null]); $id = snapRegionOrder(['79995550001']); // Через час ДаДата уже отвечает — например, обновила справочник. $this->travelTo(snapRegionUtc('2026-08-03 10:00')); snapRegionDaData(['79995550001' => 'UTC+3']); snapRegionEnrich($id, (int) $this->tenant->id); $this->travelTo(snapRegionUtc('2026-08-04 10:00')); // сутки прошли $this->artisan('client-sms:resume-waiting')->assertSuccessful(); expect(ClientSmsCampaignPhone::where('campaign_id', $id)->value('skip_reason'))->toBeNull() ->and(ClientSmsMessage::where('campaign_id', $id)->value('status')) ->not->toBe(ClientSmsMessage::SKIP_UNKNOWN_REGION) ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE); }); it('3.3 — деньги: заморозка снимается, когда ждать больше нечего', function () { // В песочнице деньги не двигаются вовсе, поэтому здесь настоящий режим: иначе // «заморозка снялась» доказывалось бы тем, что её и не было (В-81). config(['services.sms.sandbox' => false]); snapRegionRealRoute(); Http::fake([ 'cleaner.dadata.ru/*' => fn () => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => null]], 200), '*' => Http::response([ 'code' => 0, 'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]], ], 200), ]); $this->travelTo(snapRegionUtc('2026-08-03 09:00')); app(AdWalletService::class)->topup((int) $this->tenant->id, '1000.00', null, 'test'); snapRegionDeal((int) $this->tenant->id, '79995550001', 82); // Москва — уйдёт snapRegionDeal((int) $this->tenant->id, '79995550003', null); // региона нет — подождёт $id = (int) $this->postJson('/api/sms/campaigns', [ 'title' => 'По сделкам', 'source' => 'deals', 'body' => 'Здравствуйте!', 'audience_days' => 30, ])->assertCreated()->json('id'); $wallet = AdWallet::where('tenant_id', $this->tenant->id)->firstOrFail(); expect((string) $wallet->frozen_rub)->not->toBe('0.00'); // пока ждём — держим $this->travelTo(snapRegionUtc('2026-08-04 10:00')); // сутки прошли $this->artisan('client-sms:resume-waiting')->assertSuccessful(); $wallet->refresh(); $campaign = ClientSmsCampaign::find($id); expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE) ->and((string) $wallet->frozen_rub)->toBe('0.00') // Списано ровно за отправленное, а не за смету на обоих. ->and((string) $campaign->actual_cost_rub)->not->toBe('0.00') ->and((string) $campaign->actual_cost_rub)->toBe( bcadd((string) ClientSmsMessage::where('campaign_id', $id)->sum('cost_rub'), '0', 2) ); }); it('4.14 — оператор не из четвёрки, названный ДаДатой, выбывает из снимка', function () { // 🔴 Вторая точка решения (строка листа 4.14, решение владельца В-149). // Сырое имя оператора есть только в ответе ДаДаты: в снимок ложится // канонический ключ, и «Тинькофф Мобайл» стал бы там неотличим от «ещё не // спрашивали». Значит приговор выносится в момент ответа, другого момента нет. // // Тест ПАРНЫЙ (урок В-54): рядом стоит тот же прогон с оператором из четвёрки. // Без него «выбыл» доказывало бы разве что «выбывают все подряд». $this->travelTo(snapRegionUtc('2026-08-03 09:00')); // полдень в Москве snapRegionDaData(['79995550001' => 'UTC+3', '79995550002' => 'UTC+3']); Queue::fake(); // обогащение зовём руками, чтобы смотреть ровно на него $allowed = snapRegionOrder(['79995550001']); // 🪤 Текст у второй рассылки ДРУГОЙ: одинаковый текст в те же десять минут // портал считает случайным повтором и отвечает 409 (защита из строки 2.6). $denied = (int) $this->postJson('/api/sms/campaigns', [ 'title' => 'Свой список — второй', 'source' => 'manual', 'body' => 'Добрый день!', 'phones' => ['79995550002'], ])->assertCreated()->json('id'); $this->daDataProvider = 'МТС'; snapRegionEnrich($allowed, (int) $this->tenant->id); $this->daDataProvider = 'Тинькофф Мобайл'; snapRegionEnrich($denied, (int) $this->tenant->id); $allowedRow = ClientSmsCampaignPhone::where('campaign_id', $allowed)->firstOrFail(); $deniedRow = ClientSmsCampaignPhone::where('campaign_id', $denied)->firstOrFail(); // Абонент четвёрки: узнал пояс и оператора, причины пропуска нет. expect($allowedRow->skip_reason)->toBeNull() ->and($allowedRow->tz_offset_minutes)->toBe(180) ->and($allowedRow->operator)->toBe('mts'); // Мелкий оператор: честная причина, и время ему больше не нужно. expect($deniedRow->skip_reason)->toBe(ClientSmsMessage::SKIP_OPERATOR_DENIED) ->and($deniedRow->tz_offset_minutes)->toBeNull(); }); it('3.3 — выбранный дневной лимит ДаДаты не помечает номера окончательно', function () { // Лимит выбран — это не «регион неизвестен навсегда», а «спросим завтра». $this->travelTo(snapRegionUtc('2026-08-03 09:00')); snapRegionDaData(['79995550001' => 'UTC+3']); config(['services.dadata.daily_cap_rub' => 1]); app(DaDataBudgetGuard::class)->recordSpend(200); // 2 ₽ > 1 ₽ Queue::fake(); $id = snapRegionOrder(['79995550001']); snapRegionEnrich($id, (int) $this->tenant->id); $row = ClientSmsCampaignPhone::where('campaign_id', $id)->firstOrFail(); Http::assertNothingSent(); expect($row->tz_offset_minutes)->toBeNull() ->and($row->skip_reason)->toBeNull(); });