Files
portal/app/tests/Feature/Advertising/ChestnyeStatusyKampaniiTest.php
T
Дмитрий fcf4331d24 fix(реклама): карточка объясняет, что происходит с объявлениями
Владелец, глядя на две живые карточки: «статус на модерации — не понятно,
идут показы или ожидает! и принято, когда реально отклонили».

Корень: ярлык один на всю кампанию, а объявления внутри в разных состояниях,
и у объявления два независимых признака — прошло проверку и показывается.
Карточка сваливала их в одно слово.

Три починки:

1. Портал спрашивает Яндекс о модерации каждые 15 минут вместо двух часов.
   У кампании #11 проверка кончилась между обходами, и клиент полтора часа
   видел бы «На модерации» на уже работающей кампании. Сторож держит
   расписание */15.

2. Портал считает придержанные объявления — одобренные, показ которых
   Яндекс не включил. Признак уже ставился на объявление, но наверх не
   поднимался; теперь в списке есть banners_held. Два сторожа: слепок
   боевой #6 даёт 13, слепок #11 даёт 0.

3. Ярлык называет положение дел словами, приписка всегда даёт числа:
   «Яндекс проверяет объявления» + «Одобрено 9 из 15, остальные ещё
   проверяются»; «Одобрено, но показ не включён» + «Одобрено 13 из 15,
   отклонено 2. Яндекс их пока не показывает — причина в сообщениях
   кампании»; «Крутится» + «Одобрено 15 из 15». Числа показываются всегда,
   а не только при частичном отказе.

Живой замер, ради которого всё это: у боевой кампании #6 все 15 объявлений
выключены при 13 одобренных — Яндекс просит документы, поэтому показов нет
с 30.07. Карточка про это молчала.

Прогон: 386 сторожей блока рекламы, 1974 фронта, статанализ 0, стиль чист.
Схема не менялась. Тесты гонялись на отдельной базе liderra_testing_s19 —
общую соседняя смена сносила трижды за прогон.

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

237 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\ModerationReason;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* Честные статусы кампаний — факты для экрана (замечание владельца З-6).
*
* 🔴 Беда, которую закрываем. Живой замер кампании #6 на бою 02.08.2026: статус
* `running`, клиент видит зелёное «Крутится», а показов доставлено НОЛЬ, потрачено
* 0,00 ₽ и при этом заморожено 3 333,36 ₽ — третьи сутки. Формально ярлык не врёт
* (правило спеки §6: кампания работает, если принято хотя бы одно объявление), по
* смыслу врёт.
*
* Правило перехода статусов трогать НЕЛЬЗЯ: на `rejected` завязан возврат заморозки
* (AdWalletService::release, «ВЫХОД 2»). Чиним то, что видит человек — но экран
* физически не может сказать правду, потому что список кампаний не отдаёт ни
* `delivered_impressions`, ни числа принятых/отклонённых объявлений. Этот файл
* стережёт, что факты доезжают.
*
* ПДн нет — телефонов тут не создаём вовсе.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Объявление кампании с заданным вердиктом модерации. Размер — лишь бы уникальный. */
function bannerSVerdiktom(AdCampaign $campaign, string $status, int $width, bool $included = true): AdCampaignBanner
{
return AdCampaignBanner::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'width' => $width,
'height' => 250,
'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$width}x250.jpg",
'bytes' => 1000,
'included' => $included,
'moderation_status' => $status,
]);
}
it('список кампаний отдаёт СКОЛЬКО ПОКАЗОВ БЫЛО, а не только сколько обещали', function () {
AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Приёмка робота',
'audience_days' => 30,
'estimated_impressions' => 12000,
'delivered_impressions' => 0,
]);
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
// Обещали 12000, показали 0 — экран обязан видеть обе цифры, иначе «Крутится»
// при нуле показов не отличить от настоящей работы.
expect($row['estimated_impressions'])->toBe(12000)
->and($row['delivered_impressions'])->toBe(0);
});
it('список отдаёт, сколько объявлений принято и сколько отклонено', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Кампания с частичным отказом',
'audience_days' => 30,
]);
// Слепок боевой кампании #6: приняли 13, отклонили 2 (узкие полоски).
foreach (range(1, 13) as $i) {
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_ACCEPTED, 300 + $i);
}
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_REJECTED, 320);
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_REJECTED, 321);
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
expect($row['banners_total'])->toBe(15)
->and($row['banners_accepted'])->toBe(13)
->and($row['banners_rejected'])->toBe(2);
});
/**
* 🔴 «Принято» и «показывается» — РАЗНЫЕ вещи, и клиент их путает.
*
* Живой случай 04.08.2026: у кампании #6 тринадцать объявлений приняты модерацией, но
* Яндекс их не включает (просит документы), и показов нет неделю. Карточка говорила
* «Принято, показов пока нет» и не объясняла почему. Владелец: «принято когда реально
* отклонили — я честно нихера не понимаю».
*
* Чтобы карточка могла сказать правду, ей нужно ЧЕТВЁРТОЕ число: сколько принятых
* объявлений Яндекс придерживает. Признак придержанного — причина POKAZ_PRIDERZHAN,
* её ставит SyncCampaignModerationJob, когда Яндекс отказался включить показ.
*/
it('список отдаёт, сколько принятых объявлений Яндекс придерживает', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Приняли, но не показывают',
'audience_days' => 30,
]);
// Слепок боевой кампании #6: 13 приняты и придержаны, 2 отклонены.
foreach (range(1, 13) as $i) {
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_ACCEPTED, 300 + $i)
->update(['moderation_reason' => ModerationReason::POKAZ_PRIDERZHAN]);
}
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_REJECTED, 320);
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_REJECTED, 321);
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
expect($row['banners_held'])->toBe(13);
});
it('принятое и показывающееся объявление придержанным не считается', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Показы идут',
'audience_days' => 30,
]);
// Слепок кампании #11 в день запуска: девять уже показываются, шесть ещё проверяют.
foreach (range(1, 9) as $i) {
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_ACCEPTED, 300 + $i)
->update(['moderation_reason' => 'Идут показы.']);
}
foreach (range(1, 6) as $i) {
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_MODERATION, 400 + $i);
}
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
expect($row['banners_total'])->toBe(15)
->and($row['banners_accepted'])->toBe(9)
->and($row['banners_held'])->toBe(0);
});
it('кампания без объявлений отдаёт нули, а не пропущенные поля', function () {
AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_DRAFT,
'name' => 'Пустой черновик',
'audience_days' => 30,
]);
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
// Не null и не отсутствие ключа: экран считает по этим числам, а `undefined`
// в арифметике даёт NaN и молча ломает ярлык.
expect($row['banners_total'])->toBe(0)
->and($row['banners_accepted'])->toBe(0)
->and($row['banners_rejected'])->toBe(0);
});
it('объявления, снятые с показа, в счёт не идут', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Часть снята с показа',
'audience_days' => 30,
]);
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_ACCEPTED, 300);
// Снято галочкой «в показ» — в Яндекс не уезжает, в приписке «2 из 15» ему не место.
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_REJECTED, 301, included: false);
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
expect($row['banners_total'])->toBe(1)
->and($row['banners_accepted'])->toBe(1)
->and($row['banners_rejected'])->toBe(0);
});
it('чужие объявления в счётчики не попадают', function () {
$moya = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Моя',
'audience_days' => 30,
]);
bannerSVerdiktom($moya, AdCampaignBanner::MOD_ACCEPTED, 300);
$chuzhoyTenant = Tenant::factory()->create();
$chuzhaya = AdCampaign::create([
'tenant_id' => $chuzhoyTenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Чужая',
'audience_days' => 30,
]);
foreach (range(1, 5) as $i) {
bannerSVerdiktom($chuzhaya, AdCampaignBanner::MOD_REJECTED, 300 + $i);
}
$data = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
expect($data)->toHaveCount(1)
->and($data[0]['banners_total'])->toBe(1)
->and($data[0]['banners_rejected'])->toBe(0);
});
it('счётчики берутся ОДНИМ запросом на весь список, а не по кампании в цикле', function () {
foreach (range(1, 12) as $i) {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => "Кампания {$i}",
'audience_days' => 30,
]);
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_ACCEPTED, 300 + $i);
}
// Датчик на N+1: считаем запросы к таблице объявлений. По одному на кампанию —
// это 12 лишних походов в базу на каждое открытие экрана, и растёт с числом кампаний.
$zaprosy = 0;
DB::listen(function ($query) use (&$zaprosy) {
if (str_contains($query->sql, 'ad_campaign_banners')) {
$zaprosy++;
}
});
$this->getJson('/api/advertising/campaigns')->assertOk();
expect($zaprosy)->toBe(1);
});