7922bffb6d
Пачка 1 замечаний владельца о едином виде рекламных экранов. Песочница выключена 02.08.2026 — каждая дыра ниже стоила живых денег. 1. Заголовок объявления в АВТО-правиле. Кабинет МТС требует его для рекламы сайта; утром 02.08 поле довезли до разовой формы, а в авто его не было вовсе — накопитель создавал кампанию без ad_headline, и она сгорела бы в кабинете уже после списания. Колонка client_tg_auto_rule.ad_headline, приём в контроллере (обязателен только при включённом авто и не-телеграмной ссылке), перенос в кампанию, поле на экране. 2. Картинка/видео в авто-правиле. Колонка media_path, отдельная загрузка POST /api/telegram/auto-rule/media, перенос в кампанию, поле на экране. 3. Проверка медиа переписана по настоящим требованиям кабинета, снятым глазами 02.08. Было mimes:png,jpg,jpeg,gif,mp4|max:51200 — врало по пяти пунктам: принимало GIF, пропускало вдвое больший вес, не смотрело пиксели и длительность, зря отказывало в mov/webm. Стало правило App\Rules\ClientTg\MtsMedia: картинка JPEG/PNG до 25 МБ и 640x360...5120x2880, видео до 20 МБ, 3-55 секунд, от 640x360. Формат картинки определяется по содержимому, длительность и кадр видео читает App\Support\Mp4Probe из контейнера — без внешних программ. Отказ человеческий: «Картинка слишком маленькая: 300x200. Нужна не меньше 640x360». 4. Цена показа с медиа — 600 руб. с картинкой, 680 с видео — теперь видна ДО загрузки файла, на обоих экранах. Сверх плана, найдено по дороге: 5. Предохранитель накопителя: правило, сохранённое до 02.08 со ссылкой на сайт и пустым заголовком, всё равно ушло бы в кабинет. Теперь такая пачка держится черновиком, в журнал пишется причина no_headline. 6. Отказ сервера доходит до клиента его словами. Оба экрана глушили ответ общей фразой «Не удалось рассчитать кампанию», и человек не понимал, что не так с файлом. Границы честности: длительность и размер кадра читаются только у mp4/mov/m4v; у webm/mkv/mpeg/wmv проверяются формат и вес — так и записано в Mp4Probe. Проверено: 281 тест телеграм-модуля, 1753 фронтенд-теста, статанализ 0, Pint чист. Глазами НЕ принимали — приёмка живьём в пачке 5. На боевой не выкачено. Журнал схемы: v9.64 — номер взят как максимум по всем веткам плюс один, чтобы не повторить столкновения 29.07 и 01.08. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
11 KiB
PHP
233 lines
11 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\ClientTg\AutoRule;
|
||
use App\Models\ClientTg\Campaign;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Services\ClientTg\TelegramAutoAccumulator;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Support\Facades\Queue;
|
||
use Illuminate\Support\Facades\Storage;
|
||
|
||
/**
|
||
* Заголовок объявления и медиа в АВТО-правиле Telegram-рекламы (замечание З-5).
|
||
*
|
||
* 🔴 Дыра, которую закрываем: 02.08.2026 утром «Заголовок объявления» доставили на
|
||
* разовую форму — кабинет МТС требует его, когда реклама ведёт на САЙТ. В авто-режиме
|
||
* поля не было вовсе: `TelegramAutoAccumulator::openBatch` создавал кампанию без
|
||
* `ad_headline`, а `AutoRuleController` такого поля даже не принимал. Клиент, включивший
|
||
* авто со ссылкой на свой сайт, получил бы провал кампании в кабинете — и теперь на
|
||
* ЖИВЫХ деньгах (песочница выключена 02.08.2026 в 07:40 МСК).
|
||
*
|
||
* Картинки в авто тоже не было — по той же причине «не сделали». Запрета у МТС нет:
|
||
* объявление то же, кабинет тот же.
|
||
*
|
||
* Синтетические номера 7999… и файлы — реальных ПДн нет.
|
||
*/
|
||
uses(RefreshDatabase::class);
|
||
|
||
beforeEach(function () {
|
||
Queue::fake();
|
||
Storage::fake('local');
|
||
$this->withHeaders(['Accept' => 'application/json']);
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
$this->actingAs($this->user);
|
||
config(['client_tg.auto_batch_threshold' => 3]); // в тестах порог занижен
|
||
});
|
||
|
||
/** Тело сохранения правила: по умолчанию — реклама сайта (там заголовок обязателен). */
|
||
function pravilo(array $override = []): array
|
||
{
|
||
return array_merge([
|
||
'enabled' => true,
|
||
'ad_text' => 'Займы под залог за час',
|
||
'ad_link' => 'https://example.com/zaim',
|
||
'ad_headline' => 'Ломбард «Омега»',
|
||
'ord_category' => 'Размещение рекламы',
|
||
'budget_cap_rub' => '2500.00',
|
||
'daily_limit_rub' => '5000.00',
|
||
'batch_threshold' => 400,
|
||
], $override);
|
||
}
|
||
|
||
// ── Заголовок ───────────────────────────────────────────────────────────────
|
||
|
||
it('сохраняет заголовок объявления и отдаёт его обратно', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo())->assertOk();
|
||
|
||
expect(AutoRule::where('tenant_id', $this->tenant->id)->first()->ad_headline)
|
||
->toBe('Ломбард «Омега»');
|
||
|
||
$this->getJson('/api/telegram/auto-rule')
|
||
->assertOk()
|
||
->assertJsonPath('ad_headline', 'Ломбард «Омега»');
|
||
});
|
||
|
||
it('реклама САЙТА без заголовка не сохраняется → 422', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo(['ad_headline' => '']))
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors('ad_headline');
|
||
|
||
expect(AutoRule::where('tenant_id', $this->tenant->id)->count())->toBe(0);
|
||
});
|
||
|
||
it('реклама телеграм-канала без заголовка сохраняется — там его кабинет не спрашивает', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo([
|
||
'ad_link' => 'https://t.me/example_channel',
|
||
'ad_headline' => '',
|
||
]))->assertOk();
|
||
|
||
expect(AutoRule::where('tenant_id', $this->tenant->id)->first()->ad_headline)->toBeNull();
|
||
});
|
||
|
||
it('выключенное авто без заголовка сохраняется — правило ещё не работает', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo(['enabled' => false, 'ad_headline' => '']))
|
||
->assertOk();
|
||
});
|
||
|
||
it('заголовок длиннее 40 знаков не проходит — столько же, сколько в кабинете', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo(['ad_headline' => str_repeat('я', 41)]))
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors('ad_headline');
|
||
});
|
||
|
||
it('накопитель кладёт заголовок из правила в созданную авто-кампанию', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo())->assertOk();
|
||
|
||
app(TelegramAutoAccumulator::class)->accumulate($this->tenant->id, '79990000001');
|
||
|
||
$campaign = Campaign::where('tenant_id', $this->tenant->id)->firstOrFail();
|
||
expect($campaign->ad_headline)->toBe('Ломбард «Омега»');
|
||
});
|
||
|
||
// ── Картинка / видео ────────────────────────────────────────────────────────
|
||
|
||
it('прикладывает картинку к авто-правилу: файл сохранён, путь записан', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo())->assertOk();
|
||
|
||
$res = $this->post('/api/telegram/auto-rule/media', [
|
||
'media' => UploadedFile::fake()->image('ad.jpg', 1280, 720),
|
||
]);
|
||
|
||
$res->assertOk();
|
||
$path = AutoRule::where('tenant_id', $this->tenant->id)->first()->media_path;
|
||
expect($path)->not->toBeNull();
|
||
Storage::disk('local')->assertExists($path);
|
||
expect($res->json('media_path'))->toBe($path);
|
||
});
|
||
|
||
it('картинка авто-правила проверяется теми же требованиями МТС', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo())->assertOk();
|
||
|
||
$res = $this->post('/api/telegram/auto-rule/media', [
|
||
'media' => UploadedFile::fake()->image('ad.jpg', 300, 200),
|
||
]);
|
||
|
||
$res->assertStatus(422);
|
||
expect((string) $res->json('errors.media.0'))->toContain('640');
|
||
});
|
||
|
||
it('замена картинки авто-правила удаляет прежний файл', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo())->assertOk();
|
||
|
||
$this->post('/api/telegram/auto-rule/media', [
|
||
'media' => UploadedFile::fake()->image('first.jpg', 1280, 720),
|
||
])->assertOk();
|
||
$first = AutoRule::where('tenant_id', $this->tenant->id)->first()->media_path;
|
||
|
||
$this->post('/api/telegram/auto-rule/media', [
|
||
'media' => UploadedFile::fake()->image('second.jpg', 1280, 720),
|
||
])->assertOk();
|
||
$second = AutoRule::where('tenant_id', $this->tenant->id)->first()->media_path;
|
||
|
||
expect($second)->not->toBe($first);
|
||
Storage::disk('local')->assertMissing($first);
|
||
Storage::disk('local')->assertExists($second);
|
||
});
|
||
|
||
it('накопитель кладёт картинку из правила в созданную авто-кампанию', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo())->assertOk();
|
||
$this->post('/api/telegram/auto-rule/media', [
|
||
'media' => UploadedFile::fake()->image('ad.jpg', 1280, 720),
|
||
])->assertOk();
|
||
$rulePath = AutoRule::where('tenant_id', $this->tenant->id)->first()->media_path;
|
||
|
||
app(TelegramAutoAccumulator::class)->accumulate($this->tenant->id, '79990000001');
|
||
|
||
expect(Campaign::where('tenant_id', $this->tenant->id)->firstOrFail()->media_path)
|
||
->toBe($rulePath);
|
||
});
|
||
|
||
it('сохранение правила НЕ стирает уже приложенную картинку', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo())->assertOk();
|
||
$this->post('/api/telegram/auto-rule/media', [
|
||
'media' => UploadedFile::fake()->image('ad.jpg', 1280, 720),
|
||
])->assertOk();
|
||
|
||
$this->putJson('/api/telegram/auto-rule', pravilo(['ad_text' => 'Другой текст']))->assertOk();
|
||
|
||
expect(AutoRule::where('tenant_id', $this->tenant->id)->first()->media_path)->not->toBeNull();
|
||
});
|
||
|
||
// ── Предохранитель накопителя ───────────────────────────────────────────────
|
||
|
||
it('старое правило: реклама САЙТА без заголовка — пачку не запускаем, деньги целы', function () {
|
||
// Такое правило нельзя сохранить через кабинет (валидация не пустит), но оно могло
|
||
// остаться от прежних версий — до 02.08.2026 поля заголовка не существовало вовсе.
|
||
// Запусти мы его, кабинет МТС встал бы на шаге «Объявление» уже ПОСЛЕ списания.
|
||
AutoRule::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'enabled' => true,
|
||
'ad_text' => 'Займы под залог',
|
||
'ad_link' => 'https://example.com/zaim',
|
||
'ad_headline' => null,
|
||
'ord_category' => 'Размещение рекламы',
|
||
'budget_cap_rub' => '2500.00',
|
||
'daily_limit_rub' => '5000.00',
|
||
]);
|
||
|
||
foreach (['79990000001', '79990000002', '79990000003', '79990000004'] as $phone) {
|
||
app(TelegramAutoAccumulator::class)->accumulate($this->tenant->id, $phone);
|
||
}
|
||
|
||
$campaign = Campaign::where('tenant_id', $this->tenant->id)->firstOrFail();
|
||
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT);
|
||
Queue::assertNothingPushed();
|
||
});
|
||
|
||
it('то же правило со ссылкой на канал — запускается: там заголовок не нужен', function () {
|
||
AutoRule::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'enabled' => true,
|
||
'ad_text' => 'Заходите в канал',
|
||
'ad_link' => 'https://t.me/example_channel',
|
||
'ad_headline' => null,
|
||
'ord_category' => 'Размещение рекламы',
|
||
'budget_cap_rub' => '2500.00',
|
||
'daily_limit_rub' => '5000.00',
|
||
]);
|
||
|
||
foreach (['79990000001', '79990000002', '79990000003', '79990000004'] as $phone) {
|
||
app(TelegramAutoAccumulator::class)->accumulate($this->tenant->id, $phone);
|
||
}
|
||
|
||
expect(Campaign::where('tenant_id', $this->tenant->id)->firstOrFail()->status)
|
||
->toBe(Campaign::STATUS_QUEUED);
|
||
});
|
||
|
||
it('изоляция тенантов: каждый видит только своё правило', function () {
|
||
$this->putJson('/api/telegram/auto-rule', pravilo(['ad_headline' => 'Наш заголовок']))->assertOk();
|
||
|
||
$tenantB = Tenant::factory()->create();
|
||
$userB = User::factory()->create(['tenant_id' => $tenantB->id]);
|
||
|
||
$this->actingAs($userB)
|
||
->getJson('/api/telegram/auto-rule')
|
||
->assertOk()
|
||
->assertJsonPath('ad_headline', '');
|
||
});
|