Files
portal/app/tests/Feature/ClientSms/PollDeliveryTest.php
T
Дмитрий 5356bf3ed6 feat(смс-клиент): опрос судьбы отправленных сообщений у операторов
Этап 5, Task 3. Появилась команда client-sms:poll-delivery — каждые десять минут
спрашивает у канала, что стало с отправленными сообщениями, и проставляет судьбу
в журнал.

Устройство:
- умение рассказать судьбу — способность КАНАЛА: канал без разъёма просто
  пропускается. Когда у Т2/Мегафона/Билайна появятся кабинеты, команда не
  изменится ни строчкой;
- отчёт ПРАВИТ существующую строку, новых не пишет (В-204): сторож зависших
  меряет движение рассылки по последней записи журнала, и новые строки делали бы
  зависшую рассылку «живой» — деньги остались бы замороженными;
- спрашиваем только реально отправленные этим каналом, с номером сообщения,
  не старше трёх суток и с неокончательной судьбой. Оператор помнит судьбу трое
  суток — спрашивать про старое бессмысленно;
- незнакомое слово оператора судьбу НЕ меняет: пишем слово в журнал и оставляем
  графу как была. Угадать значило бы соврать про деньги.

ЖИВОЙ ПРОГОН ПОД БОЕВОЙ РОЛЬЮ, тройкой (политика srv_bypass воспроизведена тем же
текстом, что в db/03, — на стенде их нет ни одной):
  А) право UPDATE есть + политика есть -> «уточнено: 1», судьба delivered;
  Б) права UPDATE нет -> команда ПАДАЕТ «нет доступа к таблице» (видно);
  В) права есть, политики нет -> «успех», «уточнено: 0», судьба пустая (НЕ видно).
Подтверждает В-181: у двух опор разная цена отказа, и опаснее вторая. Стенд
вернулся в исходное, политик srv_bypass не осталось.

ПРО ПРИБОРЫ — два теста оказались НЕ приборами, починены, а не подогнаны:
- «незнакомое слово не меняет судьбу» не отличал «графу не тронули» от «записали
  пусто» — теперь у сообщения есть стартовая судьба, и проверяется, что она цела;
- «пробное не спрашиваем» краснел бы не от того: пробное отсекает фильтр по
  КАНАЛУ, а не по статусу. Переименован по тому, что реально охраняет, и под две
  оставшиеся защиты заведены свои тесты.
После починки каждый из четырёх вырезов добавляет ровно один красный тест.

Прогоны: модуль 336/336 (13 пачек, все с первой попытки), phpstan ровно 2 чужие
давние, pint чисто. Команда в расписании — проверено schedule:list.
2026-07-31 06:23:24 +03:00

375 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Services\Sms\SmsDeliveryReport;
use App\Services\Sms\SmsDeliveryReporter;
use App\Services\Sms\SmsDeliveryState;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsProvider;
use App\Services\Sms\SmsRouter;
use App\Services\Sms\SmsSendResult;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строки листа 5.1–5.2: судьба приходит от оператора, зависшие доспрашиваем мы.
*
* 🪤 SharesSupplierPdo обязателен: команда перечисляет и правит СЛУЖЕБНЫМ
* соединением (она кросс-клиентская и пометку клиента не ставит). Без общего PDO
* это соединение не видит незакоммиченных данных теста и находит НОЛЬ — прогон
* позеленел бы ВРУЩИ там, где мы ждём правки (урок В-95).
*
* Помощники с префиксом poll* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Телефоны только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
Carbon::setTestNow('2026-08-15 12:00:00');
});
afterEach(function () {
Carbon::setTestNow();
});
/**
* Канал, который умеет и отправлять, и рассказывать судьбу. Судьбы задаются заранее.
*
* @param array<int, SmsDeliveryReport> $reports
*/
function pollFakeChannel(array $reports): SmsProvider
{
return new class($reports) implements SmsDeliveryReporter, SmsProvider
{
/** @param array<int, SmsDeliveryReport> $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<int, string> */
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([]);
});