true]); $this->tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); }); /** Момент UTC по человеческой записи. */ function windowUtc(string $iso): CarbonImmutable { return CarbonImmutable::parse($iso, 'UTC'); } /** Сделка с регионом. 🪤 Регион живёт в subject_code (1..89), НЕ в region_code (В-82). */ function windowDeal(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(), ])); } /** Заказ рассылки по сделкам. */ function windowPayload(array $extra = []): array { return $extra + [ 'title' => 'Окно', 'source' => 'deals', 'body' => 'Здравствуйте!', 'audience_days' => 30, ]; } /** * Настоящий маршрут МТС вместо песочницы — нужен там, где проверяются деньги. * 🪤 Подменять надо роутер в контейнере: канал выбирается ДВАЖДЫ — отборщиком при * создании рассылки и читателем снимка при отправке (В-46). */ function windowRealRoute(): void { $router = new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]); app()->instance(SmsRouter::class, $router); app()->instance(ClientSmsRecipientSelector::class, new ClientSmsRecipientSelector( $router, new OperatorNormalizer, )); } function windowRunJob(int $campaignId, int $tenantId): void { (new SendClientSmsCampaignJob($campaignId, $tenantId))->handle( app(ClientSmsSnapshotReader::class), app(ClientSmsPricing::class), app(AdWalletService::class), ); } it('в московский полдень камчатский номер ждёт своего утра', function () { $this->travelTo(windowUtc('2026-08-03 09:00')); // 12:00 в Москве, 21:00 на Камчатке windowDeal($this->tenant->id, '79995550001', 82); // Москва, UTC+3 windowDeal($this->tenant->id, '79995550002', 27); // Камчатский край, UTC+12 $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); $rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone'); expect($rows['79995550001']->tz_offset_minutes)->toBe(180) ->and($rows['79995550001']->send_after)->toBeNull() // Москве — прямо сейчас ->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); }); it('контрольный: камчатским утром уходит камчатский, а Москва ждёт', function () { // Тот же расклад, что выше, но время другое: 22:00 UTC = 10:00 утра на Камчатке // и полночь в Москве. Без этого прогона «ушёл один» ничего не доказывает (В-54). $this->travelTo(windowUtc('2026-08-02 22:00')); windowDeal($this->tenant->id, '79995550001', 82); // Москва windowDeal($this->tenant->id, '79995550002', 27); // Камчатка $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1) ->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550002') ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW); }); it('днём в рабочее время уходят оба — окно никого не задерживает зря', function () { // 07:00 UTC = 10:00 в Москве и… 19:00 на Камчатке: окно открыто у обоих. $this->travelTo(windowUtc('2026-08-03 07:00')); windowDeal($this->tenant->id, '79995550001', 82); windowDeal($this->tenant->id, '79995550002', 27); $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2) ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_DONE); }); it('номер без региона не уходит вовсе и ждёт уточнения — решение владельца В-85', function () { $this->travelTo(windowUtc('2026-08-03 09:00')); // рабочий день в Москве // Сделка, заведённая в кабинете руками: региона у неё нет вообще. windowDeal($this->tenant->id, '79995550003', 82)->update(['subject_code' => null]); windowDeal($this->tenant->id, '79995550001', 82); $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); $rows = ClientSmsCampaignPhone::where('campaign_id', $id)->get()->keyBy('phone'); // Он не пропущен (причины нет) и не отправлен — он ждёт, когда узнаем регион. expect($rows['79995550003']->tz_offset_minutes)->toBeNull() ->and($rows['79995550003']->skip_reason)->toBeNull(); expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1) ->and(ClientSmsMessage::where('campaign_id', $id)->value('phone'))->toBe('79995550001'); // И в журнале рассылки про него НЕ написано «нет маршрута»: мы его даже не пробовали. expect(ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550003')->exists()) ->toBeFalse(); }); it('ждущий утра номер не съедает заморозку и не считается отправленным', function () { $this->travelTo(windowUtc('2026-08-03 09:00')); windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — подождёт Queue::fake(); // отправку запускаем сами, чтобы посмотреть снимок до неё $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); $campaign = ClientSmsCampaign::find($id); expect($campaign->planned_count)->toBe(2); // смета считалась на ОБОИХ windowRunJob($id, $this->tenant->id); $campaign->refresh(); expect($campaign->sent_count)->toBe(1) ->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW) ->and($campaign->stop_reason)->toBeNull(); }); /* |-------------------------------------------------------------------------- | Строки листа 3.4–3.5: созревшие дошлются САМИ, без человека. |-------------------------------------------------------------------------- | Раз в четверть часа команда `client-sms:resume-waiting` обходит рассылки, | висящие «ждёт утра», и заново кладёт в очередь те, у которых что-то созрело. | Повторный запуск безопасен: джоб пропускает номера, по которым уже есть запись | в журнале, а списание идёт по ключу на номер. */ it('созревший номер дошлётся сам — команда добора доводит рассылку до конца', function () { $this->travelTo(windowUtc('2026-08-03 09:00')); // полдень в Москве, 21:00 на Камчатке windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — подождёт до своего утра $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1) ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW); // Наступило камчатское утро. Человек ничего не нажимает — работает расписание. $this->travelTo(windowUtc('2026-08-03 22:00')); // 10:00 на Камчатке $this->artisan('client-sms:resume-waiting')->assertSuccessful(); $campaign = ClientSmsCampaign::find($id); expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(2) ->and(ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550002')->exists())->toBeTrue() ->and($campaign->sent_count)->toBe(2) ->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE); }); it('контрольный: пока утро не наступило, команда добора рассылку не трогает', function () { // Без этого прогона предыдущий ничего не доказывает: «дошлось» могло бы означать // «команда шлёт всем подряд, не глядя на часы» (урок В-54). $this->travelTo(windowUtc('2026-08-03 09:00')); windowDeal($this->tenant->id, '79995550001', 82); windowDeal($this->tenant->id, '79995550002', 27); $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); // Очередь глушим ПОСЛЕ создания: сама рассылка уже отработала и висит «ждёт утра», // а вот тронет ли её команда — как раз то, что проверяем. Queue::fake(); $this->artisan('client-sms:resume-waiting')->assertSuccessful(); Queue::assertNothingPushed(); expect(ClientSmsMessage::where('campaign_id', $id)->count())->toBe(1) ->and(ClientSmsCampaign::find($id)->status)->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW); }); it('список рассылок различает «ждут утра» и «ждут уточнения региона»', function () { // Строка листа 3.4. Свалить два ожидания в одно число нельзя: первое пройдёт само // к утру, второе может кончиться тем, что номер не уйдёт вовсе (следствие В-85). $this->travelTo(windowUtc('2026-08-03 09:00')); windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — ждёт утра windowDeal($this->tenant->id, '79995550003', 82)->update(['subject_code' => null]); // ждёт региона $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); $row = collect($this->getJson('/api/sms/campaigns')->assertOk()->json('campaigns')) ->firstWhere('id', $id); expect($row['status'])->toBe(ClientSmsCampaign::STATUS_WAITING_WINDOW) ->and($row['sent_count'])->toBe(1) ->and($row['planned_count'])->toBe(3) ->and($row['waiting_window_count'])->toBe(1) ->and($row['waiting_region_count'])->toBe(1); }); it('деньги: заморозка держится всё ожидание и снимается, когда дошёл последний номер', function () { config(['services.sms.sandbox' => false]); windowRealRoute(); Http::fake(['*' => Http::response([ 'code' => 0, 'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]], ], 200)]); $this->travelTo(windowUtc('2026-08-03 09:00')); app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test'); windowDeal($this->tenant->id, '79995550001', 82); windowDeal($this->tenant->id, '79995550002', 27); $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); // Пока ждём камчатское утро, смета остаётся замороженной целиком: деньги на // оставшийся номер ещё понадобятся, и отдавать их клиенту обратно нельзя. $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first(); expect((string) $wallet->frozen_rub)->not->toBe('0.00'); $this->travelTo(windowUtc('2026-08-03 22:00')); $this->artisan('client-sms:resume-waiting')->assertSuccessful(); $campaign = ClientSmsCampaign::find($id); $wallet->refresh(); expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE) ->and((string) $wallet->frozen_rub)->toBe('0.00') // Факт — сумма по журналу, а не смета: «записано» ⟺ «оплачено». ->and((string) $campaign->actual_cost_rub)->toBe( bcadd((string) ClientSmsMessage::where('campaign_id', $id)->sum('cost_rub'), '0', 2) ) ->and((string) $campaign->actual_cost_rub)->not->toBe('0.00'); // иначе тест пустой }); it('3.14 — цена зафиксирована при создании: объём вырос, а списано по старой цене', function () { // Рассылка растянулась на сутки, за это время клиент отправил ещё 5 000 СМС и // перешёл на дешёвую ступень. Досланному номеру списываем ТУ ЖЕ цену, которую // клиент видел при заказе, — пересчёт задним числом был бы обманом в обе стороны. config(['services.sms.sandbox' => false]); windowRealRoute(); Http::fake(['*' => Http::response([ 'code' => 0, 'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]], ], 200)]); $this->travelTo(windowUtc('2026-08-03 09:00')); app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test'); windowDeal($this->tenant->id, '79995550001', 82); // Москва — уйдёт сразу windowDeal($this->tenant->id, '79995550002', 27); // Камчатка — дождётся утра $id = (int) $this->postJson('/api/sms/campaigns', windowPayload())->assertCreated()->json('id'); $campaign = ClientSmsCampaign::find($id); $fixedPrice = (string) $campaign->price_rub_per_sms; expect($fixedPrice)->toBe('9.00'); // объём заказа 2 СМС — самая дорогая ступень // Пока камчатский номер ждёт утра, клиент отправил ещё 5 000 СМС другой рассылкой. ClientSmsMessage::create([ 'tenant_id' => $this->tenant->id, 'phone' => '79995559999', 'status' => ClientSmsMessage::STATUS_SENT, 'segments' => 5000, 'cost_rub' => '0.00', ]); // Новый заказ уже пошёл бы по дешёвой ступени — иначе тест ничего не доказывает. expect(app(ClientSmsPricing::class)->pricePerSmsRubFor((int) $this->tenant->id, 1))->toBe('8.00'); $this->travelTo(windowUtc('2026-08-03 22:00')); $this->artisan('client-sms:resume-waiting')->assertSuccessful(); $kamchatka = ClientSmsMessage::where('campaign_id', $id)->where('phone', '79995550002')->firstOrFail(); expect($kamchatka->status)->toBe(ClientSmsMessage::STATUS_SENT) ->and((string) $kamchatka->cost_rub)->toBe($fixedPrice); });