true]); $this->tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); $this->actingAs($this->user); // Этап 3: окно 10–20 по местному времени получателя. Время прогона фиксируем — // иначе тест зависел бы от часа, в который его запустили (вечером все номера // ждали бы утра). 09:00 UTC = полдень в Москве, окно открыто. $this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); }); /** Заказ рассылки по своей базе. Порядок слагаемых: переданное побеждает (оператор «+»). */ function snapshotPayload(array $extra = []): array { return $extra + [ 'title' => 'Снимок', 'source' => 'base', 'body' => 'Здравствуйте!', ]; } function snapshotContact(int $tenantId, string $phone): ClientSmsContact { return ClientSmsContact::create([ 'tenant_id' => $tenantId, 'phone' => $phone, 'name' => 'Кто-то', 'operator' => 'МТС', // Этап 3: без известного региона номер не уходит вовсе (В-85). 'tz_offset_minutes' => 180, ]); } /** * Маршрут МТС существует — иначе в реальном режиме получателей ноль и тест пустой. * * 🪤 Подменять ОДИН отборщик (как в тестах Этапа 1) теперь мало: со снимком канал * выбирается дважды — при создании рассылки отборщиком и при отправке читателем * снимка. Читатель берёт роутер из контейнера, поэтому подменять надо именно * роутер: иначе смета посчитается, а отправится ноль — и денежный тест окажется * пустым, оставаясь зелёным. */ function snapshotRealRoute(): 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 snapshotRunJob(int $campaignId, int $tenantId): void { (new SendClientSmsCampaignJob($campaignId, $tenantId))->handle( app(ClientSmsSnapshotReader::class), app(ClientSmsPricing::class), app(AdWalletService::class), ); } it('фиксирует получателей в момент создания рассылки', function () { snapshotContact($this->tenant->id, '79990000001'); snapshotContact($this->tenant->id, '79990000002'); // Этот в стоп-листе — в снимке он тоже есть, но с причиной «не уйдёт». snapshotContact($this->tenant->id, '79990000009'); ClientSmsOptout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000009']); $response = $this->postJson('/api/sms/campaigns', snapshotPayload())->assertCreated(); $campaignId = (int) $response->json('id'); $snapshot = ClientSmsCampaignPhone::where('campaign_id', $campaignId)->get(); expect($snapshot)->toHaveCount(3) ->and($snapshot->whereNull('skip_reason')->pluck('phone')->sort()->values()->all()) ->toBe(['79990000001', '79990000002']) ->and($snapshot->firstWhere('phone', '79990000009')->skip_reason) ->toBe(ClientSmsMessage::SKIP_OPTOUT); }); it('не шлёт тому, кто появился уже ПОСЛЕ создания рассылки', function () { snapshotContact($this->tenant->id, '79990000001'); $campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload()) ->assertCreated()->json('id'); // Контакт приходит между созданием рассылки и её отправкой: в снимке его нет, // значит и СМС ему не уйдёт — считали одно, отправляем ровно то же. snapshotContact($this->tenant->id, '79990000777'); snapshotRunJob($campaignId, $this->tenant->id); expect(ClientSmsCampaign::find($campaignId)->sent_count)->toBe(1) ->and(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000777')->exists()) ->toBeFalse(); }); it('шлёт тому, кого внесли в стоп-лист уже ПОСЛЕ запуска — решение владельца В-39', function () { snapshotContact($this->tenant->id, '79990000001'); snapshotContact($this->tenant->id, '79990000002'); $campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload()) ->assertCreated()->json('id'); // Клиент передумал уже после запуска. Владелец 27.07: «написать всё равно» — // список зафиксирован, смета и факт обязаны сойтись копейка в копейку. ClientSmsOptout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000002']); snapshotRunJob($campaignId, $this->tenant->id); expect(ClientSmsCampaign::find($campaignId)->sent_count)->toBe(2) ->and(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000002') ->where('status', ClientSmsMessage::STATUS_FAKE_SENT)->exists()) ->toBeTrue(); }); it('2.3 — канал исчез после снимка: в журнале «не определён оператор», как и в предпросмотре', function () { // Номер без оператора: так приходят номера, вписанные руками, и номера «своей // базы», которым ДаДата ещё не проставила оператора. В песочнице заглушка // универсальная, поэтому в снимок он попадает как «уйдёт». ClientSmsContact::create([ 'tenant_id' => $this->tenant->id, 'phone' => '79990000123', 'name' => 'Без оператора', 'operator' => null, // Регион известен, а оператор — нет: тест ровно про оператора. Без региона // номер вообще не дошёл бы до вопроса о маршруте и просто ждал бы (Этап 3, В-85). 'tz_offset_minutes' => 180, ]); // 🪤 Без этого джоб выполняется ВНУТРИ запроса на создание (В-61) — то есть ещё // со старым, универсальным каналом: номер уходит, и мой прогон оказывается // вторым, где он уже «обработан». Отправку выполняем сами, после подмены канала. Queue::fake(); $campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload()) ->assertCreated()->json('id'); expect(ClientSmsCampaignPhone::where('campaign_id', $campaignId)->whereNull('skip_reason')->count()) ->toBe(1); // иначе тест проверял бы не то // Универсальный канал выключили уже ПОСЛЕ создания рассылки — маршрута больше нет. // Причину в журнале ставит читатель снимка, и она обязана совпадать с той, что // человек видел в предпросмотре: «не определён оператор», а не «не из МТС» (В-70). snapshotRealRoute(); snapshotRunJob($campaignId, $this->tenant->id); expect(ClientSmsMessage::where('campaign_id', $campaignId)->where('phone', '79990000123')->value('status')) ->toBe(ClientSmsMessage::SKIP_UNKNOWN_OPERATOR); }); it('списывает ровно смету — до копейки', function () { config(['services.sms.sandbox' => false]); snapshotRealRoute(); Http::fake(['*' => Http::response([ 'code' => 0, 'data' => ['submitResults' => [['msid' => '7999', 'messageID' => 'MSG-1', 'code' => 'OK']]], ], 200)]); app(AdWalletService::class)->topup($this->tenant->id, '1000.00', null, 'test'); snapshotContact($this->tenant->id, '79990000001'); snapshotContact($this->tenant->id, '79990000002'); $campaignId = (int) $this->postJson('/api/sms/campaigns', snapshotPayload()) ->assertCreated()->json('id'); // Мир меняется между сметой и отправкой — на деньги это влиять не должно. snapshotContact($this->tenant->id, '79990000778'); snapshotRunJob($campaignId, $this->tenant->id); $campaign = ClientSmsCampaign::find($campaignId); expect((string) $campaign->actual_cost_rub)->toBe((string) $campaign->estimated_cost_rub) ->and((string) $campaign->actual_cost_rub)->not->toBe('0.00'); // иначе тест пустой $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first(); expect((string) $wallet->balance_rub)->toBe(bcsub('1000.00', (string) $campaign->actual_cost_rub, 2)) ->and((string) $wallet->frozen_rub)->toBe('0.00'); });