Files
portal/app/tests/Feature/ClientSms/NameFeeJobTest.php
T
Дмитрий 533b9b33f7 телеграм,смс: имя посредника и техтексты убраны с клиентских экранов — чистка на ПОКАЗЕ
Владелец увидел на боевой карточке «Причина: Вход в кабинет МТС потерян —
нужен вход руками (npm run login)». Перевод на человеческий существовал с
07.08, но работал ТОЛЬКО НА ЗАПИСИ: шесть боевых кампаний (5, 7, 10, 12, 13,
14) записаны раньше и продолжали ехать на экран сырыми, а 14-я — с внутренним
адресом кабинета marketolog.

Чистка перенесена на МОМЕНТ ПОКАЗА — PrichinaDlyaKlienta::dlyaEkrana плюс
CampaignController::dlyaKlienta: старые строки чинятся сами, базу трогать не
надо, технический текст в базе остаётся для админки. Слова МОДЕРАТОРА
при status=rejected проходят дословно — иначе клиент не поймёт, что править.

Закрыты ещё три дыры того же класса:
  — подметальщик брошенных прогонов писал «кабинет МТС» мимо перевода;
  — колокольчик и письмо клиенту брали сырой текст робота;
  — перевод не был идемпотентным: второй проход вырождал уже переведённую
    фразу в общую, то есть показывал клиенту текст мутнее записанного.

СМС — та же беда и вдобавок неправда: клиенту показывалось «имя отключено,
погасить у МТС», хотя денег МТС он не должен, долг перед рекламным кошельком
Лидерры. Тексты вынесены в ClientSmsSender::NOTE_DEBT_SUSPENDED /
NOTE_OWNER_SUSPENDED и переписаны по-человечески; служебный смысл остался в
suspended_reason и в записи журнала. Подсказка про регистрацию имени и
документ-согласие теперь говорят «оператор», а не «МТС».

Названия сетей (МТС/Билайн/МегаФон/Теле2) на экране СМС оставлены намеренно:
это сети получателей, которые клиент сам выбирает, а не наш посредник.

Сторожа: PrichinaNaEkraneTest — 12 проверок, боевые строки насквозь через API;
PometkaImeniBezPosrednikaTest — 2; плюс 4 в PrichinaDlyaKlientaTest.
ClientTg 412 зелёных, ClientSms 453, экраны 126, статанализ 0, стиль чисто.

Правлены два чужих сторожа, объяснение — прямо в файлах: RejectNotifyTest
подставлял слова модератора к статусу «идёт работа» (в жизни такой пары нет),
NameFeeJobTest сверял текст клиентской записки вместо признака suspended_reason.
2026-08-08 10:25:04 +03:00

315 lines
15 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,
]);
}
/**
* Отключённое имя. `$reason` — ПОЧЕМУ отключено: за долг (портал вправе вернуть
* его сам) или рукой владельца (портал возвращать не смеет, журнал В-140).
*/
function makeSuspendedSender(int $tenantId, string $reason, string $fee = '100.00'): ClientSmsSender
{
return ClientSmsSender::create([
'tenant_id' => $tenantId,
'name' => 'MyBrand',
'name_type' => ClientSmsSender::TYPE_COMPANY,
'status' => ClientSmsSender::STATUS_SUSPENDED,
'operators' => ['mts'],
'monthly_fee_rub' => $fee,
'paid_until' => '2026-04-25', // не платили с апреля — три месяца долга
'debt_since' => '2026-04-26',
'suspended_reason' => $reason,
'note' => ClientSmsSender::NOTE_DEBT_SUSPENDED,
]);
}
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();
// 🔴 08.08.2026: раньше здесь сверялся текст записки («Долг более 29 дней»), но
// записку ВИДИТ КЛИЕНТ, и служебные подробности из неё убраны. Признак отключения
// за долг живёт в `suspended_reason` — именно по нему код и решает, вправе ли портал
// вернуть имя сам (В-140). Сторожим признак, а не формулировку: охрана та же, а
// текст на экране клиента теперь можно менять, не ломая тест.
expect($sender->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and($sender->suspended_reason)->toBe(ClientSmsSender::SUSPENDED_BY_DEBT)
->and($sender->note)->toBe(ClientSmsSender::NOTE_DEBT_SUSPENDED)
->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('4.4 — отключённое за долг имя включается само, когда появились деньги, и берём за ОДИН месяц', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
app(ChargeSmsNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->debt_since)->toBeNull()
->and($sender->suspended_reason)->toBeNull()
->and($sender->note)->toBeNull()
// Оплачен месяц ВПЕРЁД от сегодняшнего дня, а не от протухшего paid_until.
->and($sender->paid_until->toDateString())->toBe('2026-08-25');
// Списан РОВНО один месяц, а не три: за месяцы, когда имя не работало,
// не берём (решение владельца В-133).
expect((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))
->toBe('4900.00');
$tx = AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->get();
expect($tx)->toHaveCount(1)
->and($tx->first()->external_key)->toBe("sms:sender:{$sender->id}:2026-07");
});
it('4.4 — денег не хватает: имя остаётся отключённым и НИЧЕГО не списывается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test'); // < плата 100
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
app(ChargeSmsNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and($sender->debt_since->toDateString())->toBe('2026-04-26')
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('50.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});
it('4.4 — замороженные под рассылку деньги свободными не считаются', function () {
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '120.00', null, 'test');
$wallet->freeze($tenant->id, 'sms', 'campaign', 777, '100.00'); // свободно 20 < плата 100
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
app(ChargeSmsNameFeeJob::class)->handle();
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('120.00');
});
it('4.4 — имя, отключённое РУКОЙ ВЛАДЕЛЬЦА, работник обратно НЕ включает (В-140)', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test'); // денег полно
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_OWNER);
app(ChargeSmsNameFeeJob::class)->handle();
// Владелец гасил имя у МТС — портал не вправе включить его обратно, тем более за деньги клиента.
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('5000.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});
it('4.4 — за текущий месяц дважды не спишем: клиент уже оплатил кнопкой (В-141)', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
// Так спишет кнопка «включить имя обратно» (Task 4) — тот же месяц, тот же ключ.
app(AdWalletService::class)->charge(
$tenant->id, 'sms', 'sender', $sender->id, '100.00', "sms:sender:{$sender->id}:2026-07",
);
app(ChargeSmsNameFeeJob::class)->handle();
expect((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('4900.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('4.4 — в песочнице отключённое за долг имя не воскресает и деньги не двигаются', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
app(ChargeSmsNameFeeJob::class)->handle();
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('5000.00');
});
it('4.4 — отключая за долг, работник записывает ПРИЧИНУ отключения', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test');
// grace 29; долг с 2026-06-25 = 30 дней назад → отключение.
$sender = makeActiveSender($tenant->id, '2026-07-24', '100.00', '2026-06-25');
app(ChargeSmsNameFeeJob::class)->handle();
expect($sender->fresh()->suspended_reason)->toBe(ClientSmsSender::SUSPENDED_BY_DEBT);
});
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');
});