Files
portal/app/tests/Feature/ClientTg/AutoFreezeTest.php
T
Дмитрий 68fe632cca fix(телеграм-реклама): правки по сводному код-ревью ветки — деньги, статус-машина, робот, RLS
Закрывает находки ревью: C1-блокер + рассинхроны длины + все оранжевые. TDD, всё зелёное.
Поведение в песочнице не меняется; правки готовят ветку к боевому включению.

F1 (блокер): AdWalletService::freeze реактивирует released-hold через updateOrCreate по
  4-ключу — пересдача кампании и повторная заявка на имя больше не падают на дубле ключа
  23505 в боевом режиме.
F2: длины валидации выровнены под колонки БД — имя 64, ad_link 500, ord_category 200;
  длинное значение даёт ошибку поля, а не замаскированный 422 от БД.
F3: авто-рассылка морозит потолок бюджета симметрично ручному запуску только в бою и
  считает дневной лимит под lockForUpdate строки правила.
F4: кампания не зависает в moderating вечно — переход moderating→needs_review плюс
  предохранитель уборщика по возрасту client_tg.moderation_stuck_hours=48, бронь не трогаем.
F5: единое осторожное правило возврата брони в finalize и failed — есть mts_campaign_id
  значит могла уйти на модерацию → needs_review без release; нет id → failed плюс возврат брони.
F6: робот cabinet.js — денежные кнопки оплатить/списать/запустить в чёрном списке
  domClickButton, finalize целит только кнопку отправки на модерацию.
F7: finalize live не врёт launched:true на шаге /payment — launched:false, stoppedAt:payment;
  не дошли до /payment → падаем громко.
F8: assertCostWithinCap подключён в live-finalize — сверка фактической стоимости с потолком.
F9: GRANT SELECT служебным ролям на client_tg_campaigns миграцией 000016 — иначе
  кросс-тенантные джобы Poll/Sweep видели бы 0 строк на проде; правка ложного комментария в
  000011. CHANGELOG v8.93, rls-reviewer CLEAN. ДЕПЛОЙ: ПЕРЕзапустить db/03_service_bypass_policies.sql.

Приёмка: бэкенд ClientTg 196/196; робот npm test 89/89; pint/phpstan/deptrac чисто. Фронт не трогали.

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

106 lines
4.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\AdWallet;
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\Queue;
/**
* Ревью-фикс F3 (Деньги I2): авто-путь ДОЛЖЕН бронировать потолок (budget_cap_rub)
* симметрично ручному launch — иначе кампания уходит в очередь без зарезервированных
* денег. Проверка баланса — против ПОТОЛКА брони (не только сметы), чтобы freeze не
* бросил в лицо накопителю (best-effort). В песочнице брони нет.
* Синтетические номера 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();
$this->cost = app(TelegramTariffService::class)->estimateRub(3); // 1.35
});
afterEach(fn () => Carbon::setTestNow());
it('в бою авто-путь бронирует потолок и ставит в очередь', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
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);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('2500.00'); // забронирован потолок
});
it('денег хватает на смету, но НЕ на потолок брони → держит черновиком, без брони', function () {
app(AdWalletService::class)->topup($this->tenant->id, '2000.00', 'telegram', 'test'); // > сметы 1.35, < потолка 2500
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);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00');
});
it('две пачки за день — счётчик суммирует, две брони по потолку', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
$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.70
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('5000.00'); // 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);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect($wallet)->toBeNull(); // кошелёк не заводился — денег не касались
});