Files
portal/app/tests/Feature/ClientTg/MediaTrebovaniyaMtsTest.php
T
Дмитрий 7922bffb6d
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(телеграм-реклама): заголовок и картинка в авто-режиме + честная проверка медиа по требованиям МТС
Пачка 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>
2026-08-02 13:41:26 +03:00

191 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
});