Files
portal/app/tests/Feature/ClientTg/AutoSafeguardTest.php
T
Дмитрий aa4b482d45 feat(телеграм-реклама): Этап 5 — «Своё имя» + «Авторассылка» из кабинета + предохранители трат
Закрывает Этап 5 плана docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
Клиент управляет именем отправителя и авторассылкой сам из кабинета; авто не тратит
без денег и сверх дневного лимита. TDD, весь бэкенд+фронт зелёный.

## 5.1 — предохранители авторассылки
Накопитель в БОЮ перед постановкой пачки проверяет: смета умещается И в свободный
остаток кошелька (balance−frozen), И в дневной лимит правила за вычетом трат за
сегодня. Не прошла — держим черновиком + Log::info('client_tg.auto_skipped', reason).
Дефолт daily_limit_rub=0 → авто выключено (само денег не потратит). В песочнице гейта
нет (деньги не трогаются, как ручной launch). Новые колонки client_tg_auto_rule:
daily_limit_rub, spent_today_rub, spent_date (счётчик за день, сброс при смене даты).

## 5.2 — клиентское API имени (SenderController)
GET /sender (статус + остаток грейса), POST /sender (завести), /sender/disable,
/sender/enable (suspended→active, идемпотентно по периоду — без двойной оплаты в месяц;
проверка средств ДО списания). Логика в TelegramSenderService (enableSender+snapshot),
контроллер тонкий. Всё скоуп тенантом.

## 5.3 — экран имени (TelegramSenderPanel.vue)
Самодостаточная панель: статус имени человеческими словами, дата оплаты, остаток грейса
при долге; suspended → «Отключено за долг» + «Включить»; нет имени → форма «Завести имя».
telegram.ts: fetchSender/createSender/disableSender/enableSender.

## 5.4 — экран авторассылки + API (AutoRuleController)
GET/PUT /api/telegram/auto-rule (вкл/выкл, объявление, порог, бюджет, дневной лимит).
Порог клиентский — новая колонка client_tg_auto_rule.batch_threshold (NULL → дефолт
конфига 367; ниже 367 API не даёт — минимум МТС). TelegramAutoRulePanel.vue: тумблер +
поля порога/бюджета/лимита. В списке кампаний авто-кампании (created_by=null) помечены
чипом «авто».

Обе панели встроены в AdvertisingTelegramView.

## Схема
Две аддитивные миграции на существующую таблицу client_tg_auto_rule (000014 daily_limit
+ счётчик за день; 000015 batch_threshold). RLS/GRANT не тронуты (табличный GRANT
покрывает новые колонки). Записи db/CHANGELOG_schema.md v8.91/v8.92; обе прогнаны через
rls-reviewer — CLEAN.

## Приёмка
TDD. Бэкенд ClientTg 177/177; phpstan/pint/deptrac чисто. Фронт полный набор
212 файлов/1538 тестов зелёные; vue-tsc+ESLint по нашим файлам чисто. Проверено вживую
в браузере (клиент demo, песочница): обе панели читают и пишут — правило сохраняется
точь-в-точь (порог/бюджет/лимит), имя заводится (pending, деньги не тронуты).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 14:02:56 +03:00

150 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\AutoRule;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\TelegramAutoAccumulator;
use App\Services\ClientTg\TelegramTariffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
/**
* Предохранители авто-режима Telegram-рекламы (план §Этап 5, задача 5.1).
*
* Авто-путь копит номера и при пороге ставит кампанию в очередь. В БОЮ перед
* постановкой должен пройти двойной гейт: (1) на кошельке хватает свободных денег
* под смету пачки; (2) сумма за сегодня + смета не превышает дневной лимит правила
* (по умолчанию 0 → авто выключено). Не прошёл — не ставим в очередь, пишем причину.
*
* В песочнице деньги не трогаются (как ручной launch) — гейт не вмешивается.
* Синтетические номера 7999… — реальных ПДн не касаемся.
*/
uses(RefreshDatabase::class);
function safeguardRule(int $tenantId, string $dailyLimit = '1000.00'): AutoRule
{
return AutoRule::create([
'tenant_id' => $tenantId,
'enabled' => true,
'ad_text' => 'Заходите в наш канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00',
'daily_limit_rub' => $dailyLimit,
]);
}
/** @return list<string> */
function safeguardPhones(int $n): array
{
$out = [];
for ($i = 1; $i <= $n; $i++) {
$out[] = '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
}
return $out;
}
function feed(int $tenantId, int $n): void
{
foreach (safeguardPhones($n) as $phone) {
app(TelegramAutoAccumulator::class)->accumulate($tenantId, $phone);
}
}
beforeEach(function () {
Carbon::setTestNow('2026-07-28 10:00:00');
Queue::fake();
config(['client_tg.auto_batch_threshold' => 3]); // порог занижен для теста
config(['client_tg.sandbox' => false]); // деньги включены — проверяем боевой гейт
$this->tenant = Tenant::factory()->create();
// Смета пачки из 3 кандидатов = 3 × 0.45 (ступень min_qty=1) = 1.35 ₽.
$this->cost = app(TelegramTariffService::class)->estimateRub(3);
});
afterEach(function () {
Carbon::setTestNow();
});
it('в бою при нехватке денег не ставит в очередь и пишет причину', function () {
app(AdWalletService::class)->topup($this->tenant->id, '1.00', 'telegram', 'test'); // < 1.35
safeguardRule($this->tenant->id, dailyLimit: '1000.00');
Log::spy();
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT); // остаётся черновиком
Log::shouldHaveReceived('info')->withArgs(fn ($msg) => $msg === 'client_tg.auto_skipped')->atLeast()->once();
});
it('в бою при исчерпанном дневном лимите не ставит в очередь', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test'); // денег вдоволь
safeguardRule($this->tenant->id, dailyLimit: '1.00'); // лимит < сметы 1.35
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_DRAFT);
});
it('дневной лимит по умолчанию 0 → авто выключено даже при деньгах', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
// daily_limit_rub не задан явно → дефолт 0.00
AutoRule::create([
'tenant_id' => $this->tenant->id,
'enabled' => true,
'ad_text' => 'Текст',
'ad_link' => 'https://t.me/x',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00',
]);
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
});
it('в бою при деньгах и лимите в норме — ставит в очередь и учитывает трату за день', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
$rule = safeguardRule($this->tenant->id, dailyLimit: '1000.00');
feed($this->tenant->id, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_QUEUED);
$rule->refresh();
expect((string) $rule->spent_today_rub)->toBe($this->cost); // 1.35 записано
expect($rule->spent_date->toDateString())->toBe('2026-07-28');
});
it('счётчик за день сбрасывается на новую дату', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
$rule = safeguardRule($this->tenant->id, dailyLimit: '1.35');
// Вчерашняя трата «под завязку» — но сегодня лимит должен освободиться.
$rule->update(['spent_today_rub' => '1.35', 'spent_date' => '2026-07-27']);
feed($this->tenant->id, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
$rule->refresh();
expect((string) $rule->spent_today_rub)->toBe($this->cost); // счётчик пересчитан с нуля за сегодня
});
it('в песочнице предохранитель не вмешивается — ставит в очередь без денег', function () {
config(['client_tg.sandbox' => true]);
safeguardRule($this->tenant->id, dailyLimit: '0.00'); // ни денег, ни лимита
feed($this->tenant->id, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
});