Files
portal/app/tests/Feature/ClientSms/PollDeliveryTest.php
T

375 lines
14 KiB
PHP
Raw Normal View History

<?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([]);
});