aa4b482d45
Закрывает Этап 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>
148 lines
6.5 KiB
PHP
148 lines
6.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\AdWallet;
|
||
use App\Models\AdWalletTransaction;
|
||
use App\Models\ClientTg\Sender;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Carbon;
|
||
|
||
/**
|
||
* Клиентское HTTP-API «своего имени» Telegram-рекламы (план §Этап 5, задача 5.2).
|
||
* Тонкий слой над TelegramSenderService: клиент заводит/отключает/включает имя из
|
||
* кабинета. GET отдаёт статус + остаток грейса. Песочница денег не трогает.
|
||
*
|
||
* Auth/tenant как у CampaignApiTest: Tenant::factory + User(tenant_id) + actingAs;
|
||
* middleware ['auth:sanctum','tenant']. Scope тенантом из $request->user().
|
||
*/
|
||
uses(RefreshDatabase::class);
|
||
|
||
beforeEach(function () {
|
||
Carbon::setTestNow('2026-07-28 10:00:00');
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
$this->actingAs($this->user);
|
||
});
|
||
|
||
afterEach(function () {
|
||
Carbon::setTestNow();
|
||
});
|
||
|
||
it('GET /sender без имени → sender null, песочница, грейс null', function () {
|
||
$this->getJson('/api/telegram/sender')
|
||
->assertOk()
|
||
->assertJsonPath('sender', null)
|
||
->assertJsonPath('sandbox', true)
|
||
->assertJsonPath('grace_days_left', null);
|
||
});
|
||
|
||
it('POST /sender заводит имя (pending), в песочнице деньги не трогает', function () {
|
||
$this->postJson('/api/telegram/sender', ['name' => 'МойБренд'])
|
||
->assertCreated()
|
||
->assertJsonPath('status', Sender::STATUS_PENDING)
|
||
->assertJsonPath('name', 'МойБренд');
|
||
|
||
expect(Sender::where('tenant_id', $this->tenant->id)->value('status'))->toBe(Sender::STATUS_PENDING);
|
||
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse(); // денег не трогали
|
||
});
|
||
|
||
it('POST /sender без имени → 422', function () {
|
||
$this->postJson('/api/telegram/sender', [])
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['name']);
|
||
});
|
||
|
||
it('POST /sender при активном имени → 422', function () {
|
||
Sender::create([
|
||
'tenant_id' => $this->tenant->id, 'name' => 'Есть', 'status' => Sender::STATUS_ACTIVE,
|
||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
|
||
]);
|
||
|
||
$this->postJson('/api/telegram/sender', ['name' => 'Другое'])->assertStatus(422);
|
||
});
|
||
|
||
it('POST /sender/disable отключает имя (cancelled)', function () {
|
||
$this->postJson('/api/telegram/sender', ['name' => 'МойБренд'])->assertCreated();
|
||
|
||
$this->postJson('/api/telegram/sender/disable')
|
||
->assertOk()
|
||
->assertJsonPath('status', Sender::STATUS_CANCELLED);
|
||
});
|
||
|
||
it('GET /sender отдаёт остаток грейса для имени с долгом', function () {
|
||
Sender::create([
|
||
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_ACTIVE,
|
||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-07-26', 'debt_since' => '2026-07-23',
|
||
]);
|
||
|
||
// грейс 29 дней, долг с 23.07 → на 28.07 прошло 5 дней → осталось 24.
|
||
$this->getJson('/api/telegram/sender')
|
||
->assertOk()
|
||
->assertJsonPath('sender.status', Sender::STATUS_ACTIVE)
|
||
->assertJsonPath('grace_days_left', 24);
|
||
});
|
||
|
||
it('GET /sender не видит имя чужого тенанта', function () {
|
||
$other = Tenant::factory()->create();
|
||
Sender::create([
|
||
'tenant_id' => $other->id, 'name' => 'Чужой', 'status' => Sender::STATUS_ACTIVE,
|
||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
|
||
]);
|
||
|
||
$this->getJson('/api/telegram/sender')->assertOk()->assertJsonPath('sender', null);
|
||
});
|
||
|
||
// --- Живой режим (деньги включены) ----------------------------------------
|
||
|
||
it('POST /sender/enable включает suspended → active, списывает плату один раз в месяц', function () {
|
||
config(['client_tg.sandbox' => false]);
|
||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||
$sender = Sender::create([
|
||
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_SUSPENDED,
|
||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-06-26', 'debt_since' => '2026-06-26',
|
||
]);
|
||
|
||
$this->postJson('/api/telegram/sender/enable')
|
||
->assertOk()
|
||
->assertJsonPath('status', Sender::STATUS_ACTIVE);
|
||
|
||
$charges = AdWalletTransaction::where('tenant_id', $this->tenant->id)
|
||
->where('type', AdWalletTransaction::TYPE_CHARGE)->count();
|
||
expect($charges)->toBe(1);
|
||
|
||
// Повторное включение в тот же месяц не должно списать второй раз (идемпотентность периода).
|
||
// query-builder update (а не $sender->update): у стейл-модели статус уже 'suspended' в памяти → не dirty.
|
||
Sender::where('tenant_id', $this->tenant->id)->update(['status' => Sender::STATUS_SUSPENDED]);
|
||
$this->postJson('/api/telegram/sender/enable')->assertOk();
|
||
|
||
$charges = AdWalletTransaction::where('tenant_id', $this->tenant->id)
|
||
->where('type', AdWalletTransaction::TYPE_CHARGE)->count();
|
||
expect($charges)->toBe(1); // всё ещё одно списание
|
||
});
|
||
|
||
it('POST /sender/enable при нехватке денег → 409, имя остаётся suspended', function () {
|
||
config(['client_tg.sandbox' => false]);
|
||
app(AdWalletService::class)->topup($this->tenant->id, '100.00', 'telegram', 'test'); // мало
|
||
Sender::create([
|
||
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_SUSPENDED,
|
||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-06-26', 'debt_since' => '2026-06-26',
|
||
]);
|
||
|
||
$this->postJson('/api/telegram/sender/enable')->assertStatus(409);
|
||
|
||
expect(Sender::where('tenant_id', $this->tenant->id)->value('status'))->toBe(Sender::STATUS_SUSPENDED);
|
||
});
|
||
|
||
it('POST /sender/enable для не-suspended → 422', function () {
|
||
Sender::create([
|
||
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_ACTIVE,
|
||
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
|
||
]);
|
||
|
||
$this->postJson('/api/telegram/sender/enable')->assertStatus(422);
|
||
});
|