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

2
.gitignore vendored
View File

@@ -8,7 +8,7 @@
.env .env
.env.* .env.*
!.env.example !.env.example
playlists.ini config/playlists.ini
channels.json channels.json
.phpunit.result.cache .phpunit.result.cache
.php-cs-fixer.cache .php-cs-fixer.cache

View File

@@ -12,7 +12,7 @@ namespace App\Controllers;
use App\Core\Bot; use App\Core\Bot;
use App\Core\Kernel; use App\Core\Kernel;
use App\Core\StatisticsService; use App\Core\StatisticsService;
use App\Errors\PlaylistNotFoundException; use App\Exceptions\PlaylistNotFoundException;
use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions; use chillerlan\QRCode\QROptions;
use Exception; use Exception;
@@ -38,7 +38,7 @@ class ApiController extends BasicController
$code = $request->getAttributes()['code'] ?? null; $code = $request->getAttributes()['code'] ?? null;
empty($code) && throw new PlaylistNotFoundException(''); empty($code) && throw new PlaylistNotFoundException('');
$playlist = ini()->getPlaylist($code); $playlist = ini()->playlist($code);
if ($playlist['isOnline'] === true) { if ($playlist['isOnline'] === true) {
unset($playlist['content']); unset($playlist['content']);
} }

View File

@@ -9,7 +9,7 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use App\Errors\PlaylistNotFoundException; use App\Exceptions\PlaylistNotFoundException;
use Exception; use Exception;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
@@ -36,7 +36,7 @@ class WebController extends BasicController
*/ */
public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{ {
$ini = ini()->load(); $ini = ini();
$keys = []; $keys = [];
$count = count($ini); $count = count($ini);
$pageSize = config('app.page_size'); $pageSize = config('app.page_size');
@@ -45,11 +45,11 @@ class WebController extends BasicController
$pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1); $pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1);
$pageCount = ceil($count / $pageSize); $pageCount = ceil($count / $pageSize);
$offset = max(0, ($pageCurrent - 1) * $pageSize); $offset = max(0, ($pageCurrent - 1) * $pageSize);
$ini = array_slice($ini, $offset, $pageSize, true); $ini = array_slice($ini->get, $offset, $pageSize, true);
$keys = array_keys($ini); $keys = array_keys($ini);
} }
$playlists = ini()->getPlaylists($keys); $playlists = ini()->playlists($keys);
return $this->view($request, $response, 'list.twig', [ return $this->view($request, $response, 'list.twig', [
'updatedAt' => ini()->updatedAt(), 'updatedAt' => ini()->updatedAt(),
@@ -75,7 +75,7 @@ class WebController extends BasicController
$code = $request->getAttributes()['code']; $code = $request->getAttributes()['code'];
try { try {
$playlist = ini()->getPlaylist($code); $playlist = ini()->playlist($code);
return $response->withHeader('Location', $playlist['url']); return $response->withHeader('Location', $playlist['url']);
} catch (Throwable) { } catch (Throwable) {
return $this->notFound($request, $response); return $this->notFound($request, $response);
@@ -98,7 +98,7 @@ class WebController extends BasicController
$code = $request->getAttributes()['code']; $code = $request->getAttributes()['code'];
try { try {
$playlist = ini()->getPlaylist($code); $playlist = ini()->playlist($code);
return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]); return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]);
} catch (PlaylistNotFoundException) { } catch (PlaylistNotFoundException) {
return $this->notFound($request, $response); return $this->notFound($request, $response);

View File

@@ -9,8 +9,8 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
use App\Errors\InvalidTelegramSecretException; use App\Exceptions\InvalidTelegramSecretException;
use App\Errors\PlaylistNotFoundException; use App\Exceptions\PlaylistNotFoundException;
use DateTimeImmutable; use DateTimeImmutable;
use Exception; use Exception;
use JsonException; use JsonException;
@@ -168,7 +168,7 @@ class Bot
} }
try { try {
$pls = ini()->getPlaylist($code); $pls = ini()->playlist($code);
} catch (PlaylistNotFoundException) { } catch (PlaylistNotFoundException) {
return $this->reply("Плейлист `$code` не найден"); return $this->reply("Плейлист `$code` не найден");
} }

View File

@@ -1,4 +1,5 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project * This file is part of m3u.su project
@@ -9,70 +10,71 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
use App\Errors\PlaylistNotFoundException; use App\Exceptions\FileReadException;
use Exception; use App\Exceptions\IniParsingException;
use App\Exceptions\PlaylistNotFoundException;
use ArrayAccess;
use Override;
use Throwable;
/** /**
* Класс для работы со списком плейлистов * Класс для работы со списком плейлистов
*
* @phpstan-type TIniFile array{}|TPlaylistDefinition[]
* @phpstan-type TPlaylistDefinition array{name?: string, desc?: string, url: string, src?: string}
* @template TKey as non-falsy-string
* @template TValue as TPlaylistDefinition
* @implements ArrayAccess<TKey, TValue>
*/ */
class IniFile class IniFile implements ArrayAccess
{ {
/** /**
* @var array[] Коллекция подгруженных плейлистов * @var array{}|array<TKey, TValue> Коллекция подгруженных плейлистов
*/ */
protected array $playlists; protected array $playlists;
/** /**
* @var string Дата последнего обновления списка * @var positive-int Дата последнего обновления списка
*/ */
protected string $updatedAt; protected int $updatedAt;
/** /**
* Считывает ini-файл и инициализирует плейлисты * Загружает ini-файл и инициализирует плейлисты
* *
* @return array * @param string $filepath
* @throws FileReadException
* @throws IniParsingException
*/ */
public function load(): array public function __construct(
{ protected string $filepath,
$filepath = config_path('playlists.ini'); ) {
$this->playlists = parse_ini_file($filepath, true); try {
$this->updatedAt = date('d.m.Y h:i', filemtime($filepath)); $content = file_get_contents($this->filepath);
} catch (Throwable) {
return $this->playlists; $content = false;
}
/**
* Возвращает плейлисты
*
* @return array[]
* @throws Exception
*/
public function getPlaylists(array $plsCodes = []): array
{
$playlists = [];
empty($this->playlists) && $this->load();
empty($plsCodes) && $plsCodes = array_keys($this->playlists);
$cached = array_combine($plsCodes, redis()->mget($plsCodes));
foreach ($cached as $code => $data) {
$playlists[$code] = $this->initPlaylist($code, $data);
} }
return $playlists; $content === false && throw new FileReadException($this->filepath);
$parsed = parse_ini_string($content, true);
$parsed === false && throw new IniParsingException($this->filepath);
$this->playlists = $parsed;
/** @var positive-int $timestamp */
$timestamp = is_readable($this->filepath) ? filemtime($this->filepath) : time();
$this->updatedAt = $timestamp;
} }
/** /**
* Возвращает плейлист по его коду * Возвращает определение плейлиста по его коду
* *
* @param string $code Код плейлиста * @param TKey $code
* @return array|null * @return TValue
* @throws PlaylistNotFoundException * @throws PlaylistNotFoundException
* @throws Exception
*/ */
public function getPlaylist(string $code): ?array public function playlist(string $code): array
{ {
empty($this->playlists) && $this->load(); return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code);
$data = redis()->get($code);
return $this->initPlaylist($code, $data);
} }
/** /**
@@ -82,107 +84,50 @@ class IniFile
*/ */
public function updatedAt(): string public function updatedAt(): string
{ {
return $this->updatedAt; return date('d.m.Y h:i', $this->updatedAt);
} }
/** /**
* Подготавливает данные о плейлисте в расширенном формате * @inheritDoc
* * @param non-falsy-string $offset
* @param string $code
* @param array|false $data
* @return array
* @throws PlaylistNotFoundException
*/
protected function initPlaylist(string $code, array|false $data): array
{
if ($data === false) {
$raw = $this->playlists[$code]
?? throw new PlaylistNotFoundException($code);
$data = [
'code' => $code,
'name' => $raw['name'] ?? "Плейлист #$code",
'description' => $raw['desc'] ?? null,
'url' => $raw['pls'],
'source' => $raw['src'] ?? null,
'content' => null,
'isOnline' => null,
'attributes' => [],
'groups' => [],
'channels' => [],
'checkedAt' => null,
];
}
// приколы golang
$data['attributes'] === null && $data['attributes'] = [];
$data['groups'] === null && $data['groups'] = [];
$data['channels'] === null && $data['channels'] = [];
$data['onlinePercent'] = 0;
$data['offlinePercent'] = 0;
if ($data['isOnline'] === true && count($data['channels']) > 0) {
$data['onlinePercent'] = round($data['onlineCount'] / count($data['channels']) * 100);
$data['offlinePercent'] = round($data['offlineCount'] / count($data['channels']) * 100);
}
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
$data['hasTvg'] = !empty($data['attributes']['url-tvg']) || !empty($data['attributes']['x-tvg-url']);
$data['hasTokens'] = $this->hasTokens($data);
$data['tags'] = [];
foreach ($data['channels'] as &$channel) {
$data['tags'] = array_merge($data['tags'], $channel['tags']);
$channel['hasToken'] = $this->hasTokens($channel);
}
$data['tags'] = array_values(array_unique($data['tags']));
sort($data['tags']);
return $data;
}
/**
* Проверяет наличие токенов в плейлисте
*
* Сделано именно так, а не через тег unstable, чтобы разделить логику: есть заведомо нестабильные каналы,
* которые могут не транслироваться круглосуточно, а есть платные круглосуточные, которые могут оборваться
* в любой момент.
*
* @param array $data
* @return bool * @return bool
*/ */
protected function hasTokens(array $data): bool #[Override]
public function offsetExists(mixed $offset): bool
{ {
$string = ($data['url'] ?? '') . ($data['content'] ?? ''); return isset($this->playlists[$offset]);
if (empty($string)) { }
return false;
}
$badAttributes = [ /**
// токены и ключи * @inheritDoc
'[?&]token=', * @param TKey $offset
'[?&]drmreq=', * @return TPlaylistDefinition
// логины * @throws PlaylistNotFoundException
'[?&]u=', */
'[?&]user=', #[Override]
'[?&]username=', public function offsetGet(mixed $offset): array
// пароли {
'[?&]p=', return $this->playlist($offset);
'[?&]pwd=', }
'[?&]password=',
// неизвестные
// 'free=true',
// 'uid=',
// 'c_uniq_tag=',
// 'rlkey=',
// '?s=',
// '&s=',
// '?q=',
// '&q=',
];
return array_any( /**
$badAttributes, * @inheritDoc
static fn (string $badAttribute) => preg_match_all("/$badAttribute/", $string) >= 1, * @param TKey $offset
); * @param TValue $value
* @return void
*/
#[Override]
public function offsetSet(mixed $offset, mixed $value): void
{
}
/**
* @inheritDoc
* @param non-falsy-string $offset
* @return void
*/
#[Override]
public function offsetUnset(mixed $offset): void
{
} }
} }

View File

@@ -222,6 +222,6 @@ final class Kernel
*/ */
public function ini(): IniFile public function ini(): IniFile
{ {
return $this->iniFile ??= new IniFile(); return $this->iniFile ??= new IniFile(config_path('playlists.ini'));
} }
} }

216
app/Core/Playlist.php Normal file
View File

@@ -0,0 +1,216 @@
<?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 App\Core;
use App\Exceptions\PlaylistNotFoundException;
use App\Exceptions\PlaylistWithoutUrlException;
/**
* @phpstan-import-type TPlaylistDefinition from IniFile
*/
class Playlist
{
/**
* @var non-falsy-string
*/
public readonly string $code;
/**
* @var non-falsy-string|null
*/
public readonly ?string $desc;
/**
* @var non-falsy-string|null
*/
public readonly ?string $name;
/**
* @var non-falsy-string
*/
public readonly string $url;
/**
* @var non-falsy-string|null
*/
public readonly ?string $src;
protected string $text;
/**
* @param non-falsy-string $code
* @param TPlaylistDefinition $params
*/
public function __construct(string $code, array $params)
{
$this->code = $code;
$this->name = isset($params['name']) ? trim($params['name']) : null;
$this->desc = isset($params['desc']) ? trim($params['desc']) : null;
$this->url = isset($params['url']) ? trim($params['url']) : throw new PlaylistWithoutUrlException($code);
$this->src = isset($params['src']) ? trim($params['src']) : null;
}
public function getCheckResult(\Redis $redis)
{
$this->text = $redis->get($this->code);
$stop = 1;
}
private array $definition;
private array $cached;
/**
* Возвращает плейлисты
*
* @return array[]
* @throws Exception
*/
// public function getCachedPlaylists(array $plsCodes = []): array
// {
// $playlists = [];
// empty($plsCodes) && $plsCodes = array_keys($this->playlists);
// $cached = array_combine($plsCodes, redis()->mget($plsCodes));
// foreach ($cached as $code => $data) {
// $playlists[$code] = $this->initPlaylist($code, $data);
// }
//
// return $playlists;
// }
/**
* Возвращает плейлист по его коду
*
* @param string $code Код плейлиста
* @return array|null
* @throws PlaylistNotFoundException
* @throws Exception
*/
// public function getCachedPlaylist(string $code): ?array
// {
// $data = redis()->get($code);
//
// return $this->initPlaylist($code, $data);
// }
/**
* Подготавливает данные о плейлисте в расширенном формате
*
* @param string $code
* @param array|false $data
* @return array
* @throws PlaylistNotFoundException
*/
protected function initPlaylist(string $code, array|false $data): array
{
if ($data === false) {
$raw = $this->playlists[$code]
?? throw new PlaylistNotFoundException($code);
$data = [
'code' => $code,
'name' => $raw['name'] ?? "Плейлист #{$code}",
'description' => $raw['desc'] ?? null,
'url' => $raw['url'],
'source' => $raw['src'] ?? null,
'content' => null,
'isOnline' => null,
'attributes' => [],
'groups' => [],
'channels' => [],
'checkedAt' => null,
];
}
// приколы golang
$data['attributes'] === null && $data['attributes'] = [];
$data['groups'] === null && $data['groups'] = [];
$data['channels'] === null && $data['channels'] = [];
$data['onlinePercent'] = 0;
$data['offlinePercent'] = 0;
if ($data['isOnline'] === true && count($data['channels']) > 0) {
$data['onlinePercent'] = round($data['onlineCount'] / count($data['channels']) * 100);
$data['offlinePercent'] = round($data['offlineCount'] / count($data['channels']) * 100);
}
$data['hasCatchup'] = str_contains($data['content'] ?? '', 'catchup');
$data['hasTvg'] = !empty($data['attributes']['url-tvg']) || !empty($data['attributes']['x-tvg-url']);
$data['hasTokens'] = $this->hasTokens($data);
$data['tags'] = [];
foreach ($data['channels'] as &$channel) {
$data['tags'] = array_merge($data['tags'], $channel['tags']);
$channel['hasToken'] = $this->hasTokens($channel);
}
$data['tags'] = array_values(array_unique($data['tags']));
sort($data['tags']);
return $data;
}
/**
* Проверяет наличие токенов в плейлисте
*
* Сделано именно так, а не через тег unstable, чтобы разделить логику: есть заведомо нестабильные каналы,
* которые могут не транслироваться круглосуточно, а есть платные круглосуточные, которые могут оборваться
* в любой момент.
*
* @param array $data
* @return bool
*/
protected function hasTokens(array $data): bool
{
$string = ($data['url'] ?? '') . ($data['content'] ?? '');
if (empty($string)) {
return false;
}
$badAttributes = [
// токены и ключи
'[?&]token=',
'[?&]drmreq=',
// логины
'[?&]u=',
'[?&]user=',
'[?&]username=',
// пароли
'[?&]p=',
'[?&]pwd=',
'[?&]password=',
// неизвестные
// 'free=true',
// 'uid=',
// 'c_uniq_tag=',
// 'rlkey=',
// '?s=',
// '&s=',
// '?q=',
// '&q=',
];
return array_any(
$badAttributes,
static fn (string $badAttribute) => preg_match_all("/{$badAttribute}/", $string) >= 1,
);
}
}

View File

@@ -7,7 +7,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Errors; namespace App\Exceptions;
use Psr\Http\Message\{ use Psr\Http\Message\{
ResponseInterface, ResponseInterface,
@@ -19,7 +19,7 @@ use Throwable;
/** /**
* Обработчик ошибок * Обработчик ошибок
*/ */
class ErrorHandler extends SlimErrorHandler class ExceptionHandler extends SlimErrorHandler
{ {
/** /**
* Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым * Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым

View File

@@ -0,0 +1,20 @@
<?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 App\Exceptions;
use Exception;
class FileNotFoundException extends Exception
{
public function __construct(string $filepath)
{
parent::__construct('Файл не найден: ' . $filepath);
}
}

View File

@@ -0,0 +1,20 @@
<?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 App\Exceptions;
use Exception;
class FileReadException extends Exception
{
public function __construct(string $filepath)
{
parent::__construct('Ошибка чтения файла: ' . $filepath);
}
}

View File

@@ -0,0 +1,20 @@
<?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 App\Exceptions;
use Exception;
class IniParsingException extends Exception
{
public function __construct(string $filepath)
{
parent::__construct('Ошибка разбора файла: ' . $filepath);
}
}

View File

@@ -7,7 +7,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Errors; namespace App\Exceptions;
use Exception; use Exception;

View File

@@ -1,4 +1,5 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project * This file is part of m3u.su project
@@ -7,7 +8,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Errors; namespace App\Exceptions;
use Exception; use Exception;
@@ -15,6 +16,6 @@ class PlaylistNotFoundException extends Exception
{ {
public function __construct(string $code) public function __construct(string $code)
{ {
parent::__construct("Плейлист '$code' не найден"); parent::__construct("Плейлист '{$code}' не найден");
} }
} }

View File

@@ -0,0 +1,20 @@
<?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 App\Exceptions;
use InvalidArgumentException;
class PlaylistWithoutUrlException extends InvalidArgumentException
{
public function __construct(string $code)
{
parent::__construct("Плейлист '{$code}' имеет неверный url");
}
}

View File

@@ -1,4 +1,5 @@
<?php <?php
/* /*
* Copyright (c) 2025, Антон Аксенов * Copyright (c) 2025, Антон Аксенов
* This file is part of m3u.su project * This file is part of m3u.su project
@@ -17,8 +18,8 @@ if (!function_exists('root_path')) {
/** /**
* Возвращает абсолютный путь к корневой директории приложения. * Возвращает абсолютный путь к корневой директории приложения.
* *
* @param string $path Относительный путь для добавления к корневому. * @param string $path относительный путь для добавления к корневому
* @return string Абсолютный путь. * @return string абсолютный путь
*/ */
function root_path(string $path = ''): string function root_path(string $path = ''): string
{ {
@@ -32,12 +33,12 @@ if (!function_exists('config_path')) {
/** /**
* Возвращает абсолютный путь к директории конфигурации приложения. * Возвращает абсолютный путь к директории конфигурации приложения.
* *
* @param string $path Относительный путь для добавления к директории конфигурации. * @param string $path относительный путь для добавления к директории конфигурации
* @return string Абсолютный путь. * @return string абсолютный путь
*/ */
function config_path(string $path = ''): string function config_path(string $path = ''): string
{ {
return root_path("config/$path"); return root_path("config/{$path}");
} }
} }
@@ -45,12 +46,12 @@ if (!function_exists('cache_path')) {
/** /**
* Возвращает абсолютный путь к директории кэша приложения. * Возвращает абсолютный путь к директории кэша приложения.
* *
* @param string $path Относительный путь для добавления к директории кэша. * @param string $path относительный путь для добавления к директории кэша
* @return string Абсолютный путь. * @return string абсолютный путь
*/ */
function cache_path(string $path = ''): string function cache_path(string $path = ''): string
{ {
return root_path("cache/$path"); return root_path("cache/{$path}");
} }
} }
@@ -58,8 +59,8 @@ if (!function_exists('base_url')) {
/** /**
* Возвращает базовый URL приложения. * Возвращает базовый URL приложения.
* *
* @param string $route Дополнительный маршрут, который будет добавлен к базовому URL. * @param string $route дополнительный маршрут, который будет добавлен к базовому URL
* @return string Полный URL. * @return string полный URL
*/ */
function base_url(string $route = ''): string function base_url(string $route = ''): string
{ {
@@ -71,7 +72,7 @@ if (!function_exists('kernel')) {
/** /**
* Возвращает синглтон-экземпляр ядра приложения. * Возвращает синглтон-экземпляр ядра приложения.
* *
* @return Kernel Экземпляр ядра приложения. * @return Kernel экземпляр ядра приложения
*/ */
function kernel(): Kernel function kernel(): Kernel
{ {
@@ -83,7 +84,7 @@ if (!function_exists('app')) {
/** /**
* Возвращает синглтон-экземпляр Slim-приложения. * Возвращает синглтон-экземпляр Slim-приложения.
* *
* @return App Экземпляр Slim-приложения. * @return App экземпляр Slim-приложения
*/ */
function app(): App function app(): App
{ {
@@ -95,9 +96,9 @@ if (!function_exists('config')) {
/** /**
* Возвращает значение из конфигурации приложения. * Возвращает значение из конфигурации приложения.
* *
* @param string $key Ключ конфигурации. * @param string $key ключ конфигурации
* @param mixed $default Значение по умолчанию, если ключ не найден. * @param mixed $default значение по умолчанию, если ключ не найден
* @return mixed Значение конфигурации или значение по умолчанию. * @return mixed значение конфигурации или значение по умолчанию
*/ */
function config(string $key, mixed $default = null): mixed function config(string $key, mixed $default = null): mixed
{ {
@@ -109,7 +110,7 @@ if (!function_exists('redis')) {
/** /**
* Возвращает синглтон-экземпляр Redis-клиента. * Возвращает синглтон-экземпляр Redis-клиента.
* *
* @return Redis Экземпляр Redis-клиента. * @return Redis экземпляр Redis-клиента
*/ */
function redis(): Redis function redis(): Redis
{ {
@@ -121,7 +122,7 @@ if (!function_exists('ini')) {
/** /**
* Возвращает синглтон-экземпляр IniFile. * Возвращает синглтон-экземпляр IniFile.
* *
* @return IniFile Экземпляр IniFile. * @return IniFile экземпляр IniFile
*/ */
function ini(): IniFile function ini(): IniFile
{ {
@@ -273,11 +274,11 @@ if (!function_exists('env')) {
/** /**
* Возвращает значение переменной окружения. * Возвращает значение переменной окружения.
* *
* @param string $key Ключ переменной окружения. * @param string $key ключ переменной окружения
* @param mixed $default Значение по умолчанию, если переменная не найдена. * @param mixed $default значение по умолчанию, если переменная не найдена
* @param bool $required Бросать исключение, если переменная обязательна и не найдена. * @param bool $required бросать исключение, если переменная обязательна и не найдена
* @return mixed Значение переменной окружения или значение по умолчанию. * @return mixed значение переменной окружения или значение по умолчанию
* @throws InvalidArgumentException Если переменная обязательна и не найдена. * @throws InvalidArgumentException если переменная обязательна и не найдена
*/ */
function env(string $key, mixed $default = null, bool $required = false): mixed function env(string $key, mixed $default = null, bool $required = false): mixed
{ {
@@ -311,7 +312,7 @@ if (!function_exists('here')) {
* @param bool $asArray массив или строка в формате `<file|class>:<func>():<line>` * @param bool $asArray массив или строка в формате `<file|class>:<func>():<line>`
* @return string|array * @return string|array
*/ */
function here(bool $asArray = false): string|array function here(bool $asArray = false): array|string
{ {
$trace = debug_backtrace(!DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 2); $trace = debug_backtrace(!DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 2);
@@ -376,7 +377,7 @@ if (!function_exists('snake2camel')) {
} }
} }
if (!function_exists('as_data_url')) { if (!function_exists('data_stream')) {
/** /**
* Создает data URL для данных * Создает data URL для данных
* *
@@ -384,9 +385,9 @@ if (!function_exists('as_data_url')) {
* @param string $mimeType MIME-тип данных * @param string $mimeType MIME-тип данных
* @return string Data URL * @return string Data URL
*/ */
function as_data_url(string $data, string $mimeType = 'text/plain'): string function data_stream(string $data, string $mimeType = 'text/plain'): string
{ {
return "data://$mimeType,$data"; return "data://{$mimeType},{$data}";
} }
} }
@@ -473,7 +474,7 @@ if (!function_exists('number_format_local')) {
* @return string Отформатированное число * @return string Отформатированное число
*/ */
function number_format_local( function number_format_local(
int|float $number, float|int $number,
int $decimals = 0, int $decimals = 0,
string $decPoint = '.', string $decPoint = '.',
string $thousandsSep = ' ', string $thousandsSep = ' ',
@@ -494,7 +495,7 @@ if (!function_exists('format_bytes')) {
{ {
$units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']; $units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { for ($i = 0; $bytes > 1024 && $i < count($units) - 1; ++$i) {
$bytes /= 1024; $bytes /= 1024;
} }
@@ -581,11 +582,12 @@ if (!function_exists('get_noun_form')) {
if ($lastDigit === 1) { if ($lastDigit === 1) {
return $form1; return $form1;
} elseif ($lastDigit >= 2 && $lastDigit <= 4) {
return $form2;
} else {
return $form5;
} }
if ($lastDigit >= 2 && $lastDigit <= 4) {
return $form2;
}
return $form5;
} }
} }
@@ -605,7 +607,7 @@ if (!function_exists('random_string')) {
$result = ''; $result = '';
$max = strlen($chars) - 1; $max = strlen($chars) - 1;
for ($i = 0; $i < $length; $i++) { for ($i = 0; $i < $length; ++$i) {
$result .= $chars[random_int(0, $max)]; $result .= $chars[random_int(0, $max)];
} }
@@ -702,7 +704,7 @@ if (!function_exists('recast')) {
function recast(string $className, stdClass &$object): mixed function recast(string $className, stdClass &$object): mixed
{ {
if (!class_exists($className)) { if (!class_exists($className)) {
throw new InvalidArgumentException("Class not found: $className"); throw new InvalidArgumentException("Class not found: {$className}");
} }
$new = new $className(); $new = new $className();
@@ -769,7 +771,7 @@ if (!function_exists('mb_count_chars')) {
for ($i = 0; $i < $length; ++$i) { for ($i = 0; $i < $length; ++$i) {
$char = mb_substr($string, $i, 1, 'UTF-8'); $char = mb_substr($string, $i, 1, 'UTF-8');
!array_key_exists($char, $unique) && $unique[$char] = 0; !array_key_exists($char, $unique) && $unique[$char] = 0;
$unique[$char]++; ++$unique[$char];
} }
return $unique; return $unique;
@@ -853,7 +855,7 @@ if (!function_exists('array_last')) {
return $array[$lastKey]; return $array[$lastKey];
} }
for ($i = count($keys) - 1; $i >= 0; $i--) { for ($i = count($keys) - 1; $i >= 0; --$i) {
$key = $keys[$i]; $key = $keys[$i];
if ($callback($array[$key], $key)) { if ($callback($array[$key], $key)) {
return $array[$key]; return $array[$key];
@@ -916,7 +918,7 @@ if (!function_exists('array_get')) {
* @param mixed $default Значение по умолчанию * @param mixed $default Значение по умолчанию
* @return mixed Значение из массива или значение по умолчанию * @return mixed Значение из массива или значение по умолчанию
*/ */
function array_get(array $array, string|int $key, mixed $default = null): mixed function array_get(array $array, int|string $key, mixed $default = null): mixed
{ {
return $array[$key] ?? $default; return $array[$key] ?? $default;
} }
@@ -1118,7 +1120,7 @@ if (!function_exists('array_recursive_diff')) {
$aReturn[$key] = $aRecursiveDiff; $aReturn[$key] = $aRecursiveDiff;
} }
} else { } else {
if ($value != $b[$key]) { if ($value !== $b[$key]) {
$aReturn[$key] = $value; $aReturn[$key] = $value;
} }
} }
@@ -1400,7 +1402,7 @@ if (!function_exists('flatten')) {
* *
* @param array $tree Дерево (например, результат функции tree()) * @param array $tree Дерево (например, результат функции tree())
* @param string $branching Ключ ноды, под которым находится массив с дочерними нодами * @param string $branching Ключ ноды, под которым находится массив с дочерними нодами
* @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем * @param string|null $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
* @return array Плоский список * @return array Плоский список
*/ */
function flatten( function flatten(
@@ -1459,8 +1461,8 @@ if (!function_exists('clear_tree')) {
* *
* @param array $node Нода, которая должна быть обработана * @param array $node Нода, которая должна быть обработана
* @param string $branching Ключ ноды, под которым находится массив с дочерними нодами * @param string $branching Ключ ноды, под которым находится массив с дочерними нодами
* @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем * @param string|null $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем
* @return null|array Обработанная нода с хотя бы одним потомком либо null * @return array|null Обработанная нода с хотя бы одним потомком либо null
*/ */
function clear_tree( function clear_tree(
array $node, array $node,

View File

@@ -44,6 +44,11 @@
"app/helpers.php" "app/helpers.php"
] ]
}, },
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": { "scripts": {
"clear-views": "rm -rf cache/views", "clear-views": "rm -rf cache/views",
"post-install-cmd": [ "post-install-cmd": [

View File

@@ -45,4 +45,4 @@ parameters:
# требует явно расписывать все итерируемые типы, структуры полей и т.д. # требует явно расписывать все итерируемые типы, структуры полей и т.д.
# можно раскомментировать для уточнения типов при разработке, но убирать пока рано # можно раскомментировать для уточнения типов при разработке, но убирать пока рано
# - identifier: missingType.iterableValue - identifier: missingType.iterableValue

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 235.6 292.2" style="enable-background:new 0 0 235.6 292.2;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g id="b_1_">
<path class="st0" d="M44.3,164.5L76.9,51.6H127l-10.1,35c-0.1,0.2-0.2,0.4-0.3,0.6L90,179.6h24.8c-10.4,25.9-18.5,46.2-24.3,60.9
c-45.8-0.5-58.6-33.3-47.4-72.1 M90.7,240.6l60.4-86.9h-25.6l22.3-55.7c38.2,4,56.2,34.1,45.6,70.5
c-11.3,39.1-57.1,72.1-101.7,72.1C91.3,240.6,91,240.6,90.7,240.6z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 735 B

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=

View File

@@ -10,8 +10,8 @@
<title>{% block title %}{{ config('app.title') }}{% endblock %}</title> <title>{% block title %}{{ config('app.title') }}{% endblock %}</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="description" content="{% block metadescription %}{% endblock %}"> <meta name="description" content="{% block metadescription %}{% endblock %}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="keywords" content="{% block metakeywords %}{% endblock %}" /> <meta name="keywords" content="{% block metakeywords %}{% endblock %}" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style> <style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script> <script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
@@ -23,7 +23,7 @@
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="IPTV Плейлисты" /> <meta name="apple-mobile-web-app-title" content="{{ config('app.title') }}" />
<link rel="manifest" href="/favicon/site.webmanifest" /> <link rel="manifest" href="/favicon/site.webmanifest" />
<meta name="msapplication-TileColor" content="#00aba9"> <meta name="msapplication-TileColor" content="#00aba9">
<meta name="msapplication-TileImage" content="/favicon/mstile-150x150.png"> <meta name="msapplication-TileImage" content="/favicon/mstile-150x150.png">