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')); }); /** * Провайдер-счётчик: на N-й отправке дёргает переданный обработчик. * * Тип возврата НЕ сужен до SmsProvider намеренно: тест читает счётчик `$calls`, * а интерфейс его не обещает — под интерфейсным типом анализатор счётчика не видит * и краснеет, хотя свойство у класса есть. */ function cancelSpyProvider(?Closure $onSend = null) { return new class($onSend) implements SmsProvider { public int $calls = 0; public function __construct(private readonly ?Closure $onSend) {} public function key(): string { return 'mts'; } public function servesOperators(): array { return ['mts']; } public function priceKopecks(string $operator): int { return 0; } public function send(SmsOutgoing $message): SmsSendResult { $this->calls++; if ($this->onSend !== null) { ($this->onSend)($this->calls); } return new SmsSendResult('MSG-CANCEL-1', $message->segments, 0, CarbonImmutable::now()); } }; } function cancelCampaign(int $tenantId, int $count): ClientSmsCampaign { return ClientSmsCampaign::create([ 'tenant_id' => $tenantId, 'title' => 'Рассылка', 'body' => 'Здравствуйте!', 'sender_name' => 'liderra.ru', 'source' => ClientSmsCampaign::SOURCE_BASE, 'status' => ClientSmsCampaign::STATUS_QUEUED, 'segments' => 1, 'planned_count' => $count, 'total_sms' => $count, 'price_rub_per_sms' => '8.50', 'estimated_cost_rub' => bcmul('8.50', (string) $count, 2), 'created_by' => null, ]); } function cancelContacts(int $tenantId, int $count): void { foreach (range(1, $count) as $i) { ClientSmsContact::create([ 'tenant_id' => $tenantId, 'phone' => '7999000010'.$i, 'name' => 'Контакт '.$i, 'operator' => 'МТС', // Этап 3: без известного региона номер не уходит вовсе (В-85). 'tz_offset_minutes' => 180, ]); } } /** * Снимок получателей + канал, которым пойдут сообщения — то, что в бою делает * контроллер при создании рассылки (строки листа 2.1–2.2). * * 🪤 Роутер ставится В КОНТЕЙНЕР: канал выбирается дважды — отборщиком при снимке * и читателем снимка при отправке. Подменить один отборщик теперь мало. */ function cancelSnapshot(ClientSmsCampaign $campaign, SmsProvider $provider): void { $router = new SmsRouter([$provider]); app()->instance(SmsRouter::class, $router); $recipients = app(ClientSmsAudienceBuilder::class)->build($campaign); $plan = (new ClientSmsRecipientSelector($router, new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer))) ->build($recipients, (int) $campaign->tenant_id); app(ClientSmsSnapshotWriter::class)->write((int) $campaign->tenant_id, (int) $campaign->id, $plan); } it('после остановки провайдер больше не вызывается ни разу', function () { config(['services.sms.sandbox' => true]); cancelContacts($this->tenant->id, 5); $campaign = cancelCampaign($this->tenant->id, 5); // Клиент нажимает «Остановить» ровно в тот момент, когда ушло второе сообщение. $spy = cancelSpyProvider(function (int $n) use ($campaign) { if ($n === 2) { $campaign->newQuery()->whereKey($campaign->id)->update(['cancel_requested_at' => now()]); } }); cancelSnapshot($campaign, $spy); (new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle( app(ClientSmsSnapshotReader::class), app(ClientSmsPricing::class), app(AdWalletService::class), ); expect($spy->calls)->toBe(2); }); it('итог остановленной рассылки честный и деньги сходятся', function () { config(['services.sms.sandbox' => false]); $wallet = app(AdWalletService::class); $wallet->topup($this->tenant->id, '1000.00', null, 'test'); cancelContacts($this->tenant->id, 5); $campaign = cancelCampaign($this->tenant->id, 5); $wallet->freeze($this->tenant->id, 'sms', 'campaign', $campaign->id, '42.50'); $spy = cancelSpyProvider(function (int $n) use ($campaign) { if ($n === 2) { $campaign->newQuery()->whereKey($campaign->id)->update(['cancel_requested_at' => now()]); } }); cancelSnapshot($campaign, $spy); (new SendClientSmsCampaignJob($campaign->id, $this->tenant->id))->handle( app(ClientSmsSnapshotReader::class), app(ClientSmsPricing::class), $wallet, ); $campaign->refresh(); expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED) ->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_CLIENT) ->and($campaign->sent_count)->toBe(2) ->and((string) $campaign->actual_cost_rub)->toBe('17.00'); // 2 × 8.50, не 42.50 // Номера, до которых не дошли, в журнале отсутствуют (В-27). expect(ClientSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(2); $after = AdWallet::where('tenant_id', $this->tenant->id)->first(); expect((string) $after->balance_rub)->toBe('983.00') // 1000 − 17 ->and((string) $after->frozen_rub)->toBe('0.00'); // заморозка снята полностью }); it('остановить можно в очереди, в отправке и в ожидании окна', function () { foreach ([ ClientSmsCampaign::STATUS_QUEUED, ClientSmsCampaign::STATUS_SENDING, ClientSmsCampaign::STATUS_WAITING_WINDOW, ] as $status) { $campaign = cancelCampaign($this->tenant->id, 1); $campaign->update(['status' => $status]); $this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertOk(); expect($campaign->fresh()->cancel_requested_at)->not->toBeNull(); } }); it('законченную рассылку остановить нельзя', function () { $campaign = cancelCampaign($this->tenant->id, 1); $campaign->update(['status' => ClientSmsCampaign::STATUS_DONE]); $this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertStatus(409); expect($campaign->fresh()->cancel_requested_at)->toBeNull(); }); it('чужую рассылку остановить нельзя', function () { $stranger = Tenant::factory()->create(); $campaign = cancelCampaign($stranger->id, 1); $this->postJson("/api/sms/campaigns/{$campaign->id}/cancel")->assertNotFound(); expect($campaign->fresh()->cancel_requested_at)->toBeNull(); });