68fe632cca
Закрывает находки ревью: 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>
85 lines
3.6 KiB
PHP
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);
|
|
});
|