diff --git a/.gitignore b/.gitignore index 11358a0..b4b5e83 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ .env .env.* !.env.example -playlists.ini +config/playlists.ini channels.json .phpunit.result.cache .php-cs-fixer.cache diff --git a/app/Controllers/ApiController.php b/app/Controllers/ApiController.php index 5728cd0..8b8c539 100644 --- a/app/Controllers/ApiController.php +++ b/app/Controllers/ApiController.php @@ -12,7 +12,7 @@ namespace App\Controllers; use App\Core\Bot; use App\Core\Kernel; use App\Core\StatisticsService; -use App\Errors\PlaylistNotFoundException; +use App\Exceptions\PlaylistNotFoundException; use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QROptions; use Exception; @@ -38,7 +38,7 @@ class ApiController extends BasicController $code = $request->getAttributes()['code'] ?? null; empty($code) && throw new PlaylistNotFoundException(''); - $playlist = ini()->getPlaylist($code); + $playlist = ini()->playlist($code); if ($playlist['isOnline'] === true) { unset($playlist['content']); } diff --git a/app/Controllers/WebController.php b/app/Controllers/WebController.php index 24fddc3..0ad360f 100644 --- a/app/Controllers/WebController.php +++ b/app/Controllers/WebController.php @@ -9,7 +9,7 @@ declare(strict_types=1); namespace App\Controllers; -use App\Errors\PlaylistNotFoundException; +use App\Exceptions\PlaylistNotFoundException; use Exception; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -36,7 +36,7 @@ class WebController extends BasicController */ public function home(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { - $ini = ini()->load(); + $ini = ini(); $keys = []; $count = count($ini); $pageSize = config('app.page_size'); @@ -45,11 +45,11 @@ class WebController extends BasicController $pageCurrent = (int)($request->getAttributes()['page'] ?? $request->getQueryParams()['page'] ?? 1); $pageCount = ceil($count / $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); } - $playlists = ini()->getPlaylists($keys); + $playlists = ini()->playlists($keys); return $this->view($request, $response, 'list.twig', [ 'updatedAt' => ini()->updatedAt(), @@ -75,7 +75,7 @@ class WebController extends BasicController $code = $request->getAttributes()['code']; try { - $playlist = ini()->getPlaylist($code); + $playlist = ini()->playlist($code); return $response->withHeader('Location', $playlist['url']); } catch (Throwable) { return $this->notFound($request, $response); @@ -98,7 +98,7 @@ class WebController extends BasicController $code = $request->getAttributes()['code']; try { - $playlist = ini()->getPlaylist($code); + $playlist = ini()->playlist($code); return $this->view($request, $response, 'details.twig', ['playlist' => $playlist]); } catch (PlaylistNotFoundException) { return $this->notFound($request, $response); diff --git a/app/Core/Bot.php b/app/Core/Bot.php index 1f7f1b5..b1cd2c2 100644 --- a/app/Core/Bot.php +++ b/app/Core/Bot.php @@ -9,8 +9,8 @@ declare(strict_types=1); namespace App\Core; -use App\Errors\InvalidTelegramSecretException; -use App\Errors\PlaylistNotFoundException; +use App\Exceptions\InvalidTelegramSecretException; +use App\Exceptions\PlaylistNotFoundException; use DateTimeImmutable; use Exception; use JsonException; @@ -168,7 +168,7 @@ class Bot } try { - $pls = ini()->getPlaylist($code); + $pls = ini()->playlist($code); } catch (PlaylistNotFoundException) { return $this->reply("Плейлист `$code` не найден"); } diff --git a/app/Core/IniFile.php b/app/Core/IniFile.php index f218dc9..45079e5 100644 --- a/app/Core/IniFile.php +++ b/app/Core/IniFile.php @@ -1,4 +1,5 @@ */ -class IniFile +class IniFile implements ArrayAccess { /** - * @var array[] Коллекция подгруженных плейлистов + * @var array{}|array Коллекция подгруженных плейлистов */ 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 - { - $filepath = config_path('playlists.ini'); - $this->playlists = parse_ini_file($filepath, true); - $this->updatedAt = date('d.m.Y h:i', filemtime($filepath)); - - return $this->playlists; - } - - /** - * Возвращает плейлисты - * - * @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); + public function __construct( + protected string $filepath, + ) { + try { + $content = file_get_contents($this->filepath); + } catch (Throwable) { + $content = false; } - 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 Код плейлиста - * @return array|null + * @param TKey $code + * @return TValue * @throws PlaylistNotFoundException - * @throws Exception */ - public function getPlaylist(string $code): ?array + public function playlist(string $code): array { - empty($this->playlists) && $this->load(); - $data = redis()->get($code); - return $this->initPlaylist($code, $data); + return $this->playlists[$code] ?? throw new PlaylistNotFoundException($code); } /** @@ -82,107 +84,50 @@ class IniFile */ public function updatedAt(): string { - return $this->updatedAt; + return date('d.m.Y h:i', $this->updatedAt); } /** - * Подготавливает данные о плейлисте в расширенном формате - * - * @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 + * @inheritDoc + * @param non-falsy-string $offset * @return bool */ - protected function hasTokens(array $data): bool + #[Override] + public function offsetExists(mixed $offset): bool { - $string = ($data['url'] ?? '') . ($data['content'] ?? ''); - if (empty($string)) { - return false; - } + return isset($this->playlists[$offset]); + } - $badAttributes = [ - // токены и ключи - '[?&]token=', - '[?&]drmreq=', - // логины - '[?&]u=', - '[?&]user=', - '[?&]username=', - // пароли - '[?&]p=', - '[?&]pwd=', - '[?&]password=', - // неизвестные - // 'free=true', - // 'uid=', - // 'c_uniq_tag=', - // 'rlkey=', - // '?s=', - // '&s=', - // '?q=', - // '&q=', - ]; + /** + * @inheritDoc + * @param TKey $offset + * @return TPlaylistDefinition + * @throws PlaylistNotFoundException + */ + #[Override] + public function offsetGet(mixed $offset): array + { + return $this->playlist($offset); + } - return array_any( - $badAttributes, - static fn (string $badAttribute) => preg_match_all("/$badAttribute/", $string) >= 1, - ); + /** + * @inheritDoc + * @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 + { } } diff --git a/app/Core/Kernel.php b/app/Core/Kernel.php index 5268368..adb1873 100644 --- a/app/Core/Kernel.php +++ b/app/Core/Kernel.php @@ -222,6 +222,6 @@ final class Kernel */ public function ini(): IniFile { - return $this->iniFile ??= new IniFile(); + return $this->iniFile ??= new IniFile(config_path('playlists.ini')); } } diff --git a/app/Core/Playlist.php b/app/Core/Playlist.php new file mode 100644 index 0000000..71e27ec --- /dev/null +++ b/app/Core/Playlist.php @@ -0,0 +1,216 @@ +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, + ); + } +} diff --git a/app/Errors/ErrorHandler.php b/app/Exceptions/ExceptionHandler.php similarity index 97% rename from app/Errors/ErrorHandler.php rename to app/Exceptions/ExceptionHandler.php index 5e17c0c..b5d8133 100644 --- a/app/Errors/ErrorHandler.php +++ b/app/Exceptions/ExceptionHandler.php @@ -7,7 +7,7 @@ declare(strict_types=1); -namespace App\Errors; +namespace App\Exceptions; use Psr\Http\Message\{ ResponseInterface, @@ -19,7 +19,7 @@ use Throwable; /** * Обработчик ошибок */ -class ErrorHandler extends SlimErrorHandler +class ExceptionHandler extends SlimErrorHandler { /** * Логирует ошибку и отдаёт JSON-ответ с необходимым содержимым diff --git a/app/Exceptions/FileNotFoundException.php b/app/Exceptions/FileNotFoundException.php new file mode 100644 index 0000000..208e3a6 --- /dev/null +++ b/app/Exceptions/FileNotFoundException.php @@ -0,0 +1,20 @@ +:():` * @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); @@ -376,7 +377,7 @@ if (!function_exists('snake2camel')) { } } -if (!function_exists('as_data_url')) { +if (!function_exists('data_stream')) { /** * Создает data URL для данных * @@ -384,9 +385,9 @@ if (!function_exists('as_data_url')) { * @param string $mimeType MIME-тип данных * @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 Отформатированное число */ function number_format_local( - int|float $number, + float|int $number, int $decimals = 0, string $decPoint = '.', string $thousandsSep = ' ', @@ -494,7 +495,7 @@ if (!function_exists('format_bytes')) { { $units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']; - for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { + for ($i = 0; $bytes > 1024 && $i < count($units) - 1; ++$i) { $bytes /= 1024; } @@ -581,11 +582,12 @@ if (!function_exists('get_noun_form')) { if ($lastDigit === 1) { 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 = ''; $max = strlen($chars) - 1; - for ($i = 0; $i < $length; $i++) { + for ($i = 0; $i < $length; ++$i) { $result .= $chars[random_int(0, $max)]; } @@ -702,7 +704,7 @@ if (!function_exists('recast')) { function recast(string $className, stdClass &$object): mixed { if (!class_exists($className)) { - throw new InvalidArgumentException("Class not found: $className"); + throw new InvalidArgumentException("Class not found: {$className}"); } $new = new $className(); @@ -769,7 +771,7 @@ if (!function_exists('mb_count_chars')) { for ($i = 0; $i < $length; ++$i) { $char = mb_substr($string, $i, 1, 'UTF-8'); !array_key_exists($char, $unique) && $unique[$char] = 0; - $unique[$char]++; + ++$unique[$char]; } return $unique; @@ -853,7 +855,7 @@ if (!function_exists('array_last')) { return $array[$lastKey]; } - for ($i = count($keys) - 1; $i >= 0; $i--) { + for ($i = count($keys) - 1; $i >= 0; --$i) { $key = $keys[$i]; if ($callback($array[$key], $key)) { return $array[$key]; @@ -916,7 +918,7 @@ if (!function_exists('array_get')) { * @param mixed $default Значение по умолчанию * @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; } @@ -1118,7 +1120,7 @@ if (!function_exists('array_recursive_diff')) { $aReturn[$key] = $aRecursiveDiff; } } else { - if ($value != $b[$key]) { + if ($value !== $b[$key]) { $aReturn[$key] = $value; } } @@ -1400,7 +1402,7 @@ if (!function_exists('flatten')) { * * @param array $tree Дерево (например, результат функции tree()) * @param string $branching Ключ ноды, под которым находится массив с дочерними нодами - * @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем + * @param string|null $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем * @return array Плоский список */ function flatten( @@ -1459,8 +1461,8 @@ if (!function_exists('clear_tree')) { * * @param array $node Нода, которая должна быть обработана * @param string $branching Ключ ноды, под которым находится массив с дочерними нодами - * @param null|string $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем - * @return null|array Обработанная нода с хотя бы одним потомком либо null + * @param string|null $leafProperty Ключ ноды, значение коего определяет является ли каждая нода родителем + * @return array|null Обработанная нода с хотя бы одним потомком либо null */ function clear_tree( array $node, diff --git a/composer.json b/composer.json index ad058ca..265eae9 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,11 @@ "app/helpers.php" ] }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, "scripts": { "clear-views": "rm -rf cache/views", "post-install-cmd": [ diff --git a/phpstan.neon b/phpstan.neon index 8be2ff2..e386fc1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -45,4 +45,4 @@ parameters: # требует явно расписывать все итерируемые типы, структуры полей и т.д. # можно раскомментировать для уточнения типов при разработке, но убирать пока рано - # - identifier: missingType.iterableValue + - identifier: missingType.iterableValue diff --git a/public/boosty.svg b/public/boosty.svg deleted file mode 100644 index f6b84a1..0000000 --- a/public/boosty.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php new file mode 100644 index 0000000..a54ef67 --- /dev/null +++ b/tests/BaseTestCase.php @@ -0,0 +1,340 @@ +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[] = ''; + foreach ($headers as $header) { + $html[] = ""; + } + $html[] = ''; + + foreach ($result as $row) { + $html[] = ''; + foreach ($row as $value) { + $value instanceof BackedEnum && $value = $value->value; + $value = str_replace("'", '', var_export($value, true)); // строки без кавычек + $html[] = ""; + } + $html[] = ''; + } + $html[] = '
{$header}
{$value}
'; + 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); + } + } +} diff --git a/tests/Controllers/ApiControllerTest.php b/tests/Controllers/ApiControllerTest.php new file mode 100644 index 0000000..ab39b8b --- /dev/null +++ b/tests/Controllers/ApiControllerTest.php @@ -0,0 +1,37 @@ +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); + } +} diff --git a/tests/Core/PlaylistTest.php b/tests/Core/PlaylistTest.php new file mode 100644 index 0000000..598f88e --- /dev/null +++ b/tests/Core/PlaylistTest.php @@ -0,0 +1,111 @@ +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(); + + } +} diff --git a/tests/FixtureHandler.php b/tests/FixtureHandler.php new file mode 100644 index 0000000..1a3551a --- /dev/null +++ b/tests/FixtureHandler.php @@ -0,0 +1,109 @@ +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 = "{% block title %}{{ config('app.title') }}{% endblock %} - + @@ -23,7 +23,7 @@ - +