This commit is contained in:
2026-01-01 21:10:46 +08:00
parent 5c1b19c08a
commit 6c31ffa120
26 changed files with 1171 additions and 209 deletions

340
tests/BaseTestCase.php Normal file
View File

@@ -0,0 +1,340 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
abstract class BaseTestCase extends TestCase
{
/**
* Тестирует наличие методов в классе
*
* @param array $methods
* @param object|string $class
*/
public function assertHasMethods(array $methods, object|string $class): void
{
foreach ($methods as $method) {
$this->assertTrue(
method_exists(is_object($class) ? $class::class : $class, $method),
"Method {$class}::{$method}() does not exist"
);
}
}
protected function makePlaylist(): void
{
}
protected function makeIni(?string $contents = null): string
{
$contents ??= <<<'EOD'
[foo]
name=foo name
desc=foo description
url=http://example.com/foo.m3u
src=http://example.com/
[bar]
name=bar name
desc=bar description
url=http://example.com/bar.m3u
src=http://example.com/
EOD;
return data_stream($contents, 'text/ini');
}
/*
|--------------------------------------------------------------------------
| Методы для заглушки объектов и методов других классов
|--------------------------------------------------------------------------
*/
/**
* Создаёт и возвращает объект HTTP-запроса для тестирования методов контроллеров
*
* @param array $query The GET parameters
* @param array $request The POST parameters
* @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...)
* @param array $cookies The COOKIE parameters
* @param array $files The FILES parameters
* @param array $server The SERVER parameters
* @param resource|string|null $content The raw body data
*
* @return HttpRequest
*
* @see Request::__construct
*/
protected function makeHttpRequest(
array $query = [],
array $request = [],
array $attributes = [],
array $cookies = [],
array $files = [],
array $server = [],
mixed $content = null
): HttpRequest {
return new HttpRequest(func_get_args());
}
/**
* Возвращает объект ответа HTTP-клиента Laravel
*
* @param int $status
* @param array $headers
* @param $body
* @param string $version
* @param string|null $reason
*
* @return HttpClientResponse
*/
protected function makeHttpResponse(
int $status = 200,
array $headers = [],
$body = null,
string $version = '1.1',
?string $reason = null
): HttpClientResponse {
return new HttpClientResponse(new GuzzleResponse(...func_get_args()));
}
/**
* Вызывает любой метод указанного объекта с нужными аргументами и обходом его видимости
*
* @param class-string|object $objectOrClass
* @param string $method
* @param mixed ...$args
*
* @return mixed
*
* @throws ReflectionException
*
* @see https://stackoverflow.com/questions/249664
* @see \Psy\Sudo::callMethod()
* @see \Psy\Sudo::callStatic()
*/
protected function callMethod(object|string $objectOrClass, string $method, mixed ...$args): mixed
{
$reflObject = is_string($objectOrClass)
? new ReflectionClass($objectOrClass)
: new ReflectionObject($objectOrClass);
$reflMethod = $reflObject->getMethod($method);
return $reflMethod->invokeArgs(is_string($objectOrClass) ? null : $objectOrClass, $args);
}
/*
|--------------------------------------------------------------------------
| Методы-хелперы для подготовки и отладки тестов
|--------------------------------------------------------------------------
*/
/**
* Конвертирует многоуровневый массив в html-файл с таблицей для визуальной
* отладки и сохраняет в `"storage/app/$filename"`.
*
* Использование:
* 0. предполагается во время отладки теста
* 1. вызвать метод, который возвращает массив, приводимый к массиву или читаемый как массив объект
* 2. вызвать `$this->toTable($array, 'test')`
* 3. открыть в браузере файл `storage/app/test.html`
*
* @param array|ArrayAccess $data
* @param string $filename
*/
protected static function toTable(array|ArrayAccess $data, string $filename): void
{
$headers = $result = $html = [];
foreach ($data as $row) {
$result[] = $row = Arr::dot($row);
empty($headers) && $headers = array_keys($row);
}
$html[] = '<html lang="ru"><style>body{margin:0}table{font-family:monospace;border-collapse:collapse}'
. 'thead{background:darkorange;position:sticky;top:-1}th,td{white-space:nowrap;'
. 'border:1px solid black;padding:0 2px}tr:active{font-weight:bold}'
. 'tr:hover{background:lightgrey}</style><body><table><thead>';
foreach ($headers as $header) {
$html[] = "<th>{$header}</th>";
}
$html[] = '</thead><tbody>';
foreach ($result as $row) {
$html[] = '<tr>';
foreach ($row as $value) {
$value instanceof BackedEnum && $value = $value->value;
$value = str_replace("'", '', var_export($value, true)); // строки без кавычек
$html[] = "<td>{$value}</td>";
}
$html[] = '</tr>';
}
$html[] = '</tbody></table></body></html>';
Storage::put("{$filename}.html", implode('', $html));
}
/*
|--------------------------------------------------------------------------
| Методы проверки значений
|--------------------------------------------------------------------------
*/
/**
* Проверяет идентичность двух классов
*
* @param object|string $class1
* @param object|string $class2
*
* @return bool
*/
protected function checkIsSameClass(object|string $class1, object|string $class2): bool
{
return (is_object($class1) ? $class1::class : $class1) === (is_object($class2) ? $class2::class : $class2);
}
/**
* Проверяет наследование других классов указанным
*
* @param string[] $parents Массив имён потенциальных классов-родителей
* @param object|string $class Объект или имя класса для проверки
*
* @see https://www.php.net/manual/en/function.class-parents.php
*/
protected function checkExtendsClasses(array $parents, object|string $class): bool
{
return !empty(array_intersect($parents, is_object($class) ? class_parents($class) : [$class]));
}
/**
* Проверяет реализацию интерфейсов указанным классом
*
* @param string[] $interfaces Массив имён интерфейсов
* @param object|string $class Объект или имя класса для проверки
*
* @see https://www.php.net/manual/en/function.class-parents.php
*/
protected function checkImplementsInterfaces(array $interfaces, object|string $class): bool
{
return !empty(array_intersect($interfaces, is_object($class) ? class_implements($class) : [$class]));
}
/*
|--------------------------------------------------------------------------
| Методы проверки утверждений в тестах
|--------------------------------------------------------------------------
*/
/**
* Утверждает, что в массиве имеются все указанные ключи
*
* @param array $keys Ключи для проверки в массиве
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
*
* @return void
*/
protected function assertArrayHasKeys(array $keys, iterable $array): void
{
$array = iterator_to_array($array);
foreach ($keys as $key) {
$this->assertArrayHasKey($key, $array);
}
}
/**
* Утверждает, что в объекте имеются все указанные свойства
*
* @param array $props Свойства для проверки в объекте
* @param object $object Проверяемый объект
*
* @return void
*/
protected function assertObjectHasProperties(array $props, object $object): void
{
foreach ($props as $prop) {
$this->assertObjectHasProperty($prop, $object);
}
}
/**
* Утверждает, что в массиве отсутствуют все указанные ключи
*
* @param array $keys Ключи для проверки в массиве
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
*
* @return void
*/
protected function assertArrayNotHasKeys(array $keys, iterable $array): void
{
foreach ($keys as $key) {
$this->assertArrayNotHasKey($key, $array);
}
}
/**
* Утверждает, что в объекте отсутствуют все указанные свойства
*
* @param array $props Свойства для проверки в объекте
* @param object $object Проверяемый объект
*
* @return void
*/
protected function assertObjectNotHasProperties(array $props, object $object): void
{
foreach ($props as $prop) {
$this->assertObjectNotHasProperty($prop, $object);
}
}
/**
* Утверждает, что в массиве элементы только указанного типа
*
* @param string $type Название типа, один из возможных результатов функции gettype()
* @param iterable $array Проверяемый массив, итератор или приводимый к массиву объект
*
* @return void
*/
protected function assertArrayValuesTypeOf(string $type, iterable $array): void
{
foreach ($array as $key => $value) {
$this->assertEquals($type, gettype($value), "Failed asserting that element [{$key}] is type of {$type}");
}
}
/**
* Утверждает, что в массив содержит только объекты (опционально -- указанного класса)
*
* Работает гибче {@link self::assertContainsOnlyInstancesOf()}
* засчёт предварительной подготовки проверяемых данных и возможности
* нестрогой проверки имени класса.
*
* @param mixed $array Массив для проверки
* @param object|string|null $class Имя класса (если не указано, проверяется только тип)
*
* @return void
*/
protected function assertIsArrayOfObjects(mixed $array, object|string|null $class = null): void
{
is_object($class) && $class = $class::class;
if (is_string($array) && json_validate($array)) {
$array = json_decode($array);
}
$this->assertNotEmpty($array);
if (empty($class)) {
$filtered = array_filter($array, static fn ($elem) => is_object($elem));
$this->assertSame($array, $filtered, 'Failed asserting that array containts only objects');
} else {
$this->assertContainsOnlyInstancesOf($class, $array);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace Tests\Controllers;
use PHPUnit\Framework\TestCase;
class ApiControllerTest extends TestCase
{
public function testMakeQrCode()
{
}
public function testGetOne()
{
}
public function testHealth()
{
}
public function testStats()
{
}
public function testVersion()
{
}
}

113
tests/Core/IniFileTest.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace Tests\Core;
use App\Core\IniFile;
use App\Exceptions\FileReadException;
use App\Exceptions\IniParsingException;
use App\Exceptions\PlaylistNotFoundException;
use Tests\BaseTestCase;
use Tests\FixtureHandler;
class IniFileTest extends BaseTestCase
{
use FixtureHandler;
/**
* Проверяет успешное создание объекта, чтение и парсинг файла
*
* @return void
* @throws FileReadException
* @throws IniParsingException
*/
public function testMain(): void
{
$ini = $this->makeIni();
$ini = new IniFile($ini);
$this->assertNotNull($ini->updatedAt());
}
/**
* Проверяет исключение при попытке чтения ini-файла по некорректнмоу пути
*
* @return void
* @throws FileReadException
* @throws IniParsingException
*/
public function testFileReadException(): void
{
$this->expectException(FileReadException::class);
$this->expectExceptionMessage('Ошибка чтения файла');
$ini = '';
new IniFile($ini);
}
/**
* Проверяет исключение при попытке парсинга битого ini-файла
*
* @return void
* @throws FileReadException
* @throws IniParsingException
*/
public function testIniParsingException(): void
{
$this->expectException(IniParsingException::class);
$this->expectExceptionMessage('Ошибка разбора файла');
$ini = $this->makeIni('z]');
new IniFile($ini);
}
/**
* Проверяет успешное получение определение плейлиста из ini-файла
*
* @return void
* @throws FileReadException
* @throws IniParsingException
* @throws PlaylistNotFoundException
*/
public function testGetPlaylist(): void
{
$ini = $this->makeIni();
$ini = new IniFile($ini);
$isset = isset($ini['foo']);
$foo = $ini->playlist('foo');
$foo2 = $ini['foo'];
$this->assertTrue($isset);
$this->assertIsArray($foo);
$this->assertSame('foo name', $foo['name']);
$this->assertSame('foo description', $foo['desc']);
$this->assertSame('http://example.com/foo.m3u', $foo['url']);
$this->assertSame('http://example.com/', $foo['src']);
$this->assertSame($foo, $foo2);
}
/**
* Проверяет исключение при попытке парсинга битого ini-файла
*
* @return void
* @throws FileReadException
* @throws PlaylistNotFoundException
* @throws IniParsingException
*/
public function testPlaylistNotFoundException(): void
{
$code = 'test';
$this->expectException(PlaylistNotFoundException::class);
$this->expectExceptionMessage("Плейлист '{$code}' не найден");
$ini = $this->makeIni();
(new IniFile($ini))->playlist($code);
}
}

111
tests/Core/PlaylistTest.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace Tests\Core;
use App\Core\IniFile;
use App\Core\Playlist;
use App\Exceptions\FileReadException;
use App\Exceptions\IniParsingException;
use App\Exceptions\PlaylistNotFoundException;
use App\Exceptions\PlaylistWithoutUrlException;
use Redis;
use Tests\BaseTestCase;
use Tests\FixtureHandler;
class PlaylistTest extends BaseTestCase
{
use FixtureHandler;
/**
* Проверяет успешное создание объекта
*
* @return void
* @throws FileReadException
* @throws IniParsingException
* @throws PlaylistNotFoundException
*/
public function testMain(): void
{
$code = 'foo';
$ini = new IniFile($this->makeIni());
$definition = $ini->playlist($code);
$pls = new Playlist($code, $definition);
$this->assertSame($code, $pls->code);
$this->assertSame($definition['name'], $pls->name);
$this->assertSame($definition['desc'], $pls->desc);
$this->assertSame($definition['url'], $pls->url);
$this->assertSame($definition['src'], $pls->src);
}
/**
* Проверяет успешное создание объекта при отсутствии значений опциональных параметров
*
* @return void
* @throws FileReadException
* @throws IniParsingException
* @throws PlaylistNotFoundException
*/
public function testOptionalParams(): void
{
$code = 'foo';
$ini = new IniFile($this->makeIni());
$definition = $ini->playlist($code);
unset($definition['name']);
unset($definition['desc']);
unset($definition['src']);
$pls = new Playlist($code, $definition);
$this->assertSame($code, $pls->code);
$this->assertNull($pls->name);
$this->assertNull($pls->desc);
$this->assertSame($definition['url'], $pls->url);
$this->assertNull($pls->src);
}
/**
* Проверяет исключение при попытке чтения ini-файла по некорректнмоу пути
*
* @return void
* @throws FileReadException
* @throws IniParsingException
* @throws PlaylistNotFoundException
*/
public function testPlaylistWithoutUrlException(): void
{
$code = 'foo';
$this->expectException(PlaylistWithoutUrlException::class);
$this->expectExceptionMessage("Плейлист '{$code}' имеет неверный url");
$ini = new IniFile($this->makeIni());
$definition = $ini->playlist($code);
unset($definition['url']);
new Playlist($code, $definition);
}
public function testGetCheckResult(): void
{
$code = 'foo';
$ini = new IniFile($this->makeIni());
$definition = $ini->playlist($code);
$pls = new Playlist($code, $definition);
$redis = $this->createPartialMock(Redis::class, ['get']);
$redis->expects($this->once())->method('get')->with($code)->willReturn(null);
$pls->getCheckResult();
}
}

109
tests/FixtureHandler.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace Tests;
use InvalidArgumentException;
trait FixtureHandler
{
/**
* Вычитывает содержимое файла строкой
*
* @param string $filepath
* @return string
* @throws InvalidArgumentException
*/
public function loadFixtureContent(string $filepath): string
{
$filepath = static::buildFixtureFilePath($filepath);
is_file($filepath) || throw new InvalidArgumentException('File not found: ' . $filepath);
return (string) file_get_contents($filepath);
}
/**
* Вычитывает .json файл в php-массив
*
* @param string $filepath
* @param string|null $key
* @return array
* @throws InvalidArgumentException
*/
protected function loadJsonFixture(string $filepath, ?string $key = null): array
{
$contents = $this->loadFixtureContent($filepath);
$contents = json_decode($contents, true);
return $key ? $contents[$key] : $contents;
}
/**
* Подгружает фиксутуру для тестов
*
* @param string $filepath Имя файла или путь до него внутри tests/Fixtures/...
* @return mixed
* @throws InvalidArgumentException
*/
protected function loadPhpFixture(string $filepath): mixed
{
$filepath = static::buildFixtureFilePath($filepath);
is_file($filepath) || throw new InvalidArgumentException('File not found: ' . $filepath);
return require $filepath;
}
/**
* Сохраняет указанные сырые данные в виде файла с данными
* для использования в качестве фикстуры в тестах.
*
* Использование:
* 0. предполагается при подготовке к написанию теста
* 1. вызвать `makeFixture()`, передав нужные данные
* 2. найти файл в `tests/Fixtures/...`, проверить корректность
* 3. подгрузить фикстуру и замокать вызов курсорной БД-функции
* ```
* $fixture = this->loadFixture(...);
* $this->mockDbCursor(...)->andReturn($fixture);
* ```
*
* @param array|Collection $data Данные для сохранения в фикстуре
* @param string $name Имя файла или путь до него внутри tests/Fixtures/...
* @param bool $is_json Сохранить в json-формате
*/
public static function saveFixture(mixed $data, string $name, bool $is_json = false): void
{
$data = match (true) {
$data instanceof Traversable => iterator_to_array($data),
default => $data,
};
if ($is_json) {
$string = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$ext = 'json';
} else {
$string = var_export($data, true);
$string = preg_replace("/(\n\\s+)?array\\s\\(/", '[', $string); // конвертим в короткий синтаксис
$string = str_replace([')', 'NULL'], [']', 'null'], $string); // остатки
$string = "<?php\n\ndeclare(strict_types=1);\n\nreturn {$string};\n"; // добавляем заголовок для файла
$ext = 'php';
}
$filepath = __DIR__ . "/Fixtures/{$name}.{$ext}";
!file_exists($filepath) && @mkdir(dirname($filepath), recursive: true);
$filepath = str_replace('/', DIRECTORY_SEPARATOR, $filepath);
file_put_contents($filepath, $string);
}
protected static function buildFixtureFilePath(string $filepath): string
{
$filepath = trim(ltrim($filepath, DIRECTORY_SEPARATOR));
return __DIR__ . DIRECTORY_SEPARATOR . 'Fixtures' . DIRECTORY_SEPARATOR . $filepath;
}
}

View File

@@ -0,0 +1,5 @@
n]
name=
desc=
url=
src=

View File

@@ -0,0 +1,11 @@
[p1]
name=
desc=
url=
src=
[z2]
name=
desc=
url=
src=