0ba7890bf9
Задача 15. Программный интерфейс Яндекса причину отказа не отдаёт — на отклонённое объявление приходит «Отклонено на модерации.» и всё. Причина висит только на экране кабинета, и добыть её может лишь тот, у кого есть глаза. Как теперь работает: опрос модерации видит отказ и ставит роботу задание разведки по этому объявлению; робот открывает список объявлений, находит ячейку своего объявления, кликает по статусу, раскрывает строку причины, читает текст и снимает одно окно; доклад уезжает порталу формой вместе со снимком; портал кладёт его в ленту от имени Яндекса слово в слово, клиенту письмо и колокольчик. Робот не понял, что видит — задание сбойное, владельцу письмо, в ленту клиенту ничего не сочиняем. В админке появилась ручка «ждёт разбора». Четыре ловушки, пойманные по дороге и проверенные вырезанием: 1. Дедуп разведки нельзя вешать на кампанию. Отказ никуда не девается, а обход бежит по расписанию: после закрытия первой разведки поставилась бы вторая, и робот ходил бы в кабинет по кругу. Ключ — номер объявления, журнал схемы v9.16. 2. Рубильник держал не выдачу задания, а построение клиента Директа. Разведке слепок креативов не нужен, значит при выключенном рубильнике она получила бы задание, и робот пошёл бы в живой кабинет. 3. Дедуп ленты сравнивал только с последним сообщением Яндекса. После доклада робота обход снова клал бы «причину выясняем» поверх настоящей причины. 4. Постановка разведки шла без tenant-контекста — на бою она не сработала бы ВООБЩЕ и молча: поиск дубля давал бы ноль, запись падала бы на политике доступа, всё это в предупреждение журнала при зелёных тестах. Поймал rls-reviewer. Лечение — своя транзакция с выставлением клиента, рецепт ChargeCampaignSpendJob. Сторож поставлен на сам механизм: обычным тестом это не ловится, они ходят суперпользователем. Заодно: разведке больше не снимается слепок креативов — лишний поход в живой Яндекс внутри открытой транзакции. Портал 382/382, робот 75/75, мест снятия заморозки денег по-прежнему четыре. На боевой не выкатывалось, рубильник Директа выключен. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
6.7 KiB
PHP
133 lines
6.7 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);
|
|
});
|
|
|
|
/**
|
|
* 🪤 Ловушка, которую открыла разведка. Дедуп сравнивал текст только с ПОСЛЕДНИМ сообщением
|
|
* Яндекса. Порядок в жизни такой: обход модерации кладёт «причину выясняем» → робот приносит
|
|
* настоящую причину → следующий обход снова кладёт «причину выясняем», потому что последним
|
|
* теперь лежит доклад робота. Клиент, уже прочитавший настоящую причину, получал бы поверх
|
|
* неё письмо «Яндекс отклонил рекламу, но причину не назвал».
|
|
*/
|
|
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, 'Нет предупреждения: финансовые услуги');
|
|
$again = $service->postFromYandex($campaign, $banner->id, 'Причину выясняем');
|
|
|
|
expect($again)->toBeNull()
|
|
->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(2);
|
|
});
|