Files
portal/app/tests/Feature/ClientSms/PollDeliveryTest.php
T
Дмитрий b6a15c5bbf merge: подтянул общую ветку в клиентские СМС — 227 записей отставания закрыты
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.

Девять столкновений, каждое разобрано по существу.

Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.

Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.

Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.

Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.

Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.

СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.

Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
  - 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
    перенесены из рабочей ветки, где владелец их уже принял;
  - остальные 42 - свои, в новом коде ветки, и починены по существу:
    задвоенный ключ массива в трёх тестах (след копирования - комментарий
    оторвался от своей строки), врущие описания двух помощников (PHP сам
    делает из ключа-номера число), сужение типа возврата, прятавшее от
    анализатора свойства подставного отправителя, лишний знак вопроса и
    четыре бесполезных перенумерования списка.
  - Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
    поимённо и покраснел; убрал - ноль.

Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.

Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.

Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.

Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.

NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:50:41 +03:00

542 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),
));
}
};
}
/**
* Канал-соглядатай: ничего не рассказывает, но запоминает, О ЧЁМ его спросили.
* Нужен там, где проверяется НЕзаданный вопрос («не спрашивает про старое»).
*/
// Тип возврата НЕ сужен до SmsProvider намеренно: тест читает список заданных
// вопросов `$asked`, а интерфейс его не обещает — под интерфейсным типом анализатор
// свойства не видит и краснеет, хотя у класса оно есть.
function pollSpyChannel()
{
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 pollSmsCampaign(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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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 = pollSmsCampaign((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');
});