b6a15c5bbf
Ветка шла отдельно почти неделю и отставала на 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>
542 lines
20 KiB
PHP
542 lines
20 KiB
PHP
<?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');
|
||
});
|