$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), )); } }; } /** * Канал-соглядатай: ничего не рассказывает, но запоминает, О ЧЁМ его спросили. * Нужен там, где проверяется НЕзаданный вопрос («не спрашивает про старое»). */ function pollSpyChannel(): SmsProvider { 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 pollCampaign(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 = pollCampaign((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 = pollCampaign((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 = pollCampaign((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 = pollCampaign((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 = pollCampaign((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 = pollCampaign((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 = pollCampaign((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 = pollCampaign((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 = pollCampaign((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([]); });