Files
portal/app/tests/Feature/ClientTg/ZavershenieJobTest.php
T
Дмитрий ac16861b87 телеграм: у рекламы появился конец — закрытие отработавших кампаний и возврат переплаты
Статус launched был терминальным: конца у рекламы не существовало вовсе. Отсюда две беды
разом — робот ходил за цифрами вечно, а неоткрученные деньги возвращать было некуда.
Живая улика, кампания №15: списано 255,36 ₽ за 380 показов, показан 21, площадка
израсходовала 10,08 ₽. Переплата 241,25 ₽ не вернулась бы никогда. Портал честно читал
stats_spend_rub и не использовал его нигде.

Ш1 робот перестаёт долбиться в потерянные кампании: 5 неудач подряд уводят в разбор,
   считаются только заходы после последней правки строки кампании
Ш2 статус кабинета доезжает до портала; незнакомое слово идёт в журнал
Ш3 новый статус completed «Показы закончились», переход launched → completed
Ш4 суточная задача в 04:40 закрывает отработавшую рекламу и возвращает переплату;
   возврат идемпотентен ключом tg:campaign:ID:vozvrat, неизвестный расход не закрываем
Ш5 тревога в журнал, если заморозка денег висит дольше суток

Попутно вскрыто и починено: разбор кампании писал клиенту «деньги вернулись на рекламный
кошелёк», не вернув ни рубля. Новый вход в старый узел сделал его сообщение ложью.

Новая колонка client_tg_campaigns.spend_changed_at — двигается только при реальном
изменении суммы; stats_read_at для этого не годится, он растёт на каждом заходе робота.
Журнал схемы v9.74.

Кнопка «Остановить» не сделана, закрытие по слову кабинета тоже: разведка 08.08 показала,
что кабинет говорит «Останавливается, до 30 минут», но итогового слова мы так и не узнали,
а вернулись ли деньги — не замерено. Подробности и что делать дальше — в плане.

На боевой не выкачено: нужна миграция и разрешение владельца.

Сторожа: ClientTg 438 зелёных, экраны телеграма 47 зелёных.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:36:51 +03:00

103 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\ClientTg\ZavershitOtrabotavshieTelegramJob;
use App\Models\AdWalletHold;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\TelegramCampaignChargeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Tests\Concerns\SharesSupplierPdo;
/**
* Суточная проверка денег Telegram-рекламы: закрыть отработавшее и крикнуть про
* зависшую заморозку.
*
* Перечисление кросс-тенантное через BYPASSRLS-роль — шарим PDO, иначе данные транзакции
* RefreshDatabase не видны supplier-соединению.
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
function jobKampaniya(int $tenantId, array $peremenit = []): Campaign
{
return Campaign::query()->create(array_merge([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_LAUNCHED,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '400.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 400,
'estimated_cost_rub' => '268.80',
'mts_cost_rub' => '182.40',
'actual_cost_rub' => '255.36',
'stats_impressions' => 21,
'stats_spend_rub' => '10.08',
'stats_read_at' => now(),
'created_by' => 1,
], $peremenit));
}
beforeEach(function () {
config()->set('client_tg.sandbox', false);
config()->set('client_tg.markup', '1.40');
config()->set('client_tg.spend_stale_days', 3);
$this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', 'telegram', 'Пополнение для теста');
});
it('закрывает вставшую рекламу и не трогает идущую', function () {
$vstala = jobKampaniya($this->tenant->id, ['spend_changed_at' => now()->subDays(4)]);
$idyot = jobKampaniya($this->tenant->id, ['spend_changed_at' => now()->subHours(2)]);
ZavershitOtrabotavshieTelegramJob::dispatchSync();
expect($vstala->fresh()->status)->toBe(Campaign::STATUS_COMPLETED)
->and($idyot->fresh()->status)->toBe(Campaign::STATUS_LAUNCHED);
});
it('кричит в журнал про заморозку, висящую дольше суток', function () {
$campaign = jobKampaniya($this->tenant->id, ['spend_changed_at' => now()]);
app(TelegramCampaignChargeService::class)->freeze($campaign, '268.80');
// Отматываем заморозку на двое суток — ровно случай боевой №14 (268,80 ₽ заперты
// с 06.08, никто не знал).
AdWalletHold::where('source_id', $campaign->id)->update(['created_at' => now()->subDays(2)]);
$zhurnal = Log::spy();
ZavershitOtrabotavshieTelegramJob::dispatchSync();
$zhurnal->shouldHaveReceived('warning', [
Mockery::on(fn (string $soobshenie): bool => $soobshenie === 'client_tg.zamorozka_visit'),
Mockery::on(fn (array $ctx): bool => ($ctx['campaign_id'] ?? null) === (int) $campaign->id
&& ($ctx['summa_rub'] ?? null) === '268.80'),
]);
});
it('свежая заморозка тревоги не поднимает', function () {
$campaign = jobKampaniya($this->tenant->id, ['spend_changed_at' => now()]);
app(TelegramCampaignChargeService::class)->freeze($campaign, '268.80');
$zhurnal = Log::spy();
ZavershitOtrabotavshieTelegramJob::dispatchSync();
// 🪤 Два матчера, а не одна строка: у Log::warning ДВА довода (сообщение и подробности),
// и проверка по одному доводу не совпала бы никогда — сторож был бы вечно зелёным и не
// охранял бы ничего.
//
// 🔑 Проверено вырезанием: с порогом тревоги −1 час этот тест КРАСНЕЕТ. Порог 0 его не
// валит, и это не слабость сторожа — заморозка создаётся за доли секунды до проверки,
// и строгое «раньше, чем» на таком зазоре честно не срабатывает.
$zhurnal->shouldNotHaveReceived('warning', [
Mockery::on(fn (string $soobshenie): bool => $soobshenie === 'client_tg.zamorozka_visit'),
Mockery::any(),
]);
});