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>
191 lines
8.9 KiB
PHP
191 lines
8.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\ClientTg\Campaign;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Support\Facades\Storage;
|
||
|
||
/**
|
||
* Проверка медиа объявления по НАСТОЯЩИМ требованиям кабинета МТС (замечание З-7,
|
||
* требования сняты глазами 02.08.2026 — docs/superpowers/2026-08-02-ZAMECHANIYA-*).
|
||
*
|
||
* Прежняя проверка `mimes:png,jpg,jpeg,gif,mp4|max:51200` врала по пяти пунктам:
|
||
* принимала GIF (кабинет не берёт), пропускала 50 МБ вместо 25/20, не смотрела
|
||
* пиксели и длительность, зря отказывала в mov/webm. Дыра стоит денег: песочница
|
||
* выключена 02.08, робот идёт в кабинет уже ПОСЛЕ списания с баланса клиента.
|
||
*
|
||
* Требования: картинка — JPEG/PNG, ≤25 МБ, 640×360…5120×2880; видео — mp4/webm/
|
||
* mov/mkv/mpeg/m4v/wmv, ≤20 МБ, 3…55 секунд, сторона от 640×360.
|
||
*
|
||
* Синтетические файлы и номера — реальных ПДн нет.
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
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);
|
||
$this->campaign = Campaign::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'status' => Campaign::STATUS_DRAFT,
|
||
'ad_text' => 'Приходите к нам',
|
||
'ad_link' => 'https://t.me/example',
|
||
'ord_category' => 'Размещение рекламы',
|
||
'budget_cap_rub' => '1000.00',
|
||
'audience_kind' => Campaign::AUDIENCE_BASE,
|
||
'planned_count' => 0,
|
||
'estimated_cost_rub' => '0.00',
|
||
'created_by' => $this->user->id,
|
||
]);
|
||
});
|
||
|
||
/** Один бокс контейнера ISO-BMFF: длина (4 байта) + тип (4 байта) + содержимое. */
|
||
function mp4Box(string $type, string $payload): string
|
||
{
|
||
return pack('N', 8 + strlen($payload)).$type.$payload;
|
||
}
|
||
|
||
/**
|
||
* Синтетический mp4 с настоящими заголовками mvhd (длительность) и tkhd (размер).
|
||
* Пикселей внутри нет — нам нужны ровно те поля, которые читает наш пробник.
|
||
*/
|
||
function fakeMp4Bytes(float $seconds, int $width, int $height): string
|
||
{
|
||
$timescale = 1000;
|
||
$duration = (int) round($seconds * $timescale);
|
||
|
||
$mvhd = "\x00\x00\x00\x00" // version 0 + flags
|
||
.pack('N', 0).pack('N', 0) // создан / изменён
|
||
.pack('N', $timescale).pack('N', $duration) // масштаб времени и длительность
|
||
.pack('N', 0x00010000) // rate
|
||
.pack('n', 0x0100) // volume
|
||
.str_repeat("\x00", 2 + 8 + 36 + 24) // reserved + matrix + pre_defined
|
||
.pack('N', 2); // next_track_ID
|
||
|
||
$tkhd = "\x00\x00\x00\x07" // version 0 + флаги «дорожка включена»
|
||
.pack('N', 0).pack('N', 0) // создан / изменён
|
||
.pack('N', 1) // track_ID
|
||
.pack('N', 0) // reserved
|
||
.pack('N', $duration) // длительность дорожки
|
||
.str_repeat("\x00", 8) // reserved
|
||
.pack('n', 0).pack('n', 0) // layer, alternate_group
|
||
.pack('n', 0).pack('n', 0) // volume, reserved
|
||
.str_repeat("\x00", 36) // matrix
|
||
.pack('N', $width << 16).pack('N', $height << 16); // ширина/высота 16.16
|
||
|
||
return mp4Box('ftyp', 'isom'.pack('N', 512).'isomiso2mp41')
|
||
.mp4Box('moov', mp4Box('mvhd', $mvhd).mp4Box('trak', mp4Box('tkhd', $tkhd)));
|
||
}
|
||
|
||
/** Отправить файл на приложение медиа к черновику. */
|
||
function postMedia(mixed $test, UploadedFile $file)
|
||
{
|
||
return $test->post("/api/telegram/campaigns/{$test->campaign->id}/media", ['media' => $file]);
|
||
}
|
||
|
||
/** Текст отказа по полю media — его читает клиент. */
|
||
function otkazMedia(mixed $res): string
|
||
{
|
||
return (string) ($res->json('errors.media.0') ?? '');
|
||
}
|
||
|
||
// ── Картинка ────────────────────────────────────────────────────────────────
|
||
|
||
it('принимает JPEG ровно на нижней границе 640×360', function () {
|
||
postMedia($this, UploadedFile::fake()->image('ad.jpg', 640, 360))->assertOk();
|
||
|
||
expect(Campaign::find($this->campaign->id)->media_path)->not->toBeNull();
|
||
});
|
||
|
||
it('принимает PNG внутри допустимых размеров', function () {
|
||
postMedia($this, UploadedFile::fake()->image('ad.png', 1280, 720))->assertOk();
|
||
});
|
||
|
||
it('отклоняет GIF: кабинет МТС берёт только JPEG и PNG', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->image('ad.gif', 1280, 720));
|
||
|
||
$res->assertStatus(422)->assertJsonValidationErrors('media');
|
||
expect(otkazMedia($res))->toContain('JPEG')->toContain('PNG');
|
||
expect(Campaign::find($this->campaign->id)->media_path)->toBeNull();
|
||
});
|
||
|
||
it('отклоняет слишком маленькую картинку и называет её размер', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->image('ad.jpg', 300, 200));
|
||
|
||
$res->assertStatus(422);
|
||
expect(otkazMedia($res))->toContain('300')->toContain('200')->toContain('640');
|
||
});
|
||
|
||
it('отклоняет слишком большую картинку', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->image('ad.jpg', 6000, 3000));
|
||
|
||
$res->assertStatus(422);
|
||
expect(otkazMedia($res))->toContain('5120');
|
||
});
|
||
|
||
it('отклоняет картинку тяжелее 25 МБ', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->image('ad.jpg', 1280, 720)->size(30 * 1024));
|
||
|
||
$res->assertStatus(422);
|
||
expect(otkazMedia($res))->toContain('25 МБ');
|
||
});
|
||
|
||
// ── Видео ───────────────────────────────────────────────────────────────────
|
||
|
||
it('принимает mp4 длиной 10 секунд и размером 1280×720', function () {
|
||
postMedia($this, UploadedFile::fake()->createWithContent('ad.mp4', fakeMp4Bytes(10, 1280, 720)))
|
||
->assertOk();
|
||
});
|
||
|
||
it('принимает mov и webm — раньше мы зря им отказывали', function () {
|
||
postMedia($this, UploadedFile::fake()->createWithContent('ad.mov', fakeMp4Bytes(10, 1280, 720)))
|
||
->assertOk();
|
||
|
||
postMedia($this, UploadedFile::fake()->create('ad.webm', 500, 'video/webm'))
|
||
->assertOk();
|
||
});
|
||
|
||
it('отклоняет видео длиннее 55 секунд', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->createWithContent('ad.mp4', fakeMp4Bytes(70, 1280, 720)));
|
||
|
||
$res->assertStatus(422);
|
||
expect(otkazMedia($res))->toContain('55');
|
||
});
|
||
|
||
it('отклоняет видео короче 3 секунд', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->createWithContent('ad.mp4', fakeMp4Bytes(1.5, 1280, 720)));
|
||
|
||
$res->assertStatus(422);
|
||
expect(otkazMedia($res))->toContain('3');
|
||
});
|
||
|
||
it('отклоняет видео мельче 640×360', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->createWithContent('ad.mp4', fakeMp4Bytes(10, 320, 240)));
|
||
|
||
$res->assertStatus(422);
|
||
expect(otkazMedia($res))->toContain('640');
|
||
});
|
||
|
||
it('отклоняет видео тяжелее 20 МБ', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->create('ad.mp4', 25 * 1024, 'video/mp4'));
|
||
|
||
$res->assertStatus(422);
|
||
expect(otkazMedia($res))->toContain('20 МБ');
|
||
});
|
||
|
||
// ── Прочее ──────────────────────────────────────────────────────────────────
|
||
|
||
it('отклоняет посторонний файл человеческим текстом, без слова mimes', function () {
|
||
$res = postMedia($this, UploadedFile::fake()->create('virus.exe', 10, 'application/octet-stream'));
|
||
|
||
$res->assertStatus(422);
|
||
expect(otkazMedia($res))->toContain('JPEG');
|
||
expect(strtolower(otkazMedia($res)))->not->toContain('mimes');
|
||
});
|