2026-07-31 06:23:24 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
2026-07-31 06:36:27 +03:00
|
|
|
|
use App\Models\AdWallet;
|
|
|
|
|
|
use App\Models\AdWalletTransaction;
|
2026-07-31 06:23:24 +03:00
|
|
|
|
use App\Models\ClientSmsCampaign;
|
|
|
|
|
|
use App\Models\ClientSmsMessage;
|
|
|
|
|
|
use App\Models\Tenant;
|
2026-07-31 06:36:27 +03:00
|
|
|
|
use App\Services\Advertising\AdWalletService;
|
2026-07-31 06:23:24 +03:00
|
|
|
|
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),
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Канал-соглядатай: ничего не рассказывает, но запоминает, О ЧЁМ его спросили.
|
|
|
|
|
|
* Нужен там, где проверяется НЕзаданный вопрос («не спрашивает про старое»).
|
|
|
|
|
|
*/
|
2026-08-01 19:50:41 +03:00
|
|
|
|
// Тип возврата НЕ сужен до SmsProvider намеренно: тест читает список заданных
|
|
|
|
|
|
// вопросов `$asked`, а интерфейс его не обещает — под интерфейсным типом анализатор
|
|
|
|
|
|
// свойства не видит и краснеет, хотя у класса оно есть.
|
|
|
|
|
|
function pollSpyChannel()
|
2026-07-31 06:23:24 +03:00
|
|
|
|
{
|
|
|
|
|
|
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 [];
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-01 19:50:41 +03:00
|
|
|
|
function pollSmsCampaign(int $tenantId): ClientSmsCampaign
|
2026-07-31 06:23:24 +03:00
|
|
|
|
{
|
|
|
|
|
|
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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
$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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
$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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
$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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
$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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:23:24 +03:00
|
|
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
|
});
|
2026-07-31 06:36:27 +03:00
|
|
|
|
|
|
|
|
|
|
// ─── ДЕНЬГИ: трое суток и возврат за недоставленное (строки листа 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, 'прогон');
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:36:27 +03:00
|
|
|
|
$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, 'прогон');
|
2026-08-01 19:50:41 +03:00
|
|
|
|
$campaign = pollSmsCampaign((int) $tenant->id);
|
2026-07-31 06:36:27 +03:00
|
|
|
|
|
|
|
|
|
|
$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');
|
|
|
|
|
|
});
|