Files
portal/app/tests/Feature/ClientTg/SenderLifecycleTest.php
T
Дмитрий 5b04d751c0 feat(телеграм-модуль): авто-режим, своё имя + помесячная оплата, админ-тарифы
Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан.

4.1 Авто-режим (пачка):
- Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS).
- TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик
  (audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold,
  по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub
  и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе.
  Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе).

4.2 Защитный observer:
- DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob.
  🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный).
- Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на
  очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон
  в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob.

4.3 Своё имя/бренд + помесячная оплата:
- Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U).
- TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) /
  disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin.
- ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка
  free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended,
  кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются.
  Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование
  в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл).

4.4 Админ-тарифы/настройки:
- Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db.
- Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка
  ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён).

Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный
(+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений.
rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema
v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в
тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:41:33 +03:00

205 lines
8.9 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\Exceptions\Billing\InsufficientBalanceException;
use App\Jobs\ClientTg\ChargeTgNameFeeJob;
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\ClientTg\Sender;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\TelegramSenderService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\Concerns\SharesSupplierPdo;
/**
* Своё имя/бренд Telegram-рекламы + помесячная оплата (план §Сессия 4, задача 4.3).
* Зеркало client_sms_senders + ChargeSmsNameFeeJob, но проще (без документов/операторов
* — согласование в Телеграме уточняется живой разведкой §8; здесь — денежный цикл).
*
* Помесячный джоб перечисляет активных отправителей КРОСС-ТЕНАНТНО через pgsql_supplier
* (BYPASSRLS) — без share PDO вставленные в тест-транзакции строки не видны второму
* соединению до commit'а (зеркалит NameFeeJobTest/ChargeCampaignSpendJobTest).
*/
uses(RefreshDatabase::class);
uses(SharesSupplierPdo::class);
function tgSenderService(): TelegramSenderService
{
return app(TelegramSenderService::class);
}
function makeActiveTgSender(int $tenantId, ?string $paidUntil, string $fee = '2500.00', ?string $debtSince = null): Sender
{
return Sender::create([
'tenant_id' => $tenantId,
'name' => 'МойБренд',
'status' => Sender::STATUS_ACTIVE,
'monthly_fee_rub' => $fee,
'paid_until' => $paidUntil,
'debt_since' => $debtSince,
]);
}
beforeEach(function () {
Carbon::setTestNow('2026-07-27 10:00:00');
config(['client_tg.sandbox' => false]); // деньги включены — проверяем реальный цикл
$this->tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
});
afterEach(function () {
Carbon::setTestNow();
});
// --- Жизненный цикл заявки -------------------------------------------------
it('requestSender создаёт заявку pending и бронирует месячную плату', function () {
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд', requestedBy: 1);
expect($sender->status)->toBe(Sender::STATUS_PENDING);
expect((string) $sender->monthly_fee_rub)->toBe('2500.00'); // из client_tg_settings
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('2500.00'); // бронь под имя
});
it('requestSender при активном имени — отказ', function () {
makeActiveTgSender($this->tenant->id, '2026-08-27');
expect(fn () => tgSenderService()->requestSender($this->tenant->id, 'Другое'))
->toThrow(DomainException::class);
});
it('requestSender при нехватке денег — бросает ДО списания (заявки нет)', function () {
$poor = Tenant::factory()->create();
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test'); // кошелёк есть, но мало
expect(fn () => tgSenderService()->requestSender($poor->id, 'МойБренд'))
->toThrow(InsufficientBalanceException::class);
expect(Sender::where('tenant_id', $poor->id)->exists())->toBeFalse();
});
it('disableSender у pending снимает бронь и ставит cancelled', function () {
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
$out = tgSenderService()->disableSender($this->tenant->id);
expect($out->status)->toBe(Sender::STATUS_CANCELLED);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь снята
});
// --- Админ ----------------------------------------------------------------
it('approve списывает плату, снимает бронь, ставит active + paid_until +1мес', function () {
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
$out = tgSenderService()->approve($sender->id);
expect($out->status)->toBe(Sender::STATUS_ACTIVE);
expect($out->paid_until->toDateString())->toBe('2026-08-27');
expect($out->debt_since)->toBeNull();
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь снята
expect((string) $wallet->balance_rub)->toBe('7500.00'); // 10000 2500
});
it('approve не-pending — отказ', function () {
$sender = makeActiveTgSender($this->tenant->id, '2026-08-27');
expect(fn () => tgSenderService()->approve($sender->id))
->toThrow(DomainException::class);
});
it('reject снимает бронь и ставит rejected с заметкой', function () {
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
$out = tgSenderService()->reject($sender->id, 'Название занято');
expect($out->status)->toBe(Sender::STATUS_REJECTED);
expect($out->note)->toBe('Название занято');
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00');
});
it('disableByAdmin ставит suspended', function () {
$sender = makeActiveTgSender($this->tenant->id, '2026-08-27');
$out = tgSenderService()->disableByAdmin($sender->id);
expect($out->status)->toBe(Sender::STATUS_SUSPENDED);
});
// --- Помесячный джоб -------------------------------------------------------
it('джоб списывает плату, двигает paid_until на месяц, гасит долг когда денег хватает', function () {
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26'); // вчера → к оплате
app(ChargeTgNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->paid_until->toDateString())->toBe('2026-08-26');
expect($sender->debt_since)->toBeNull();
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('7500.00'); // 10000 2500
});
it('джоб при нехватке денег в грейсе — фиксирует долг, paid_until не двигает', function () {
$poor = Tenant::factory()->create();
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test'); // мало
$sender = makeActiveTgSender($poor->id, '2026-07-26');
app(ChargeTgNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->status)->toBe(Sender::STATUS_ACTIVE); // ещё в грейсе
expect($sender->paid_until->toDateString())->toBe('2026-07-26'); // не сдвинут
expect($sender->debt_since)->not->toBeNull();
});
it('джоб при долге дольше грейса — отключает имя (suspended)', function () {
$poor = Tenant::factory()->create();
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test');
// долг с 30 дней назад (>29 grace)
$sender = makeActiveTgSender($poor->id, '2026-06-26', debtSince: '2026-06-26');
app(ChargeTgNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->status)->toBe(Sender::STATUS_SUSPENDED);
});
it('джоб идемпотентен по периоду — второй прогон не списывает дважды', function () {
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26');
app(ChargeTgNameFeeJob::class)->handle();
$balanceAfterFirst = (string) AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub;
// Возвращаем paid_until назад — но external_key периода тот же → повторно не спишет.
$sender->update(['paid_until' => '2026-07-26']);
app(ChargeTgNameFeeJob::class)->handle();
$chargeCount = AdWalletTransaction::where('tenant_id', $this->tenant->id)
->where('channel', 'telegram')
->where('type', AdWalletTransaction::TYPE_CHARGE)
->count();
expect($chargeCount)->toBe(1); // одно списание за период
});
it('джоб в песочнице деньги не двигает', function () {
config(['client_tg.sandbox' => true]);
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26');
app(ChargeTgNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->paid_until->toDateString())->toBe('2026-07-26'); // не тронут
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('10000.00'); // не списано
});