Files
portal/app/tests/Feature/ClientSms/PollDeliveryTest.php
T
Дмитрий fc235bb5a6 feat(смс-клиент): недоставленное закрывается через трое суток, деньги возвращаются клиенту
Этап 5, Task 4. ДЕНЕЖНАЯ задача — решения владельца В-198 и В-199.

Что появилось:
- через трое суток «ещё в пути» становится «не доставлено». Довод не только
  продуктовый: оператор помнит судьбу ровно трое суток, после этого ответа не
  будет вовсе — закрыть сообщение обязаны мы, иначе итог рассылки никогда не
  станет окончательным и досыл не будет знать, кого досылать;
- за «не доставлено» и за «оператор не отправил» деньги возвращаются клиенту на
  кошелёк. МТС за недоставленное с нас не берёт (В-7), значит эти рубли лежали
  у нас ни за что;
- у кошелька появился метод refund() — ОТДЕЛЬНО от пополнения, и это не
  украшение: у topup() нет ключа события, а команда по расписанию по своей
  природе повторяется. На topup() второй заход вернул бы деньги ДВАЖДЫ.

ИДЕМПОТЕНТНОСТЬ ДВОЙНАЯ, и проверено, что нужны обе опоры:
  - ключ события sms:refund:{рассылка}:{номер} + уникальный ключ в базе;
  - отметка refunded_at на сообщении, ставится в ТОЙ ЖЕ транзакции.
Вырез только ключа тест НЕ красит (спасает отметка). Вырез только отметки тоже.
Сняв ОБЕ, тест краснеет числом 117.00 вместо 108.50 — деньги вернулись дважды.
Так и должно быть: одна опора страхует другую.

Вырезы (все вернуты): убрать «оператор не отправил» из возвращаемых -> 1 красный;
убрать закрытие по сроку -> 3 красных; возвращать за ЛЮБУЮ судьбу -> 2 красных
(значит тесты «за доставленное не возвращаем» и «за в пути не возвращаем» —
настоящие приборы, а не зелень вхолостую).

ЖИВОЙ ДЕНЕЖНЫЙ ПРОГОН (sandbox выключен — в песочнице деньги не двигаются вовсе,
и прогон доказал бы НОЛЬ): баланс 100.00 -> 108.50 ₽, движение типа refund ровно
одно, отметка у недоставленного стоит, у доставленного нет. ВТОРОЙ заход той же
команды: баланс тот же, движений по-прежнему одно. Стенд возвращён в исходное.

Прогоны: модуль 343/343 (13 пачек, все с первой попытки), рекламный кошелёк 5/5
(я трогал общий AdWalletService), phpstan ровно 2 чужие давние, pint чисто.
2026-07-31 06:36:27 +03:00

539 lines
20 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\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
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([]);
});
// ─── ДЕНЬГИ: трое суток и возврат за недоставленное (строки листа 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 = pollCampaign((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 = pollCampaign((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');
});