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

85 lines
3.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
use App\Models\AdWallet;
use App\Models\AdWalletHold;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
/**
* Ревью-фикс F4 (Мост #1): кампания не должна вечно висеть в `moderating`, если робот
* не может прочитать вердикт (сломались селекторы кабинета / кампанию удалили / id
* протух) — иначе бронь кошелька заперта бессрочно. Предохранитель по возрасту: старше
* окна `client_tg.moderation_stuck_hours` → `needs_review` для ручного разбора, БЕЗ
* возврата брони (кампания могла реально показываться → деньги вслепую не трогаем).
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
/** Кампания на модерации с состаренным updated_at (в часах). */
function modStuckCampaign(int $tenantId, ?string $mtsId, int $ageHours): Campaign
{
$c = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_MODERATING,
'mts_campaign_id' => $mtsId,
'ad_text' => 'Мод-стак-тест',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
if ($ageHours > 0) {
DB::table('client_tg_campaigns')->where('id', $c->id)
->update(['updated_at' => now()->subHours($ageHours)]);
}
return $c;
}
function modStuckWallet(int $tenantId, int $campaignId, string $balance, string $freeze): void
{
AdWallet::query()->create(['tenant_id' => $tenantId, 'balance_rub' => $balance, 'frozen_rub' => '0.00']);
app(AdWalletService::class)->freeze($tenantId, 'telegram', 'campaign', $campaignId, $freeze);
}
it('moderating допускает переход в needs_review', function () {
$t = Tenant::factory()->create();
$c = modStuckCampaign($t->id, '2231160', 0);
expect($c->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW))->toBeTrue();
});
it('moderating дольше окна → needs_review, бронь НЕ возвращается', function () {
config()->set('client_tg.sandbox', false);
config()->set('client_tg.moderation_stuck_hours', 24);
$t = Tenant::factory()->create();
$c = modStuckCampaign($t->id, '2231161', ageHours: 25);
modStuckWallet($t->id, $c->id, '5000.00', '1000.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00')
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_ACTIVE);
});
it('свежая moderating (в пределах окна) не трогается', function () {
config()->set('client_tg.sandbox', false);
config()->set('client_tg.moderation_stuck_hours', 24);
$t = Tenant::factory()->create();
$c = modStuckCampaign($t->id, '2231162', ageHours: 2); // 2ч < 24ч
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING);
});