fc235bb5a6
Этап 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 чисто.
539 lines
20 KiB
PHP
539 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),
|
||
));
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Канал-соглядатай: ничего не рассказывает, но запоминает, О ЧЁМ его спросили.
|
||
* Нужен там, где проверяется НЕзаданный вопрос («не спрашивает про старое»).
|
||
*/
|
||
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');
|
||
});
|