Files
portal/app/tests/Feature/Advertising/CampaignMessageServiceTest.php
T
Дмитрий 3fdcd88ad3 feat реклама за показы: правда про документ, экран «ждёт разбора» и разбор своих ошибок
Задача 16 закрыта решением владельца. Дороги «робот везёт документ в кабинет Яндекса»
не существует — доказано двумя нарочными отказами, обычной тематикой и лицензируемой:
в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета.

Приём документа оставлен, но портал больше не молчит: клиент приложил файл — в ленту сразу
ложится отметка «документ у нас, передать его Яндексу автоматически нельзя, при
необходимости отнесём сами и напишем здесь», а владельцу уходит письмо на адрес алертов.
Без письма обещание было бы пустым: файл просто лёг бы на диск. Сам файл письмом
не отправляем — это чужие бумаги. Обычный ответ без файла ни отметки, ни письма не даёт.

Экран «ждёт разбора» в админке: ручка была, экрана не было. Третья карточка на странице
«Реклама» — клиент, кампания, что робот делал человеческими словами, номер объявления
и на чём споткнулся. В подписи прямо сказано, чего там НЕ будет: обычных отказов,
их клиент разбирает сам.

Дальше — разбор собственной работы этого дня. Найдено четыре ошибки, все исправлены.

1. ТЯЖЁЛАЯ. Доклад разведки на бою уронил бы очередь заданий целиком. Робот пишет в ленту
   под служебной ролью, а у неё на этой таблице было только чтение. Отказ по правам,
   500 роботу, три повтора — и задание навсегда «в работе». Пока хоть одно задание
   в работе, выдача отвечает «работы нет» ВСЕМ клиентам. Лечение — запись схемы v9.17:
   право на запись плюс нумератор. В плане про это было написано прямым текстом,
   я прошёл мимо. Тесты поймать не могли: ходят суперпользователем.
2. Признак «набор создан» я выдумал: взял метку, которая в нашей же разметке описана
   как СКРЫТАЯ галочка. Проверка «видно ли её» не сработала бы никогда. Признак с экрана
   убран совсем: успех определяет портал слепком креативов, а «окно не закрылось» —
   это норма, так и есть живьём.
3. Сломал ленту для повторного отказа. Поменял защиту от дублей на «такой текст уже
   когда-либо был» — и клиент, починивший рекламу и получивший тот же отказ, не увидел бы
   ничего. Вернул сравнение с последним сообщением, а заглушку «причину выясняем» держит
   теперь сам джоб: показываем один раз, пока сказать нечего.
4. Мой собственный тест оказался пустышкой: оставался зелёным при вырезанной защите.
   В нём отклонялись ВСЕ объявления, а тогда кампания уходит в «отклонена» и обход её
   больше не берёт. Сценарий существует только при частичном отказе — тест переписан
   на два объявления и теперь вырезание защиты его роняет.

Портал 391/391, админские экраны 9/9, фронт на затронутых наборах 52/52, робот 74/74,
мест снятия заморозки денег по-прежнему четыре.
На боевой не выкатывалось, рубильник Директа выключен, живьём разведка не гонялась.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:10:18 +03:00

174 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdCampaignMessage;
use App\Models\Tenant;
use App\Services\Advertising\CampaignMessageService;
function messageCampaign(): array
{
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false,
]);
return [$tenant, $campaign];
}
it('текст сообщения хранится целиком, длиннее 255 знаков в том числе', function () {
[$tenant, $campaign] = messageCampaign();
// Модератор перечисляет претензии списком — 255 знаков не хватает. Именно предел
// колонки moderation_reason уже валил обход модерации целиком.
$long = str_repeat('Причина отказа очень подробная. ', 40);
$msg = AdCampaignMessage::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'author' => AdCampaignMessage::AUTHOR_YANDEX,
'body' => $long,
]);
expect(mb_strlen($msg->refresh()->body))->toBe(mb_strlen($long));
});
it('пояснение Яндекса ложится в ленту', function () {
[$tenant, $campaign] = messageCampaign();
app(CampaignMessageService::class)->postFromYandex($campaign, null, 'Изображение не соответствует требованиям');
$messages = AdCampaignMessage::where('campaign_id', $campaign->id)->get();
expect($messages)->toHaveCount(1)
->and($messages->first()->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX)
->and($messages->first()->body)->toBe('Изображение не соответствует требованиям');
});
/**
* Опрос модерации бежит по расписанию. Пока Яндекс не передумал, он присылает ОДНУ И ТУ ЖЕ
* причину при каждом обходе. Без защиты лента за сутки превратится в сотню одинаковых строк,
* и клиенту прилетит сотня писем.
*/
it('тот же текст по тому же баннеру второй раз в ленту не попадает', function () {
[$tenant, $campaign] = messageCampaign();
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true,
'path' => 'ad-banners/a/300x250.jpg',
]);
$service = app(CampaignMessageService::class);
$service->postFromYandex($campaign, $banner->id, 'Одно и то же');
$second = $service->postFromYandex($campaign, $banner->id, 'Одно и то же');
expect($second)->toBeNull()
->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(1);
});
/** Другой размер блока — другая беда, её надо показать отдельно. */
it('тот же текст по ДРУГОМУ баннеру в ленту попадает', function () {
[$tenant, $campaign] = messageCampaign();
$slot = [
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'bytes' => 1000, 'included' => true,
];
$a = AdCampaignBanner::create($slot + ['width' => 300, 'height' => 250, 'path' => 'ad-banners/a/300x250.jpg']);
$b = AdCampaignBanner::create($slot + ['width' => 728, 'height' => 90, 'path' => 'ad-banners/a/728x90.jpg']);
$service = app(CampaignMessageService::class);
$service->postFromYandex($campaign, $a->id, 'Одно и то же');
$service->postFromYandex($campaign, $b->id, 'Одно и то же');
expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
});
/** Яндекс передумал и написал другое — это новость, её показываем. */
it('изменившийся текст по тому же баннеру в ленту попадает', function () {
[$tenant, $campaign] = messageCampaign();
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true,
'path' => 'ad-banners/a/300x250.jpg',
]);
$service = app(CampaignMessageService::class);
$service->postFromYandex($campaign, $banner->id, 'Первая претензия');
$service->postFromYandex($campaign, $banner->id, 'Вторая претензия');
expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
});
it('пустой текст в ленту не кладём', function () {
[, $campaign] = messageCampaign();
expect(app(CampaignMessageService::class)->postFromYandex($campaign, null, ' '))->toBeNull()
->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0);
});
/**
* 🪤 Ловушка, которую открыла разведка, и которую нельзя чинить дедупом по всей истории.
*
* Клиент починил рекламу, отправил заново — и получил ТОТ ЖЕ отказ. Это новость: клиент
* должен узнать, что его опять не пустили, и по той же причине. Дедуп «такой текст уже
* когда-то был» съел бы это сообщение, и клиент остался бы без объяснения.
*
* Поэтому защита от дублей смотрит только на ПОСЛЕДНЕЕ сообщение, а «заглушку» держит
* не она, а джоб модерации — см. SyncCampaignModerationJobTest.
*/
it('тот же отказ после доклада разведки клиент увидит снова', function () {
[$tenant, $campaign] = messageCampaign();
$banner = AdCampaignBanner::create([
'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id,
'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true,
'path' => 'ad-banners/a/300x250.jpg',
]);
$service = app(CampaignMessageService::class);
$service->postFromRobot($campaign, $banner->id, 'Нет предупреждения: финансовые услуги');
$service->postFromYandex($campaign, $banner->id, 'Отклонено');
$again = $service->postFromRobot($campaign, $banner->id, 'Нет предупреждения: финансовые услуги');
expect($again)->not->toBeNull()
->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(3);
});
/** Доклад разведки умеет нести снимок экрана — он и есть доказательство прочитанного. */
it('доклад разведки кладётся со снимком экрана', function () {
[$tenant, $campaign] = messageCampaign();
$message = app(CampaignMessageService::class)->postFromRobot($campaign, null, 'Причина', [
'path' => 'ad-messages/1/1/okno.png',
'name' => 'okno.png',
'size' => 1024,
'mime' => 'image/png',
]);
expect($message->file_name)->toBe('okno.png')
->and($message->file_path)->toBe('ad-messages/1/1/okno.png')
->and($message->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX);
});
/** Пустой доклад — не доклад. В ленту не кладём. */
it('пустой доклад разведки в ленту не попадает', function () {
[, $campaign] = messageCampaign();
expect(app(CampaignMessageService::class)->postFromRobot($campaign, null, ' '))->toBeNull()
->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0);
});
/** Признак «Яндекс уже что-то сказал» — на нём держится защита от повторной заглушки. */
it('признак «слова Яндекса уже есть» считается по баннеру, а не по кампании целиком', function () {
[$tenant, $campaign] = messageCampaign();
$slot = ['tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'bytes' => 1000, 'included' => true];
$a = AdCampaignBanner::create($slot + ['width' => 300, 'height' => 250, 'path' => 'ad-banners/a/300x250.jpg']);
$b = AdCampaignBanner::create($slot + ['width' => 728, 'height' => 90, 'path' => 'ad-banners/a/728x90.jpg']);
$service = app(CampaignMessageService::class);
$service->postFromYandex($campaign, $a->id, 'Отклонено');
expect($service->hasFromYandex($campaign, $a->id))->toBeTrue()
->and($service->hasFromYandex($campaign, $b->id))->toBeFalse();
});