Files
portal/app/tests/Feature/Advertising/ChestnyeStatusyKampaniiTest.php
T
Дмитрий a2496dfe61
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(реклама): честные статусы кампаний — экран больше не зовёт «Крутится» рекламу с нулём показов
Замечание владельца З-6: «почему статус крутится, когда она отклонена?»
Живой замер кампании #6 на бою: статус running, зелёное «Крутится», показов
доставлено НОЛЬ, потрачено 0 ₽, заморожено 3 333,36 ₽ — третьи сутки.

Главное, что вскрылось: экран физически не мог показать правду. Список кампаний
отдавал estimated_impressions («сколько обещали») и не отдавал delivered_impressions
(«сколько было»), хотя колонка есть. Чисел принятых/отклонённых объявлений тоже не
было. Поэтому чинили с сервера, а не с подписей.

- сервер отдаёт факты: delivered_impressions + счётчики объявлений (всего/принято/
  отклонено), ОДНИМ запросом на весь список — на N+1 поставлен отдельный датчик;
  те же счётчики доезжают и в отчёт по кампании;
- считаются только включённые в показ: снятое галочкой в Яндекс не уезжает и в
  знаменателе «2 из 15» ему не место;
- ярлык по фактам: running при нуле показов → «Принято, показов пока нет»
  нейтральным цветом; пошли показы → зелёное «Крутится»; часть отклонена →
  приписка «часть объявлений отклонена — 2 из 15»; все → красное «Отклонено»;
- правило перехода статусов НЕ тронуто: на rejected висит возврат заморозки
  (AdWalletService::release, «ВЫХОД 2»). Чинили то, что видит человек;
- подписи собраны в один файл (composables/campaignStatusMeta.ts). Их было ДВЕ
  копии — в списке и в отчёте — и они уже разъехались; разъехавшиеся копии и есть
  та разница между экранами, на которую жалуется владелец;
- телеграм-экран: подписи вынесены отдельно и приведены к тем же словам
  («На модерации в МТС» ↔ «На модерации в Яндексе», «Отклонено» на обоих);
  голое «Запущена» → «Запущена в кабинете МТС».

Граница честности: у телеграм-модуля нет счётчика показов ВООБЩЕ — actual_cost_rub
в таблице есть, но её не пишет ни одна строка кода. Поэтому там нельзя сказать ни
«крутится», ни «показов пока нет»: это была бы выдумка того же сорта. Записано
открытым вопросом владельцу в файле замечаний.

Проверено: 435 тестов рекламы Яндекса, 281 телеграма, 1771 тест экранов
(235 файлов, 3 пропущено), статанализ 0, формат чист. Новых тестов 18.
Глазами НЕ принято (приёмка — пачка 5), на боевой НЕ выкачено.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:53:57 +03:00

179 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\Tenant;
use App\Models\User;
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);
});
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);
});