6b732de6ad
Кампания, застрявшая на модерации дольше 48 часов, обязана уйти в ручной разбор — иначе висит вечно с замороженными деньгами клиента и молча. Срок считается по времени изменения строки, а вчерашняя правка «день начала показов» писала дату при КАЖДОЙ проверке робота массовым обновлением, которое всегда двигает updated_at. Робот ходит раз в четверть часа — счётчик не досчитал бы никогда. Защита была цела на вид и мертва на деле. Улика с боевого: у кампании №15 время изменения строки совпадало с концом последнего задания робота до секунды, при том что вердикта не было третьи сутки. Пишем дату, только если она вправду изменилась. Сравнение сырых значений мимо приведения типов: starts_on это date:Y-m-d, сравнение с Carbon разошлось бы молча. Сторожа SchyotchikZavisshejModeraciiTest (3 шт) видены красными до правки. Блок телеграма 381 зелёный на своей базе, статанализ 0, pint чисто. На боевой НЕ выкачено. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
116 lines
5.4 KiB
PHP
116 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
|
|
use App\Models\ClientTg\Campaign;
|
|
use App\Models\Tenant;
|
|
use App\Services\ClientTg\RobotResult;
|
|
use App\Services\ClientTg\TelegramModerationVerdictApplier;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
// Уборщик перечисляет кампании через pgsql_supplier (BYPASSRLS) — шарим PDO,
|
|
// иначе созданное на дефолтном соединении внутри транзакции ему не видно.
|
|
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
|
|
|
/**
|
|
* 🔴 Живая дыра, найденная 07.08.2026 замером боевого.
|
|
*
|
|
* Кампания, застрявшая на модерации дольше `client_tg.moderation_stuck_hours` (48 ч),
|
|
* обязана уйти в ручной разбор: иначе она висит вечно с замороженными деньгами клиента,
|
|
* и об этом никто не узнаёт. Срок уборщик считает по времени последнего изменения строки.
|
|
*
|
|
* А правка «день начала показов» (06.08.2026) пишет дату в кампанию при КАЖДОЙ проверке
|
|
* робота — массовым обновлением, которое всегда двигает `updated_at`, даже когда дата не
|
|
* изменилась. Робот ходит раз в четверть часа, значит счётчик 48 часов сбрасывается
|
|
* каждые 15 минут и не досчитывает НИКОГДА. Защита цела на вид и мертва на деле.
|
|
*
|
|
* 🔑 Улика с боевого: у кампании №15 время изменения строки совпадало с концом
|
|
* последнего задания робота до секунды, при том что вердикта не было третьи сутки.
|
|
*
|
|
* Лечение: писать дату, только если она вправду изменилась.
|
|
*/
|
|
|
|
/** Тенант с открытым tenant-контекстом (RLS). */
|
|
function moderaciyaTenant(): Tenant
|
|
{
|
|
$t = Tenant::factory()->create(['balance_rub' => '0.00']);
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$t->id);
|
|
|
|
return $t;
|
|
}
|
|
|
|
/** Кампания на модерации, состаренная на $chasovNazad часов. */
|
|
function moderaciyaKampaniya(int $tenantId, int $chasovNazad, ?string $startsOn): Campaign
|
|
{
|
|
$c = Campaign::query()->create([
|
|
'tenant_id' => $tenantId,
|
|
'status' => Campaign::STATUS_MODERATING,
|
|
'mts_campaign_id' => '2237821',
|
|
'starts_on' => $startsOn,
|
|
'ad_text' => 'Тест счётчика зависшей модерации',
|
|
'ord_category' => 'Размещение рекламы',
|
|
'budget_cap_rub' => '1000.00',
|
|
'audience_kind' => Campaign::AUDIENCE_LIST,
|
|
'planned_count' => 0,
|
|
'estimated_cost_rub' => '268.80',
|
|
'created_by' => 1,
|
|
]);
|
|
|
|
// Прямой апдейт мимо Eloquent — иначе штамп «сейчас».
|
|
DB::table('client_tg_campaigns')->where('id', $c->id)
|
|
->update(['updated_at' => now()->subHours($chasovNazad)]);
|
|
|
|
return $c;
|
|
}
|
|
|
|
/** Робот сходил в кабинет: вердикта нет, дата старта такая-то. */
|
|
function robotProveril(int $tenantId, int $campaignId, ?string $startDate): void
|
|
{
|
|
app(TelegramModerationVerdictApplier::class)->apply(
|
|
$tenantId,
|
|
$campaignId,
|
|
new RobotResult(ok: true, moderationStatus: 'moderating', startDate: $startDate),
|
|
false,
|
|
);
|
|
}
|
|
|
|
it('проверка робота не сбрасывает счётчик зависшей модерации', function () {
|
|
config()->set('client_tg.sandbox', false);
|
|
config()->set('client_tg.moderation_stuck_hours', 48);
|
|
$t = moderaciyaTenant();
|
|
$c = moderaciyaKampaniya($t->id, 49, '2026-08-07');
|
|
|
|
// Робот сходил ещё раз: вердикта по-прежнему нет, дата старта ТА ЖЕ самая.
|
|
robotProveril($t->id, $c->id, '2026-08-07');
|
|
|
|
SweepStuckTelegramCampaignsJob::dispatchSync();
|
|
|
|
// Кампания висит 49 часов — обязана уйти в разбор, сколько бы раз её ни проверяли.
|
|
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW);
|
|
});
|
|
|
|
it('повторная запись той же даты старта не двигает время изменения строки', function () {
|
|
$t = moderaciyaTenant();
|
|
$c = moderaciyaKampaniya($t->id, 49, '2026-08-07');
|
|
$bylo = DB::table('client_tg_campaigns')->where('id', $c->id)->value('updated_at');
|
|
|
|
robotProveril($t->id, $c->id, '2026-08-07');
|
|
|
|
$stalo = DB::table('client_tg_campaigns')->where('id', $c->id)->value('updated_at');
|
|
|
|
expect($stalo)->toBe($bylo);
|
|
});
|
|
|
|
it('новая дата старта всё-таки записывается — клиент должен её увидеть', function () {
|
|
$t = moderaciyaTenant();
|
|
$c = moderaciyaKampaniya($t->id, 49, null);
|
|
|
|
robotProveril($t->id, $c->id, '2026-08-09');
|
|
|
|
expect((string) DB::table('client_tg_campaigns')->where('id', $c->id)->value('starts_on'))
|
|
->toBe('2026-08-09');
|
|
});
|