Files
portal/app/tests/Feature/Advertising/VozobnovlenieNeVryotProStatusTest.php
T
Дмитрий 49ef80cd55 fix(реклама): возобновление выдавало непроверенную кампанию за крутящуюся
Прогон Я6 приёмочного листа на боевом, с разрешения владельца трогать кошелёк
тенанта 2. Кошелёк только пополняли — из летописи ничего не вынимали.

Подгонка под «впритык»: свободных было 7 422,76 руб, долили 77,24 руб до
круглых 7 500,00 и завели кампанию на 15 000 показов по 500 руб за 1000 =
смета ровно 7 500,00 руб. Деньги отработали безупречно: запуск при точном
равенстве прошёл, заперли ровно смету, свободных осталось ровно ноль и в
минус не ушли; пауза вернула всё целиком, возобновление заперло столько же;
в летописи три честные строки, строка заморозки одна и та же.

А вот статус врал. На паузу можно поставить и кампанию, которую Яндекс ещё
проверяет — это задумано. Но «Возобновить» ставило running БЕЗУСЛОВНО, и
портал показывал зелёное «Крутится» рекламе, которой Яндекс показываться не
разрешал: показов нет и быть не может. Та же болезнь, что уже ловили на
кампании 6. Потери вердикта нет — часовой обход берёт и крутящиеся тоже.

Починка: «Крутится» ставим только когда вердикт вынесен, иначе возвращаем
«на модерации». Мерка «решено или нет» взята ТА ЖЕ, что у часового обхода:
баннер не решён, пока он не ACCEPTED и не REJECTED. Две разные мерки одного
и того же однажды разойдутся.

Сторож VozobnovlenieNeVryotProStatusTest — три проверки: не врёт на
непроверенной, не ломает одобренную, деньги ведут себя одинаково при любом
вердикте. Принят красным: до починки отдавал running.

Ловушка сторожа записана в журнал: без заглушки на отчёт о показах пауза
отвечает 200, но деньги намеренно не отпускает.

Счёт по комбинациям: закрыты 4 из 9 — Я6, Я7, Я8, Я9.

Прогоны: 430 тестов рекламы зелёные, pint чисто, статанализ 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:04:07 +03:00

127 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
/**
* Прогон Я6 приёмочного листа, побочная находка живого замера 05.08.2026.
*
* На паузу можно поставить не только крутящуюся кампанию, но и ту, что ещё ждёт вердикта
* Яндекса, — это правильно и так задумано. А вот «Возобновить» ставило `running`
* БЕЗУСЛОВНО. Кампания, которую Яндекс ещё проверяет, возвращалась с экрана паузы
* зелёной надписью «Крутится»: показов нет и быть не может, а портал говорит, что идут.
*
* Та же болезнь, что уже ловили на #6: клиент видел «Крутится» при нуле показов и
* запертых деньгах. Деньги при этом целы — заморозка и возврат отработали копейка
* в копейку, врал именно статус.
*
* 🔑 Мерка «решено или нет» взята ровно та же, что у часового обхода
* `SyncCampaignModerationJob`: баннер не решён, пока он не `ACCEPTED` и не `REJECTED`.
* Две разные мерки одного и того же однажды разойдутся.
*/
uses(DatabaseTransactions::class);
/** Кампания, уже заведённая в Яндексе, с баннерами в заданном состоянии модерации. */
function kampaniyaVYandekse(string $statusKampanii, string $statusBannerov): array
{
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.token' => 'DIRTOKEN',
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
]);
// 🪤 Отчёт о показах глушим обязательно: без него пауза не узнаёт числа показов и
// НАМЕРЕННО не отпускает деньги — «отпустить, не заплатив за показанное, значит
// подарить». Первый заход сторожа на это и налетел: пауза отвечала 200, а заморозка
// оставалась на месте. Ноль показов — единственное, что делает деньги сравнимыми.
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => []]),
'*/json/v5/reports' => Http::response('0', 200),
]);
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'yandex', 'приёмка');
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'C',
'mode' => AdCampaign::MODE_MANUAL,
'audience_days' => 10,
'use_uploaded_list' => true,
'estimated_impressions' => 2000,
'paid_impressions' => 2000,
'client_cpm_rub' => '500.00',
'landing_url' => 'https://liderra.ru/promo',
'run_days' => 1,
'status' => $statusKampanii,
'yandex_campaign_id' => 713315922,
'yandex_ad_group_id' => 5780584736,
]);
AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 300, 'height' => 250,
'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg",
'bytes' => 1000, 'included' => true, 'yandex_creative_id' => 4242,
'yandex_ad_id' => 17788562738, 'moderation_status' => $statusBannerov,
]);
app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1000.00');
return [$user, $campaign];
}
it('кампанию на модерации сняли с паузы — портал не выдаёт её за крутящуюся', function () {
[$user, $campaign] = kampaniyaVYandekse(
AdCampaign::STATUS_PENDING_MODERATION,
AdCampaignBanner::MOD_MODERATION,
);
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PAUSED);
$otvet = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
$otvet->assertOk();
expect($otvet->json('status'))->toBe(AdCampaign::STATUS_PENDING_MODERATION)
->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION);
});
it('одобренную кампанию сняли с паузы — она снова крутится', function () {
[$user, $campaign] = kampaniyaVYandekse(
AdCampaign::STATUS_RUNNING,
AdCampaignBanner::MOD_ACCEPTED,
);
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
$otvet = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/resume");
$otvet->assertOk();
expect($otvet->json('status'))->toBe(AdCampaign::STATUS_RUNNING)
->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
});
it('деньги на возобновлении ведут себя одинаково при любом вердикте', function () {
[$user, $campaign] = kampaniyaVYandekse(
AdCampaign::STATUS_PENDING_MODERATION,
AdCampaignBanner::MOD_MODERATION,
);
// Пауза отпускает неоткрученный остаток сметы, возобновление запирает его обратно.
// Смета: 2000 показов × 500 ₽ за 1000 = 1000 ₽.
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk();
$koshelek = AdWallet::where('tenant_id', $campaign->tenant_id)->first();
expect($koshelek->frozen_rub)->toBe('0.00');
$this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/resume")->assertOk();
expect(AdWallet::where('tenant_id', $campaign->tenant_id)->first()->frozen_rub)->toBe('1000.00');
});