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

131 lines
6.4 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\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Ревью-фикс F5 (Мост #2/#3): единое ОСТОРОЖНОЕ правило возврата брони — как уборщик.
* Если у кампании есть mts_campaign_id (черновик в кабинете реально создан), она МОГЛА
* уйти на модерацию → деньги вслепую НЕ возвращаем (иначе оплатим показы из своего
* кармана), помечаем needs_review. Без id — чистый провал: failed + возврат брони.
* Всё в боевом режиме (sandbox=false). Синтетические номера 7999… — реальных ПДн нет.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('client_tg.sandbox', false);
});
function crCampaign(int $tenantId, string $status, ?string $mtsId = null): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'mts_campaign_id' => $mtsId,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create(['tenant_id' => $tenantId, 'campaign_id' => $campaign->id, 'phone' => $phone]);
}
return $campaign;
}
function crMockRobot(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
it('finalize: провал робота с id кабинета → needs_review, бронь НЕ возвращается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
$campaign = crCampaign($tenant->id, Campaign::STATUS_QUEUED);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
crMockRobot(RobotResult::failed('Оборвалось на подтверждении', 'confirm', '2231200'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($fresh->mts_campaign_id)->toBe('2231200')
->and((string) $wallet->frozen_rub)->toBe('500.00') // бронь держится
->and((string) $wallet->balance_rub)->toBe('1000.00');
});
it('finalize: провал робота БЕЗ id кабинета → failed, бронь возвращается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
$campaign = crCampaign($tenant->id, Campaign::STATUS_QUEUED);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
crMockRobot(RobotResult::failed('Мало номеров «не МТС»', 'upload'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and((string) $wallet->frozen_rub)->toBe('0.00'); // бронь вернулась
});
it('failed(): running с id кабинета → needs_review, бронь НЕ возвращается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
$campaign = crCampaign($tenant->id, Campaign::STATUS_RUNNING, mtsId: '2231201');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and((string) $wallet->frozen_rub)->toBe('500.00');
});
it('failed(): running без id кабинета → failed, бронь возвращается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
$campaign = crCampaign($tenant->id, Campaign::STATUS_RUNNING);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and((string) $wallet->frozen_rub)->toBe('0.00');
});
it('failed(): уже moderating → статус не трогаем и бронь НЕ возвращаем', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
$campaign = crCampaign($tenant->id, Campaign::STATUS_MODERATING, mtsId: '2231202');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_MODERATING)
->and((string) $wallet->frozen_rub)->toBe('500.00');
});