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

117 lines
5.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\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\Queue;
/**
* Ревью-фикс F3 (Деньги I2), денежная модель без кошелька: авто-путь ДОЛЖЕН списывать
* клиентскую смету (estimated_cost_rub, с наценкой) с ОБЩЕГО баланса тенанта
* (tenants.balance_rub) симметрично ручному launch — иначе кампания уходит в очередь
* без оплаты. Заморозки/кошелька (AdWallet/AdWalletService) больше нет. Не хватило
* баланса на смету → мягкий skip (кампания держится черновиком, приём лидов не падает).
* В песочнице денег не касаемся вовсе.
* Синтетические номера 7999… — реальных ПДн нет.
*/
uses(RefreshDatabase::class);
function afRule(int $tenantId, string $cap = '2500.00', 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' => $cap,
'daily_limit_rub' => $dailyLimit,
]);
}
/** Скармливает n телефонов с заданным сдвигом (для разных пачек — разные номера). */
function afFeed(int $tenantId, int $from, int $to): void
{
for ($i = $from; $i <= $to; $i++) {
app(TelegramAutoAccumulator::class)->accumulate($tenantId, '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT));
}
}
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' => '10000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
// Клиентская смета за пачку из 3 показов (то, что списывается с баланса).
$this->cost = app(TelegramTariffService::class)->clientEstimateRub(3);
});
afterEach(fn () => Carbon::setTestNow());
it('в бою авто-путь списывает клиентскую смету и ставит в очередь', function () {
afRule($this->tenant->id, cap: '2500.00');
afFeed($this->tenant->id, 1, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_QUEUED);
// Баланс упал ровно на клиентскую смету, списание отражено проводкой.
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub('10000.00', $this->cost, 2))
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(1);
});
it('денег НЕ хватает на смету → держит черновиком, без списания', function () {
// Баланс на копейку меньше сметы — списать нельзя.
DB::table('tenants')->where('id', $this->tenant->id)
->update(['balance_rub' => bcsub((string) $this->cost, '0.01', 2)]);
afRule($this->tenant->id, cap: '2500.00');
afFeed($this->tenant->id, 1, 3);
Queue::assertNothingPushed();
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_DRAFT);
// Баланс не тронут, ни одной проводки списания.
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub((string) $this->cost, '0.01', 2))
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
});
it('две пачки за день — счётчик суммирует, две списанные сметы', function () {
$rule = afRule($this->tenant->id, cap: '2500.00', dailyLimit: '1000.00');
afFeed($this->tenant->id, 1, 3); // пачка 1 → очередь, новая пачка откроется
afFeed($this->tenant->id, 4, 6); // пачка 2 → очередь
Queue::assertPushed(RunTelegramCampaignJob::class, 2);
$rule->refresh();
expect((string) $rule->spent_today_rub)->toBe(bcadd($this->cost, $this->cost, 2)); // 2 сметы
// Баланс упал на две сметы, два списания.
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub('10000.00', bcadd($this->cost, $this->cost, 2), 2))
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(2);
});
it('в песочнице авто-путь денег не касается', function () {
config(['client_tg.sandbox' => true]);
afRule($this->tenant->id, cap: '2500.00', dailyLimit: '0.00');
afFeed($this->tenant->id, 1, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
// Ни списаний, ни движения баланса — денег в песочнице не трогаем.
expect((string) $this->tenant->fresh()->balance_rub)->toBe('10000.00')
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});