$reports */ function pollFakeChannel(array $reports): SmsProvider { return new class($reports) implements SmsDeliveryReporter, SmsProvider { /** @param array $reports */ public function __construct(private array $reports) {} public function key(): string { return 'mts'; } public function servesOperators(): array { return ['mts']; } public function priceKopecks(string $operator): int { return 300; } public function send(SmsOutgoing $message): SmsSendResult { throw new RuntimeException('в этом тесте не отправляем'); } public function deliveryBatchSize(): int { return 1000; } public function fetchDelivery(array $providerMessageIds): array { return array_values(array_filter( $this->reports, fn (SmsDeliveryReport $r) => in_array($r->providerMessageId, $providerMessageIds, true), )); } }; } /** * Канал-соглядатай: ничего не рассказывает, но запоминает, О ЧЁМ его спросили. * Нужен там, где проверяется НЕзаданный вопрос («не спрашивает про старое»). */ // Тип возврата НЕ сужен до SmsProvider намеренно: тест читает список заданных // вопросов `$asked`, а интерфейс его не обещает — под интерфейсным типом анализатор // свойства не видит и краснеет, хотя у класса оно есть. function pollSpyChannel() { return new class implements SmsDeliveryReporter, SmsProvider { /** @var array */ public array $asked = []; public function key(): string { return 'mts'; } public function servesOperators(): array { return ['mts']; } public function priceKopecks(string $operator): int { return 300; } public function send(SmsOutgoing $message): SmsSendResult { throw new RuntimeException('в этом тесте не отправляем'); } public function deliveryBatchSize(): int { return 1000; } public function fetchDelivery(array $providerMessageIds): array { foreach ($providerMessageIds as $id) { $this->asked[] = (string) $id; } return []; } }; } function pollSmsCampaign(int $tenantId): ClientSmsCampaign { return ClientSmsCampaign::create([ 'tenant_id' => $tenantId, 'title' => 'Проверка судьбы', 'body' => 'Текст', 'sender_name' => 'LIDERRA', 'source' => ClientSmsCampaign::SOURCE_MANUAL, 'status' => ClientSmsCampaign::STATUS_DONE, 'idempotency_key' => 'poll-'.uniqid(), 'segments' => 1, 'planned_count' => 1, 'sent_count' => 1, 'total_sms' => 1, 'price_rub_per_sms' => '8.50', 'estimated_cost_rub' => '8.50', ]); } function pollMessage(int $tenantId, int $campaignId, string $phone, string $messageId, ?string $createdAt = null): ClientSmsMessage { $message = ClientSmsMessage::create([ 'tenant_id' => $tenantId, 'campaign_id' => $campaignId, 'phone' => $phone, 'operator' => 'mts', 'provider_key' => 'mts', 'status' => ClientSmsMessage::STATUS_SENT, 'provider_message_id' => $messageId, 'cost_rub' => '8.50', 'segments' => 1, ]); if ($createdAt !== null) { // Возраст правим ЗАПРОСОМ: через модель `created_at` перезапишется текущим. ClientSmsMessage::whereKey($message->id)->update(['created_at' => $createdAt]); } return $message->refresh(); } it('проставляет доставленному сообщению судьбу и время', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); $message = pollMessage((int) $tenant->id, (int) $campaign->id, '79990000001', '44747238'); app()->instance(SmsRouter::class, new SmsRouter([pollFakeChannel([ new SmsDeliveryReport( providerMessageId: '44747238', phone: '79990000001', state: SmsDeliveryState::DELIVERED, happenedAt: null, deliveredAt: Carbon::parse('2026-08-15 11:59:00')->toImmutable(), partCount: 1, providerCost: '3.4000', ), ])])); Artisan::call('client-sms:poll-delivery'); $message->refresh(); expect($message->delivery_status)->toBe(SmsDeliveryState::DELIVERED); expect($message->delivered_at?->toDateTimeString())->toBe('2026-08-15 11:59:00'); expect((string) $message->provider_cost)->toBe('3.4000'); expect($message->delivery_checked_at)->not->toBeNull(); // 🔴 Статус отправки НЕ трогаем: по нему считаются деньги и месячный объём (В-202). expect($message->status)->toBe(ClientSmsMessage::STATUS_SENT); }); it('не спрашивает про сообщения старше трёх суток — у оператора их уже нет', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); pollMessage((int) $tenant->id, (int) $campaign->id, '79990000002', '111', '2026-08-10 12:00:00'); $channel = pollSpyChannel(); app()->instance(SmsRouter::class, new SmsRouter([$channel])); Artisan::call('client-sms:poll-delivery'); expect($channel->asked)->toBe([]); }); it('спрашивает про свежее сообщение — иначе прошлая проверка ничего не значит', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); pollMessage((int) $tenant->id, (int) $campaign->id, '79990000009', '999'); $channel = pollSpyChannel(); app()->instance(SmsRouter::class, new SmsRouter([$channel])); Artisan::call('client-sms:poll-delivery'); expect($channel->asked)->toBe(['999']); }); /** * 🔴 Прежняя версия этого теста НЕ БЫЛА ПРИБОРОМ: сообщение заводилось с пустой * судьбой, и «графу не тронули» было не отличить от «записали пусто» — вырез, * ставящий null поверх, тест не красил. Теперь у сообщения ЕСТЬ судьба «везу», * и проверяется, что незнакомое слово её не стёрло (урок В-164: тест, зеленеющий * сразу, ещё не прибор — проверять вырезом). */ it('незнакомое слово оператора не меняет судьбу, но сохраняется для разбора', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); $message = pollMessage((int) $tenant->id, (int) $campaign->id, '79990000003', '222'); ClientSmsMessage::whereKey($message->id)->update([ 'delivery_status' => SmsDeliveryState::SENDING, ]); app()->instance(SmsRouter::class, new SmsRouter([pollFakeChannel([ new SmsDeliveryReport( providerMessageId: '222', phone: '79990000003', state: null, happenedAt: null, deliveredAt: null, partCount: null, providerCost: null, rawState: 'НовоеСлово', ), ])])); Artisan::call('client-sms:poll-delivery'); $message->refresh(); // Судьба ОСТАЛАСЬ прежней, а не обнулилась и не была угадана. expect($message->delivery_status)->toBe(SmsDeliveryState::SENDING); expect($message->delivery_raw)->toBe('НовоеСлово'); }); it('окончательную судьбу больше не переспрашивает', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); $message = pollMessage((int) $tenant->id, (int) $campaign->id, '79990000004', '333'); ClientSmsMessage::whereKey($message->id)->update([ 'delivery_status' => SmsDeliveryState::DELIVERED, ]); $channel = pollSpyChannel(); app()->instance(SmsRouter::class, new SmsRouter([$channel])); Artisan::call('client-sms:poll-delivery'); expect($channel->asked)->toBe([]); }); it('«везу» переспрашивает — это судьба не окончательная', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); $message = pollMessage((int) $tenant->id, (int) $campaign->id, '79990000010', '1010'); ClientSmsMessage::whereKey($message->id)->update([ 'delivery_status' => SmsDeliveryState::SENDING, ]); $channel = pollSpyChannel(); app()->instance(SmsRouter::class, new SmsRouter([$channel])); Artisan::call('client-sms:poll-delivery'); expect($channel->asked)->toBe(['1010']); }); /** * 🔴 Прежде этот тест назывался «пробное сообщение не спрашивает вовсе» и НЕ БЫЛ * ПРИБОРОМ для того, что охранял по названию: пробное отсекается фильтром по * КАНАЛУ (`provider_key = 'fake'`), а не по статусу — снятие фильтра статуса его * не красило. Название приведено к тому, что тест проверяет на самом деле, а под * оставшиеся две защиты заведены свои приборы ниже. */ it('чужой канал не спрашиваем: у каждого канала своя книга номеров сообщений', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); ClientSmsMessage::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000011', 'provider_key' => 'fake', 'status' => ClientSmsMessage::STATUS_FAKE_SENT, 'provider_message_id' => '1111', 'cost_rub' => '0.00', 'segments' => 1, ]); $channel = pollSpyChannel(); app()->instance(SmsRouter::class, new SmsRouter([$channel])); Artisan::call('client-sms:poll-delivery'); expect($channel->asked)->toBe([]); }); it('не ушедшее сообщение не спрашиваем, даже если номер у него откуда-то есть', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); ClientSmsMessage::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000012', 'provider_key' => 'mts', 'status' => ClientSmsMessage::STATUS_FAILED, 'provider_message_id' => '4242', 'cost_rub' => '0.00', 'segments' => 1, ]); $channel = pollSpyChannel(); app()->instance(SmsRouter::class, new SmsRouter([$channel])); Artisan::call('client-sms:poll-delivery'); expect($channel->asked)->toBe([]); }); it('строку-пропуск без номера сообщения не спрашиваем — спрашивать не о чем', function () { $tenant = Tenant::factory()->create(); $campaign = pollSmsCampaign((int) $tenant->id); ClientSmsMessage::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000013', 'provider_key' => 'mts', 'status' => ClientSmsMessage::STATUS_SENT, 'provider_message_id' => null, 'cost_rub' => '8.50', 'segments' => 1, ]); $channel = pollSpyChannel(); app()->instance(SmsRouter::class, new SmsRouter([$channel])); Artisan::call('client-sms:poll-delivery'); expect($channel->asked)->toBe([]); }); // ─── ДЕНЬГИ: трое суток и возврат за недоставленное (строки листа 5.2, 5.6) ─── // // 🔴 Денежные тесты обязаны двигать рубли: песочница гасит денежный шаг целиком, // и прогон «зеленел» бы, ничего не доказав (урок В-191). Поэтому в каждом — // config(['services.sms.sandbox' => false]). /** Клиент с деньгами на кошельке и одно отправленное сообщение за 8.50 ₽. */ function pollPaidMessage(string $phone, string $messageId, ?string $createdAt = null): array { $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup((int) $tenant->id, '100.00', null, 'прогон'); $campaign = pollSmsCampaign((int) $tenant->id); $message = pollMessage((int) $tenant->id, (int) $campaign->id, $phone, $messageId, $createdAt); return [$tenant, $campaign, $message]; } function pollBalance(int $tenantId): string { return (string) AdWallet::where('tenant_id', $tenantId)->value('balance_rub'); } it('через трое суток «ещё в пути» становится «не доставлено»', function () { config(['services.sms.sandbox' => false]); [$tenant, , $message] = pollPaidMessage('79990000005', '444', '2026-08-11 11:00:00'); ClientSmsMessage::whereKey($message->id)->update(['delivery_status' => SmsDeliveryState::SENDING]); app()->instance(SmsRouter::class, new SmsRouter([pollSpyChannel()])); Artisan::call('client-sms:poll-delivery'); expect(ClientSmsMessage::whereKey($message->id)->value('delivery_status')) ->toBe(SmsDeliveryState::NOT_DELIVERED); }); it('за просроченное деньги возвращаются клиенту на кошелёк', function () { config(['services.sms.sandbox' => false]); [$tenant, , $message] = pollPaidMessage('79990000006', '555', '2026-08-11 11:00:00'); ClientSmsMessage::whereKey($message->id)->update(['delivery_status' => SmsDeliveryState::SENDING]); app()->instance(SmsRouter::class, new SmsRouter([pollSpyChannel()])); Artisan::call('client-sms:poll-delivery'); expect(pollBalance((int) $tenant->id))->toBe('108.50'); expect(ClientSmsMessage::whereKey($message->id)->value('refunded_at'))->not->toBeNull(); }); it('второй заход НЕ возвращает деньги ещё раз', function () { config(['services.sms.sandbox' => false]); [$tenant, , $message] = pollPaidMessage('79990000007', '666', '2026-08-11 11:00:00'); ClientSmsMessage::whereKey($message->id)->update(['delivery_status' => SmsDeliveryState::SENDING]); app()->instance(SmsRouter::class, new SmsRouter([pollSpyChannel()])); Artisan::call('client-sms:poll-delivery'); Artisan::call('client-sms:poll-delivery'); expect(pollBalance((int) $tenant->id))->toBe('108.50'); expect(AdWalletTransaction::where('tenant_id', $tenant->id) ->where('type', AdWalletTransaction::TYPE_REFUND)->count())->toBe(1); }); it('за «принял, но не отправил» деньги тоже возвращаются — клиент за это платить не должен', function () { config(['services.sms.sandbox' => false]); [$tenant, , $message] = pollPaidMessage('79990000008', '777'); app()->instance(SmsRouter::class, new SmsRouter([pollFakeChannel([ new SmsDeliveryReport( providerMessageId: '777', phone: '79990000008', state: SmsDeliveryState::NOT_SENT, happenedAt: null, deliveredAt: null, partCount: 1, providerCost: '0', ), ])])); Artisan::call('client-sms:poll-delivery'); expect(pollBalance((int) $tenant->id))->toBe('108.50'); }); it('за доставленное деньги НЕ возвращаются', function () { config(['services.sms.sandbox' => false]); [$tenant, , $message] = pollPaidMessage('79990000014', '888'); app()->instance(SmsRouter::class, new SmsRouter([pollFakeChannel([ new SmsDeliveryReport( providerMessageId: '888', phone: '79990000014', state: SmsDeliveryState::DELIVERED, happenedAt: null, deliveredAt: null, partCount: 1, providerCost: '3.4', ), ])])); Artisan::call('client-sms:poll-delivery'); expect(pollBalance((int) $tenant->id))->toBe('100.00'); }); it('за «ещё в пути» деньги не возвращаются — судьба ещё не окончательная', function () { config(['services.sms.sandbox' => false]); [$tenant, , $message] = pollPaidMessage('79990000015', '999999'); app()->instance(SmsRouter::class, new SmsRouter([pollFakeChannel([ new SmsDeliveryReport( providerMessageId: '999999', phone: '79990000015', state: SmsDeliveryState::SENDING, happenedAt: null, deliveredAt: null, partCount: 1, providerCost: null, ), ])])); Artisan::call('client-sms:poll-delivery'); expect(pollBalance((int) $tenant->id))->toBe('100.00'); expect(ClientSmsMessage::whereKey($message->id)->value('refunded_at'))->toBeNull(); }); it('за пробное сообщение возвращать нечего — денег с клиента и не брали', function () { config(['services.sms.sandbox' => false]); $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup((int) $tenant->id, '100.00', null, 'прогон'); $campaign = pollSmsCampaign((int) $tenant->id); $message = ClientSmsMessage::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000016', 'provider_key' => 'fake', 'status' => ClientSmsMessage::STATUS_FAKE_SENT, 'provider_message_id' => '1212', 'cost_rub' => '0.00', 'segments' => 1, ]); ClientSmsMessage::whereKey($message->id)->update([ 'delivery_status' => SmsDeliveryState::NOT_DELIVERED, ]); app()->instance(SmsRouter::class, new SmsRouter([pollSpyChannel()])); Artisan::call('client-sms:poll-delivery'); expect(pollBalance((int) $tenant->id))->toBe('100.00'); });