5356bf3ed6
Этап 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.
375 lines
14 KiB
PHP
375 lines
14 KiB
PHP
<?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([]);
|
||
});
|