Files
portal/app/tests/Feature/ClientSms/NameFeeJobTest.php
T
Дмитрий c47c3fb4d5 fix(смс-клиент): недосписание при повторе, срок для рассылки по сделкам, заслонка баланса + понятный интерфейс
Корректность/деньги:
- джоб рассылки: списание ПОШТУЧНО и атомарно с записью в журнал, ключ идемпотентности на номер, факт из БД — нет недосписания при падении посреди рассылки и повторе
- валидация: срок обязателен при source=deals, иначе выборка по всей истории сделок
- «Отправить» гаснет при нехватке свободного баланса; index отдаёт баланс/заморозку
- помесячный джоб имени: устойчив к пустым настройкам, сигнал в лог при автоотключении за долг
- цена одного авто-СМС + подтверждение при включении авто-рассылки
- загрузка контактов сообщает число нераспознанных номеров

Понятность для клиента:
- «СМС» вместо «сегментов», «пробный режим» вместо «песочницы», человеческие причины пропуска, шаги 1-2-3, выгода своего имени, факт вместо оценки в таблице, имя и текст в подтверждении

Тесты: бэкенд ClientSms 83/83, фронт СМС 50/50, приём лидов 10/10; pint/stan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 10:27:14 +03:00

178 lines
7.6 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\Jobs\ChargeSmsNameFeeJob;
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\ClientSmsSender;
use App\Models\ClientSmsSettings;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\Concerns\SharesSupplierPdo;
/**
* Task 14: ежемесячная плата за именного отправителя (client_sms_senders).
*
* Джоб перечисляет активных отправителей КРОСС-ТЕНАНТНО через pgsql_supplier
* (BYPASSRLS) — без share PDO вставленные в тест-транзакции отправители
* (default pgsql-соединение) не видны второму соединению до commit'а. Поэтому
* подключаем RefreshDatabase + SharesSupplierPdo вместе (зеркалит
* tests/Feature/Advertising/ChargeCampaignSpendJobTest).
*
* Настройки (fee 2500.00, grace 29) сидированы миграцией client_sms_settings.
*/
uses(RefreshDatabase::class);
uses(SharesSupplierPdo::class);
function makeActiveSender(int $tenantId, ?string $paidUntil, string $fee = '100.00', ?string $debtSince = null): ClientSmsSender
{
return ClientSmsSender::create([
'tenant_id' => $tenantId,
'name' => 'MyBrand',
'name_type' => ClientSmsSender::TYPE_COMPANY,
'status' => ClientSmsSender::STATUS_ACTIVE,
'operators' => ['mts'],
'monthly_fee_rub' => $fee,
'paid_until' => $paidUntil,
'debt_since' => $debtSince,
]);
}
beforeEach(function () {
Carbon::setTestNow('2026-07-25 10:00:00');
config(['services.sms.sandbox' => false]);
});
afterEach(function () {
Carbon::setTestNow();
});
it('charges the monthly fee, advances paid_until ~1 month, clears debt when funded', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-24'); // вчера → к оплате
app(ChargeSmsNameFeeJob::class)->handle();
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('4900.00'); // 5000 100
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->paid_until->toDateString())->toBe('2026-08-24')
->and($sender->debt_since)->toBeNull();
$tx = AdWalletTransaction::where('tenant_id', $tenant->id)
->where('external_key', "sms:sender:{$sender->id}:2026-07")
->first();
expect($tx)->not->toBeNull()
->and($tx->type)->toBe(AdWalletTransaction::TYPE_CHARGE)
->and($tx->amount_rub)->toBe('-100.00');
});
it('не падает целиком при пустой таблице настроек — берёт grace по умолчанию', function () {
ClientSmsSettings::query()->delete(); // настроек нет — джоб не должен упасть до обработки
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-24'); // к оплате
app(ChargeSmsNameFeeJob::class)->handle(); // не должно бросить
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->paid_until->toDateString())->toBe('2026-08-24')
->and((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('4900.00');
});
it('sets debt_since to today and keeps the sender active when funds are short', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test'); // < fee 100
$sender = makeActiveSender($tenant->id, '2026-07-24');
app(ChargeSmsNameFeeJob::class)->handle();
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('50.00'); // не тронут
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->debt_since->toDateString())->toBe('2026-07-25')
->and($sender->paid_until->toDateString())->toBe('2026-07-24'); // НЕ продлён
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});
it('suspends the sender when the debt is older than the grace period', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test'); // всё ещё нет денег
// grace 29; долг с 2026-06-25 = 30 дней назад → 30 > 29 → отключение.
$sender = makeActiveSender($tenant->id, '2026-07-24', '100.00', '2026-06-25');
app(ChargeSmsNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and($sender->note)->toContain('Долг более 29 дней')
->and($sender->debt_since->toDateString())->toBe('2026-06-25')
->and($sender->paid_until->toDateString())->toBe('2026-07-24'); // НЕ продлён
});
it('leaves a sender untouched when paid_until is still in the future', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-26'); // завтра → не к оплате
app(ChargeSmsNameFeeJob::class)->handle();
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('5000.00');
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->paid_until->toDateString())->toBe('2026-07-26')
->and($sender->debt_since)->toBeNull();
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});
it('does not double-charge when the whole job runs twice on the same day', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-24');
app(ChargeSmsNameFeeJob::class)->handle();
$balanceAfterFirst = (string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
app(ChargeSmsNameFeeJob::class)->handle();
$balanceAfterSecond = (string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
expect($balanceAfterFirst)->toBe('4900.00')
->and($balanceAfterSecond)->toBe('4900.00'); // без повторного списания
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('is a no-op in sandbox mode (no money moves)', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-24');
app(ChargeSmsNameFeeJob::class)->handle();
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('5000.00');
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->paid_until->toDateString())->toBe('2026-07-24');
});