Files
portal/app/tests/Feature/ClientTg/SenderApiTest.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

148 lines
6.5 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\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);
});