Files
portal/app/tests/Feature/ClientTg/AutoSafeguardTest.php
T
Дмитрий 6f469fb13d feat(телеграм-реклама): деньги на общем балансе + чистка СМС-фантазий + медиа объявления
Денежная модель (куски 1-2): реклама в Телеграме (МТС Маркетолог, робот-в-браузере)
оплачивается с ОБЩЕГО баланса тенанта (как СМС), наценка 40%. Списание при запуске,
возврат при отказе модерации/сбое/отмене (сальдо-идемпотентность tg_ad_charge/tg_ad_refund).
Старый рекламный кошелёк-заморозка убран из потока кампаний (таблицы ad_wallets НЕ удалены).
Новый TelegramCampaignChargeService (зеркало SmsChargeService); F5 money-safety сохранён
(сбой с mts_campaign_id → needs_review без возврата). Уборщик добивает зависшие queued с возвратом.

Чистка СМС-наследия (аудит модуля построчно 5 проверяющими): модуль ресейлит ПОКАЗЫ рекламы,
а не рассылку сообщений — вырезаны выдуманные сущности, механически скопированные из СМС:
- «имя/бренд отправителя» + помесячная абонплата (в телеге МТС имени отправителя нет):
  ChargeTgNameFeeJob, TelegramSenderService, SenderController, модели Sender/Setting,
  фронт-панель TelegramSenderPanel, админ-карточка, updateTgSettings, тип tg_name_fee.
  Таблицы client_tg_senders/_settings дропнуты.
- мёртвые таблицы client_tg_messages (доставка по номеру) и client_tg_templates (шаблоны
  сообщений) — без модели/использования, дропнуты (миграция 000017, create-миграции удалены).
- client_tg_optouts переосмыслен как ручной список «не показывать этим номерам» (не «отписки»).
- лексика рассылки → рекламы: «Авторассылка»→«Авто-реклама», «кошелёк/заморожено»→«общий баланс».

Медиа объявления (реальный пробел показов): новый POST /api/telegram/campaigns/{id}/media
(картинка/видео к черновику, png/jpg/gif/mp4 ≤50 МБ, замена удаляет старый файл) + поле
загрузки в форме кабинета. Робот уже принимал media_path (RunTelegramCampaignJob → mediaFile
→ cabinet.js fillAdMedia) — не хватало только приёма файла от клиента.

Схема: db/CHANGELOG_schema.md v8.94 (CHECK += tg_ad_charge/tg_ad_refund) + v8.95 (дроп
СМС-таблиц, tg_name_fee убран). Тесты зелёные поштучно (квирк партиций — гонять по одному):
Schema/Models/AdminTg/CampaignCharge/CampaignApi/Audience/Media; фронт advertising-telegram-view
19 + admin-tg/auto-rule 24. pint/deptrac(0) чисто; larastan исключён (Pest-$this шум). Робот
bots/mts-telegram-ads НЕ трогали.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 06:26:07 +03:00

180 lines
8.8 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\ClientTg\RunTelegramCampaignJob;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\AutoRule;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramAutoAccumulator;
use App\Services\ClientTg\TelegramTariffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
/**
* Предохранители авто-режима Telegram-рекламы (план §Этап 5, задача 5.1).
*
* Авто-путь копит номера и при пороге ставит кампанию в очередь. В БОЮ перед
* постановкой должен пройти двойной гейт: (1) сумма за сегодня + клиентская смета
* пачки не превышает дневной лимит правила (по умолчанию 0 → авто выключено);
* (2) клиентская смета СПИСЫВАЕТСЯ с общего баланса тенанта (`tenants.balance_rub`,
* как за СМС) — не хватило → мягкий пропуск. Не прошёл — не ставим в очередь,
* кампания остаётся черновиком, пишем причину.
*
* Отдельного рекламного кошелька с заморозкой больше НЕТ: деньги идут с общего
* баланса через TelegramCampaignChargeService (проводка BalanceTransaction
* TYPE_TG_AD_CHARGE). В песочнице деньги не трогаются (как ручной 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);
}
}
/** Сколько сейчас на общем балансе тенанта. */
function tenantBalance(int $tenantId): string
{
return (string) Tenant::whereKey($tenantId)->value('balance_rub');
}
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(['balance_rub' => '0.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
// Клиентская смета пачки из 3 кандидатов = базовая смета × наценка (то, что списывается).
$this->cost = app(TelegramTariffService::class)->clientEstimateRub(3);
});
afterEach(function () {
Carbon::setTestNow();
});
it('в бою при нехватке денег на балансе не ставит в очередь и пишет причину', function () {
$this->tenant->update(['balance_rub' => '1.00']); // заведомо меньше клиентской сметы пачки
expect(bccomp('1.00', $this->cost, 2))->toBeLessThan(0); // подстраховка: денег правда мало
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); // остаётся черновиком
expect(tenantBalance($this->tenant->id))->toBe('1.00'); // баланс не тронут
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_id', $campaign->id)->count())->toBe(0); // списания не было
Log::shouldHaveReceived('info')->withArgs(fn ($msg) => $msg === 'client_tg.auto_skipped')->atLeast()->once();
});
it('в бою при исчерпанном дневном лимите не ставит в очередь (cost = клиентская смета)', function () {
$this->tenant->update(['balance_rub' => '10000.00']); // денег вдоволь
safeguardRule($this->tenant->id, dailyLimit: '0.01'); // лимит заведомо < клиентской сметы
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT);
expect(tenantBalance($this->tenant->id))->toBe('10000.00'); // упёрлись в лимит ДО списания
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_id', $campaign->id)->count())->toBe(0);
});
it('дневной лимит по умолчанию 0 → авто выключено даже при деньгах', function () {
$this->tenant->update(['balance_rub' => '10000.00']);
// 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();
expect(tenantBalance($this->tenant->id))->toBe('10000.00'); // ничего не списано
});
it('в бою при деньгах и лимите в норме — списывает смету с баланса, ставит в очередь и учитывает трату за день', function () {
$this->tenant->update(['balance_rub' => '10000.00']);
$rule = safeguardRule($this->tenant->id, dailyLimit: '1000.00');
feed($this->tenant->id, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($campaign->status)->toBe(Campaign::STATUS_QUEUED);
// Баланс упал ровно на клиентскую смету, проводка списания одна.
expect(tenantBalance($this->tenant->id))->toBe(bcsub('10000.00', $this->cost, 2));
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_id', $campaign->id)->count())->toBe(1);
$rule->refresh();
expect((string) $rule->spent_today_rub)->toBe($this->cost); // смета записана в трату за день
expect($rule->spent_date->toDateString())->toBe('2026-07-28');
});
it('счётчик за день сбрасывается на новую дату', function () {
$this->tenant->update(['balance_rub' => '10000.00']);
$rule = safeguardRule($this->tenant->id, dailyLimit: $this->cost);
// Вчерашняя трата «под завязку» — но сегодня лимит должен освободиться.
$rule->update(['spent_today_rub' => $this->cost, '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]);
// Баланс нулевой (из beforeEach), лимита нет — но песочница деньги не трогает.
safeguardRule($this->tenant->id, dailyLimit: '0.00');
feed($this->tenant->id, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
expect(tenantBalance($this->tenant->id))->toBe('0.00'); // деньги не тронуты
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
});