Initial commit: Bicycle PHP MVC micro-framework

Core MVC, HTTP client, Session, Cookie, Config, HTTPException — 111 PHPUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Egor Isaev 2026-06-04 10:04:19 +03:00
commit b516ca07dc
47 changed files with 6011 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
.idea/
/.git
*.bat
config.php
config.json
App/config/config.local.php
.htaccess
/App/media/img/no_img/*
!/App/media/img/no_img/no_image.png
**/files/**/*
!**/files/**/
!**/files/**/.gitkeep
/tools/*

View File

@ -0,0 +1,42 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description IndexController.php
* @copyright (c) 03/06/2026
*/
namespace App\Controller;
use System\Classes\Controller;
use System\Classes\HTTP\Client\Curl;
use System\Classes\HTTP\Client\Request as HttpRequest;
class IndexController extends Controller
{
public function indexAction(): string
{
// GET-запрос
$response = (new Curl())->execute(
HttpRequest::factory('https://jsonplaceholder.typicode.com/todos/1')
);
$todo = $response->isSuccess() ? $response->json() : [];
return $this->render('index', ['todo' => $todo]);
}
public function postAction(): string
{
// POST с JSON-телом
$response = (new Curl())->execute(
HttpRequest::factory('https://jsonplaceholder.typicode.com/posts')
->method('POST')
->json(['title' => 'Bicycle', 'body' => 'test', 'userId' => 1])
);
$created = $response->isSuccess() ? $response->json() : [];
return $this->render('index', ['created' => $created]);
}
}

30
App/config/config.php Normal file
View File

@ -0,0 +1,30 @@
<?php
/**
* @package Bicycle
* @description Основной конфиг приложения
*/
return [
'db' => [
'default' => [
'driver' => 'pdo',
'connection' => [
'host' => 'db',
'dbname' => 'bicycle',
'user' => 'root',
'password' => '',
],
],
],
'session' => [
'cookie_params' => [
'lifetime' => 0,
'secure' => false,
'httponly' => true,
'samesite' => 'Lax',
],
],
];

11
App/view/Index/index.html Normal file
View File

@ -0,0 +1,11 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description index.html
* @copyright (c) 03/06/2026
*/
?>
<?php var_dump($todo); ?>

247
CLAUDE.md Normal file
View File

@ -0,0 +1,247 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project overview
**Bicycle** — самописный PHP MVC micro-framework (PHP >= 8.2). Название отражает идиому «изобрести велосипед».
## Commands
```bash
# Установка зависимостей
composer install
# Обновление автозагрузчика после добавления классов
composer dump-autoload
# Запуск тестов через Docker
docker exec bicycle vendor/bin/phpunit
```
Проект запускается как веб-приложение через Apache + PHP 8.2 в контейнере `bicycle`.
## Architecture
### Directory layout
```
index.php — точка входа; константы EXT, DOCROOT, APPPATH, SYSPATH
System/Classes/ — ядро фреймворка (namespace System\Classes\)
System/Classes/HTTP/ — HTTP-инфраструктура: интерфейсы, заголовки, исключения
System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response)
System/Classes/HTTP/Exception/ — HTTPException_302/403/404
App/Controller/ — контроллеры приложения (namespace App\Controller\)
App/Classes/ — вспомогательные классы приложения (namespace App\Classes\)
App/config/ — конфиги (config.php; config.local.php — в .gitignore)
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
App/media/ — статические ресурсы (js, css, img)
Services/DataBase/ — сервис базы данных (PDO)
Services/Mail/ — сервис почты (PHPMailer)
Services/PDF/ — генерация PDF (dompdf)
System/view/ — системные шаблоны (ошибки 404/500/403, exception)
tests/ — PHPUnit тесты
```
### Autoload (composer.json)
PSR-4 с относительными путями:
- `System\``System/`
- `App\``App/`
- `Services\``Services/`
- `Tests\``tests/` (dev)
PhpStorm может показывать предупреждение «Namespace doesn't match PSR-4» — это ложное срабатывание. Отключается в `Settings → Editor → Inspections → PHP → General`.
### Namespaces
- `System/Classes/*.php``namespace System\Classes;`
- `System/Classes/HTTP/*.php``namespace System\Classes\HTTP;`
- `System/Classes/HTTP/Client/*.php``namespace System\Classes\HTTP\Client;`
- `System/Classes/HTTP/Exception/*.php``namespace System\Classes\HTTP\Exception;`
- `App/Controller/*.php``namespace App\Controller;`
- `App/Classes/*.php``namespace App\Classes;`
- `Services/**/*.php``namespace Services\...;`
### Core classes
| Класс | Роль |
|---|---|
| `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды |
| `System\Classes\Controller` | Базовый контроллер; `render()` строит двухуровневый вывод: layout + content |
| `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include` |
| `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` |
| `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `is_ajax()` |
| `System\Classes\Response` | HTTP-ответ; `body()` — геттер/сеттер содержимого |
| `System\Classes\MyException` | Кастомное исключение; зарегистрировано через `set_exception_handler` |
| `System\Classes\Cookie` | Статический хелпер для работы с куками: `get()`, `set()`, `delete()` |
| `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` |
| `System\Classes\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` |
| `System\Classes\HTTP` | `redirect()`, `request_headers()` |
| `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат |
| `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` |
| `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... |
| `System\Classes\HTTP\HTTPException` | HTTP-исключение; `factory(int $code)`, `get_response(): Response` |
| `System\Classes\HTTP\Exception\HTTPException_302` | Редирект через `HTTP::redirect()` |
| `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden |
| `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found |
| `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL |
| `System\Classes\HTTP\Client\Request` | Билдер исходящего запроса: `method()`, `header()`, `json()`, `timeout()` |
| `System\Classes\HTTP\Client\Response` | Ответ внешнего запроса: `status()`, `body()`, `json()`, `isSuccess()` |
### Request lifecycle
```
index.php
→ Core::init()
→ Request::factory() # detect_uri(), Route::resolve(), заполняет post/query/body/method
→ Request::execute() # require_once контроллера, вызывает {action}Action()
→ Response->body() # строка с HTML
→ echo
```
### Internal Request (System\Classes\Request)
Входящий запрос от браузера. Реализует `System\Classes\HTTP\Request`.
- `factory()` — создаёт экземпляр, заполняет `$_GET`, `$_POST`, метод, тело, `X-Requested-With`
- `$initial` — первый запрос; `$current` — текущий
- `post($key?, $value?)` — getter/setter POST-данных
- `query($key?, $value?)` — getter/setter GET-параметров
- `body($content?)` — getter/setter тела запроса
- `method($method?)` — getter/setter HTTP-метода
- `is_ajax()` — проверяет `X-Requested-With: XMLHttpRequest`
- `detect_uri()` — определяет URI через `PATH_INFO``REQUEST_URI``PHP_SELF`
### External HTTP Client (System\Classes\HTTP\Client\*)
Исходящие запросы к внешним API.
```php
use System\Classes\HTTP\Client\{Request as HttpRequest, Curl};
$response = (new Curl())->execute(
HttpRequest::factory('https://api.example.com/data')
->method('POST')
->json(['key' => 'value'])
->timeout(10)
);
if ($response->isSuccess()) {
$data = $response->json();
}
```
### Session (System\Classes\Session)
Синглтон, поддерживает именованные сессии.
```php
use System\Classes\Session;
$session = Session::instance(); // сессия 'main'
$session->set('user_id', 42);
$id = $session->get('user_id');
$session->delete('user_id');
$session->regenerate(); // новый session_id, старые данные сохраняются
$session->destroy(); // уничтожить сессию
```
### Cookie (System\Classes\Cookie)
Статический хелпер.
```php
use System\Classes\Cookie;
Cookie::set('token', 'abc123', time() + 3600);
$val = Cookie::get('token');
Cookie::delete('token');
```
Свойства по умолчанию: `$expiration=0`, `$path='/'`, `$domain=null`, `$secure=false`, `$httponly=true`, `$samesite='Lax'`.
### Config (System\Classes\Config)
Lazy-load конфига из `App/config/config.php`. Если существует `App/config/config.local.php` — глубоко мержится поверх базового.
```php
use System\Classes\Config;
$db = Config::get('db'); // весь раздел
$params = Config::get('session', 'cookie_params'); // вложенный ключ
Config::set('foo', ['bar' => 'baz']); // переопределить в runtime
```
`config.local.php` исключён из git (`.gitignore`) — используется для переопределений на конкретном хосте.
### HTTPException (System\Classes\HTTP\HTTPException)
```php
use System\Classes\HTTP\HTTPException;
throw HTTPException::factory(404, 'Страница :url не найдена', [':url' => $uri]);
// или через get_response() без throw:
$response = HTTPException::factory(403)->get_response();
```
`factory(int $code)` создаёт `HTTPException_302/403/404` или базовый класс для неизвестных кодов.
### Routing (Route::resolve)
Автоматический роутинг без конфигурации, поиск контроллера по файловой системе в `App/Controller/`:
| URL | Контроллер | Метод |
|-----|-----------|-------|
| `/` | `IndexController` | `indexAction()` |
| `/users` | `UsersController` | `indexAction()` |
| `/users/list` | `UsersController` | `listAction()` |
| `/users/list/42` | `UsersController` | `listAction()`, `param(0)=42` |
| `/admin/users/edit` | `Admin/UsersController` | `editAction()` (subdir) |
Поиск файлов и имён контроллеров — без учёта регистра. Если контроллер не найден, Route возвращает flat-роутинг (не теряет action).
### View path convention
- Шаблон контента: `App/view/{Controller}/{template}.html`
- Layout: `System/view/views/{layout}.html`
- `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html`
- `$_paths` инициализируется лениво при первом вызове `findFile()`
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
### Environment
Среда задаётся через `SetEnv MMH_ENV` в `.htaccess` (PRODUCTION / STAGING / TESTING / DEVELOPMENT).
По умолчанию — `DEVELOPMENT`. Значение читается в `index.php` через `getenv('MMH_ENV')`.
### Apache
`ServerName localhost` добавлен в `/home/isaevea/http/docker-dev/conf/Bicycle/apache2/apache2.conf`.
**Никогда не трогать** `/home/isaevea/http/docker-dev/docker/php-apache/8.2.8/Dockerfile`.
### Tests
PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap.php`.
Константы определяются в bootstrap. `Core::init()` не вызывается — `findFile()` инициализирует `$_paths` лениво.
**PHPStorm**: интерпретатор PHP — Docker (`bicycle`), volume маппинг `/home/isaevea/http/Bicycle``/opt/project`, путь к autoload: `/opt/project/vendor/autoload.php`, конфиг: `/opt/project/phpunit.xml`.
| Файл | Покрытие |
|------|---------|
| `tests/Unit/RouteTest.php` | `Route::resolve()`, case-insensitive, params, fallback |
| `tests/Unit/ResponseTest.php` | `body()`, `status()`, fluent chain |
| `tests/Unit/MyExceptionTest.php` | `strtr` замена, code, previous |
| `tests/Unit/CoreTest.php` | `findFile()`, `getConst()`, environment |
| `tests/Unit/RequestTest.php` | `factory()`, `post()`, `query()`, `body()`, `is_ajax()`, `method()` |
| `tests/Unit/ViewTest.php` | `render()`, `setFilename()`, data extract |
| `tests/Unit/HttpClientRequestTest.php` | `HTTP\Client\Request` builder, json, fluent |
| `tests/Unit/CookieTest.php` | `get()`, `set()`, `delete()`, defaults |
| `tests/Unit/SessionTest.php` | singleton, `get/set/delete/destroy/regenerate/bind` |
| `tests/Unit/ConfigTest.php` | `get()`, `set()`, `merge()`, lazy-load |
| `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `get_response()`, codes |
**111 тестов, 171 assertion — все проходят.**
### Frontend dependencies (через Composer)
Bootstrap 5.3, Bootstrap Icons 1.13, jQuery 3.7.1, jQuery UI 1.12, Select2 4.1 — из `vendor/`.

65
System/Classes/Config.php Normal file
View File

@ -0,0 +1,65 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Config.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes;
class Config
{
private static ?array $config = null;
private static function load(): void
{
if (self::$config !== null) {
return;
}
$base = APPPATH . '/config/config.php';
$local = APPPATH . '/config/config.local.php';
$config = file_exists($base) ? (include $base) : [];
if (file_exists($local)) {
$config = self::merge($config, include $local);
}
self::$config = $config;
}
public static function get(string $name, ?string $group = null): mixed
{
self::load();
if ($group === null) {
return self::$config[$name] ?? null;
}
return self::$config[$name][$group] ?? null;
}
public static function set(string $name, mixed $value): void
{
self::load();
self::$config[$name] = $value;
}
/**
* Глубокое слияние двух массивов конфигурации.
*/
public static function merge(array $base, array $override): array
{
foreach ($override as $key => $value) {
if (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
$base[$key] = self::merge($base[$key], $value);
} else {
$base[$key] = $value;
}
}
return $base;
}
}

View File

@ -0,0 +1,29 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Controller.php
* @copyright (c) 03/06/2026
*/
namespace System\Classes;
class Controller
{
protected string $_layout = 'layout';
/**
* @throws MyException
*/
protected function render(string $template, array $data = [], string $dir = ''): string
{
if ($dir === '') {
$class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
}
return (new View($this->_layout, 'view/views', [
'content' => (new View($template, $dir, $data))->render()
]))->render();
}
}

53
System/Classes/Cookie.php Normal file
View File

@ -0,0 +1,53 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Cookie.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes;
class Cookie
{
public static int $expiration = 0;
public static string $path = '/';
public static ?string $domain = null;
public static bool $secure = false;
public static bool $httponly = true;
public static string $samesite = 'Lax';
public static function get(string $key, ?string $default = null): ?string
{
return $_COOKIE[$key] ?? $default;
}
public static function set(string $name, string $value, ?int $lifetime = null): bool
{
$lifetime = $lifetime ?? self::$expiration;
$expires = $lifetime > 0 ? time() + $lifetime : 0;
return setcookie($name, $value, [
'expires' => $expires,
'path' => self::$path,
'domain' => self::$domain,
'secure' => self::$secure,
'httponly' => self::$httponly,
'samesite' => self::$samesite,
]);
}
public static function delete(string $name): bool
{
unset($_COOKIE[$name]);
return setcookie($name, '', [
'expires' => 1,
'path' => self::$path,
'domain' => self::$domain,
'secure' => self::$secure,
'httponly' => self::$httponly,
'samesite' => self::$samesite,
]);
}
}

104
System/Classes/Core.php Normal file
View File

@ -0,0 +1,104 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Core.php
* @copyright (c) 03/06/2026
*/
namespace System\Classes;
use ErrorException;
class Core
{
// Среда работы системы
public const PRODUCTION = 10;
public const STAGING = 20;
public const TESTING = 30;
public const DEVELOPMENT = 40;
/**
* @var int Текущая среда системы по умолчанию
*/
public static int $environment = self::DEVELOPMENT;
/**
* @var string Кодировка на ввод и вывод
*/
public static string $charset = 'utf-8';
/**
* @var bool Начальное значение был ли создан [Core::init]?
*/
protected static bool $_init = false;
/**
* @var array
*/
protected static array $_paths = [];
public static function init(): void
{
if (self::$_init) {
return;
}
self::$_init = true;
set_exception_handler([MyException::class, 'handler']);
set_error_handler([__CLASS__, 'errorHandler']);
}
public static function findFile($folder, $file, $ext = null): bool|string
{
if (empty(self::$_paths)) {
self::$_paths = [APPPATH, SYSPATH];
}
if ($ext === null) {
// Используйте расширение по умолчанию
$ext = EXT;
} elseif ($ext) {
// Префикс расширения с точкой
$ext = ".$ext";
} else {
// Не использовать расширение
$ext = '';
}
$path_file = $folder . '/' . $file . $ext;
$found = false;
foreach (self::$_paths as $dir) {
if (is_file($dir . '/' . $path_file)) {
$found = $dir . '/' . $path_file;
break;
}
}
return $found;
}
/**
* @throws ErrorException
*/
public static function errorHandler($code, $error, $file = null, $line = null): bool
{
if (error_reporting() & $code) {
// Эта ошибка не подавляется текущими настройками отчетов об ошибках
// Преобразование ошибки в ErrorException
throw new ErrorException($error, $code, 0, $file, $line);
}
// Продолжаем работать в штатном режиме
return true;
}
public static function getConst(string $name): mixed
{
return constant("self::$name");
}
}

46
System/Classes/HTTP.php Normal file
View File

@ -0,0 +1,46 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTP.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes;
use System\Classes\HTTP\Header;
class HTTP
{
public static function redirect(string $uri, int $code = 302): never
{
http_response_code($code);
header('Location: ' . $uri);
exit;
}
public static function request_headers(): Header
{
if (function_exists('apache_request_headers')) {
return new Header(array_change_key_case(apache_request_headers()));
}
$headers = [];
if (!empty($_SERVER['CONTENT_TYPE'])) {
$headers['content-type'] = $_SERVER['CONTENT_TYPE'];
}
if (!empty($_SERVER['CONTENT_LENGTH'])) {
$headers['content-length'] = $_SERVER['CONTENT_LENGTH'];
}
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$headers[str_replace('_', '-', strtolower(substr($key, 5)))] = $value;
}
}
return new Header($headers);
}
}

View File

@ -0,0 +1,55 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTP/Client/Curl.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP\Client;
use RuntimeException;
class Curl
{
public function execute(Request $request): Response
{
$headers = [];
foreach ($request->getHeaders() as $name => $value) {
$headers[] = $name . ': ' . $value;
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $request->getUrl(),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => $request->getTimeout(),
CURLOPT_CONNECTTIMEOUT => 10,
]);
match ($request->getMethod()) {
'GET' => curl_setopt($ch, CURLOPT_HTTPGET, true),
'POST' => curl_setopt($ch, CURLOPT_POST, true),
default => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $request->getMethod()),
};
if ($request->getBody() !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $request->getBody());
}
$body = curl_exec($ch);
if ($body === false) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Curl error: ' . $error);
}
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return new Response($status, $body);
}
}

View File

@ -0,0 +1,66 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTP/Client/Request.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP\Client;
class Request
{
private string $method = 'GET';
private string $url;
private array $headers = [];
private ?string $body = null;
private int $timeout = 30;
public function __construct(string $url)
{
$this->url = $url;
}
public static function factory(string $url): static
{
return new static($url);
}
public function method(string $method): static
{
$this->method = strtoupper($method);
return $this;
}
public function header(string $name, string $value): static
{
$this->headers[$name] = $value;
return $this;
}
public function body(string $body): static
{
$this->body = $body;
return $this;
}
public function json(array $data): static
{
$this->header('Content-Type', 'application/json');
$this->header('Accept', 'application/json');
$this->body = json_encode($data, JSON_UNESCAPED_UNICODE);
return $this;
}
public function timeout(int $seconds): static
{
$this->timeout = $seconds;
return $this;
}
public function getMethod(): string { return $this->method; }
public function getUrl(): string { return $this->url; }
public function getHeaders(): array { return $this->headers; }
public function getBody(): ?string { return $this->body; }
public function getTimeout(): int { return $this->timeout; }
}

View File

@ -0,0 +1,45 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTP/Client/Response.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP\Client;
use JsonException;
class Response
{
public function __construct(
private readonly int $status,
private readonly string $body
) {}
public function status(): int
{
return $this->status;
}
public function body(): string
{
return $this->body;
}
public function isSuccess(): bool
{
return $this->status >= 200 && $this->status < 300;
}
/**
* @throws JsonException
*/
public function json(): array
{
if ($this->body === '') {
return [];
}
return json_decode($this->body, true, 512, JSON_THROW_ON_ERROR);
}
}

View File

@ -0,0 +1,24 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTPException_302.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP\Exception;
use System\Classes\HTTP;
use System\Classes\HTTP\HTTPException;
use System\Classes\Response;
class HTTPException_302 extends HTTPException
{
protected int $_code = 302;
public function get_response(): Response
{
$location = $this->getMessage() ?: '/';
HTTP::redirect($location, 302);
}
}

View File

@ -0,0 +1,34 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTPException_403.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP\Exception;
use System\Classes\Core;
use System\Classes\HTTP\HTTPException;
use System\Classes\Response;
class HTTPException_403 extends HTTPException
{
protected int $_code = 403;
public function get_response(): Response
{
http_response_code(403);
$response = new Response(403);
$view = Core::findFile('view/errors', '403', 'html');
if ($view) {
ob_start();
$message = $this->getMessage();
include $view;
$response->body(ob_get_clean());
}
return $response;
}
}

View File

@ -0,0 +1,34 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTPException_404.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP\Exception;
use System\Classes\Core;
use System\Classes\HTTP\HTTPException;
use System\Classes\Response;
class HTTPException_404 extends HTTPException
{
protected int $_code = 404;
public function get_response(): Response
{
http_response_code(404);
$response = new Response(404);
$view = Core::findFile('view/errors', '404', 'html');
if ($view) {
ob_start();
$message = $this->getMessage();
include $view;
$response->body(ob_get_clean());
}
return $response;
}
}

View File

@ -0,0 +1,55 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTP/HTTPException.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP;
use System\Classes\Core;
use System\Classes\MyException;
use System\Classes\Response;
class HTTPException extends MyException
{
protected int $_code = 0;
public function __construct(string $message = '', ?array $variables = null, ?\Throwable $previous = null, int $codeOverride = 0)
{
if ($codeOverride !== 0) {
$this->_code = $codeOverride;
}
parent::__construct($message, $variables, $this->_code, $previous);
}
public static function factory(int $code, string $message = '', ?array $variables = null, ?\Throwable $previous = null): static
{
$class = 'System\\Classes\\HTTP\\Exception\\HTTPException_' . $code;
if (class_exists($class)) {
return new $class($message, $variables, $previous);
}
return new static($message, $variables, $previous, $code);
}
public function get_response(): Response
{
$code = $this->_code ?: 500;
http_response_code($code);
$response = new Response($code);
$view = Core::findFile('view/errors', (string)$code, 'html');
if ($view) {
ob_start();
$message = $this->getMessage();
include $view;
$response->body(ob_get_clean());
}
return $response;
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTP/Header.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP;
use ArrayObject;
class Header extends ArrayObject
{
public function __construct(array $input = [])
{
parent::__construct($input);
}
public function __toString(): string
{
$output = '';
foreach ($this as $key => $value) {
$output .= $key . ': ' . (is_array($value) ? implode(', ', $value) : $value) . "\r\n";
}
return $output . "\r\n";
}
public function send(): void
{
if (headers_sent()) {
return;
}
foreach ($this as $key => $value) {
header($key . ': ' . (is_array($value) ? implode(', ', $value) : $value));
}
}
}

View File

@ -0,0 +1,17 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTP/Message.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP;
interface Message
{
public function protocol($protocol = null);
public function headers($key = null, $value = null);
public function body($content = null);
public function render();
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTP/Request.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP;
interface Request
{
public const GET = 'GET';
public const POST = 'POST';
public const PUT = 'PUT';
public const PATCH = 'PATCH';
public const DELETE = 'DELETE';
public const HEAD = 'HEAD';
public const OPTIONS = 'OPTIONS';
public function method(?string $method = null);
public function uri();
public function query(null|string|array $key = null, mixed $value = null);
public function post(null|string|array $key = null, mixed $value = null);
}

View File

@ -0,0 +1,55 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description MyException.php
* @copyright (c) 03/06/2026
*/
namespace System\Classes;
use Exception;
use Throwable;
class MyException extends Exception
{
/**
* @param $message
* @param array|null $variables
* @param $code
* @param Exception|null $previous
*/
public function __construct(string $message = "", ?array $variables = null, int $code = 0, ?Throwable $previous = null)
{
if ($variables) {
$message = strtr($message, $variables);
}
parent::__construct($message, $code, $previous);
}
public static function handler(Throwable $e): void
{
$httpCode = ($e->getCode() >= 100 && $e->getCode() <= 599) ? $e->getCode() : 500;
http_response_code($httpCode);
if (Core::$environment >= Core::DEVELOPMENT) {
$class = get_class($e);
$code = $e->getCode();
$message = $e->getMessage();
$file_ = $e->getFile();
$line = $e->getLine();
$xdebug_message = property_exists($e, 'xdebug_message') ? $e->xdebug_message : '';
$view = Core::findFile('view/exception', 'error', 'html');
if ($view) {
include $view;
return;
}
}
if (($view500 = Core::findFile('view/errors', '500', 'html')) !== false) {
include $view500;
}
}
}

176
System/Classes/Request.php Normal file
View File

@ -0,0 +1,176 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Request.php
* @copyright (c) 03/06/2026
*/
namespace System\Classes;
use System\Classes\HTTP\HTTPException;
use System\Classes\HTTP\Request as HTTPRequest;
class Request implements HTTPRequest
{
public static ?self $initial = null;
public static ?self $current = null;
private string $_uri;
private string $_controller = 'Index';
private string $_action = 'index';
private array $_params = [];
private string $_method = HTTPRequest::GET;
private array $_post = [];
private array $_get = [];
private ?string $_body = null;
private string $_requested_with = '';
private function __construct(string $uri)
{
$this->_uri = $uri;
$route = Route::resolve($uri);
$this->_controller = $route['controller'];
$this->_action = $route['action'];
$this->_params = $route['params'];
}
public static function factory(): static
{
$uri = self::detect_uri();
$instance = new static($uri);
if (self::$initial === null) {
self::$initial = $instance;
}
self::$current = $instance;
$instance->method($_SERVER['REQUEST_METHOD'] ?? HTTPRequest::GET);
$instance->query($_GET);
$instance->post($_POST);
$instance->_requested_with = strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '');
if ($instance->method() !== HTTPRequest::GET) {
$body = file_get_contents('php://input');
if ($body !== false && $body !== '') {
$instance->body($body);
}
}
return $instance;
}
public static function detect_uri(): string
{
if (!empty($_SERVER['PATH_INFO'])) {
$uri = $_SERVER['PATH_INFO'];
} elseif (isset($_SERVER['REQUEST_URI'])) {
$uri = $_SERVER['REQUEST_URI'];
if ($path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) {
$uri = $path;
}
$uri = rawurldecode($uri);
} elseif (isset($_SERVER['PHP_SELF'])) {
$uri = $_SERVER['PHP_SELF'];
} else {
$uri = '/';
}
return trim($uri, '/');
}
public function execute(): Response
{
$method = lcfirst($this->_action) . 'Action';
$parts = explode('/', $this->_controller);
$file = DOCROOT . '/App/Controller/' . implode('/', array_map('ucfirst', $parts)) . 'Controller.php';
if (!file_exists($file)) {
return HTTPException::factory(404)->get_response();
}
require_once $file;
$class = 'App\\Controller\\' . implode('\\', array_map('ucfirst', $parts)) . 'Controller';
if (!method_exists($class, $method)) {
return HTTPException::factory(404)->get_response();
}
return (new Response())->body((new $class())->$method());
}
public function method(?string $method = null): string|static
{
if ($method === null) {
return $this->_method;
}
$this->_method = strtoupper($method);
return $this;
}
public function post(null|string|array $key = null, mixed $value = null): null|array|string|static
{
if (is_array($key)) {
$this->_post = $key;
return $this;
}
if ($key === null) {
return $this->_post;
}
if ($value === null) {
return $this->_post[$key] ?? null;
}
$this->_post[$key] = $value;
return $this;
}
public function query(null|string|array $key = null, mixed $value = null): null|array|string|static
{
if (is_array($key)) {
$this->_get = $key;
return $this;
}
if ($key === null) {
return $this->_get;
}
if ($value === null) {
return $this->_get[$key] ?? null;
}
$this->_get[$key] = $value;
return $this;
}
public function body(?string $content = null): string|static
{
if ($content === null) {
return $this->_body ?? '';
}
$this->_body = $content;
return $this;
}
public function requested_with(?string $value = null): string|static
{
if ($value === null) {
return $this->_requested_with;
}
$this->_requested_with = strtolower($value);
return $this;
}
public function is_ajax(): bool
{
return $this->_requested_with === 'xmlhttprequest';
}
public function controller(): string { return $this->_controller; }
public function action(): string { return $this->_action; }
public function params(): array { return $this->_params; }
public function uri(): string { return $this->_uri; }
public function param(int $index, mixed $default = null): mixed
{
return $this->_params[$index] ?? $default;
}
}

View File

@ -0,0 +1,39 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Response.php
* @copyright (c) 03/06/2026
*/
namespace System\Classes;
class Response
{
private int $_status = 200;
private string $_body = '';
public function __construct(int $status = 200)
{
$this->_status = $status;
}
public function body(?string $content = null): string|static
{
if ($content === null) {
return $this->_body;
}
$this->_body = $content;
return $this;
}
public function status(?int $code = null): int|static
{
if ($code === null) {
return $this->_status;
}
$this->_status = $code;
http_response_code($code);
return $this;
}
}

112
System/Classes/Route.php Normal file
View File

@ -0,0 +1,112 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Route.php
* @copyright (c) 03/06/2026
*/
namespace System\Classes;
class Route
{
/**
* Разбирает URI и возвращает массив с ключами controller, action, params.
*
* Поддерживаемые форматы URL:
* / Index::index
* /controller Controller::index
* /controller/action Controller::action
* /controller/action/p1/p2 Controller::action + params
* /subdir/controller/action Subdir/Controller::action (если subdir папка в App/Controller)
*/
public static function resolve(string $uri): array
{
if ($uri === '') {
return self::make('Index', 'index', []);
}
$parts = explode('/', $uri);
$controller = self::findController($parts[0], '');
if ($controller !== null || !isset($parts[1])) {
return self::make(
$controller ?? ucfirst($parts[0]),
$parts[1] ?? 'index',
array_slice($parts, 2)
);
}
$subdirController = self::findController($parts[1], $parts[0]);
if ($subdirController !== null) {
return self::make(
$subdirController,
$parts[2] ?? 'index',
array_slice($parts, 3)
);
}
// Ни плоский контроллер, ни subdir не найдены — возвращаем flat-роутинг
return self::make(
ucfirst($parts[0]),
$parts[1] ?? 'index',
array_slice($parts, 2)
);
}
/**
* Ищет файл {name}Controller.php в App/Classes[/subdir] без учёта регистра.
* Возвращает имя контроллера (или subdir/имя) без суффикса Controller, либо null.
*/
private static function findController(string $name, string $subdir): ?string
{
$base = DOCROOT . '/App/Controller/';
$dir = $base;
if ($subdir !== '') {
foreach (is_dir($base) ? scandir($base) : [] as $entry) {
if ($entry !== '.' && $entry !== '..'
&& is_dir($base . $entry)
&& strtolower($entry) === strtolower($subdir)
) {
$dir = $base . $entry . '/';
$subdir = $entry;
break;
}
}
if ($dir === $base) {
return null;
}
}
if (!is_dir($dir)) {
return null;
}
$needle = strtolower($name . 'Controller');
foreach (scandir($dir) as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) !== 'php') {
continue;
}
$basename = pathinfo($file, PATHINFO_FILENAME);
if (strtolower($basename) === $needle) {
$clean = substr($basename, 0, -10); // убираем 'Controller'
return $subdir !== '' ? $subdir . '/' . $clean : $clean;
}
}
return null;
}
private static function make(string $controller, string $action, array $params): array
{
return [
'controller' => $controller,
'action' => $action,
'params' => $params,
];
}
}

144
System/Classes/Session.php Normal file
View File

@ -0,0 +1,144 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Session.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes;
class Session
{
private static array $instances = [];
protected bool $_destroyed = false;
protected string $_name = 'main';
protected int $_lifetime = 0;
protected array $_data = [];
private function __construct(string $name, array $config)
{
$this->_name = $name;
$this->_lifetime = $config['lifetime'] ?? 0;
session_write_close();
session_name($this->_name);
session_set_cookie_params([
'lifetime' => $this->_lifetime > 0 ? 300 : 0,
'path' => '/',
'secure' => $config['secure'] ?? false,
'httponly' => $config['httponly'] ?? true,
'samesite' => $config['samesite'] ?? 'Lax',
]);
$this->read();
}
public static function instance(string $name = 'main'): static
{
$name = strtolower($name);
if (!isset(self::$instances[$name])) {
self::$instances[$name] = new static($name, []);
}
return self::$instances[$name];
}
public function read(): void
{
session_cache_limiter(false);
session_start();
$_SESSION['start_time'] = (int)($_SESSION['start_time'] ?? time());
$gc_lifetime = (int)ini_get('session.gc_maxlifetime');
if ($_SESSION['start_time'] + $gc_lifetime < time()) {
$this->destroy();
}
$this->_data = &$_SESSION;
}
public function get(string $key, mixed $default = null): mixed
{
return array_key_exists($key, $this->_data) ? $this->_data[$key] : $default;
}
public function set(string $key, mixed $value): static
{
$this->_data[$key] = $value;
return $this;
}
public function delete(string $key): static
{
unset($this->_data[$key]);
return $this;
}
public function destroy(): bool
{
if (!$this->_destroyed) {
session_unset();
session_destroy();
setcookie(session_name(), '', 0, '/');
Cookie::delete($this->_name);
if ($this->_destroyed = !session_id()) {
$this->_data = [];
}
}
return $this->_destroyed;
}
public function regenerate(): false|string
{
session_regenerate_id(true);
$new_id = session_id();
Cookie::set($this->_name, $new_id, $this->_lifetime);
return $new_id;
}
public function write(): bool
{
if ($this->_destroyed || headers_sent()) {
return false;
}
session_write_close();
return true;
}
public function bind(string $key, mixed &$value): static
{
$this->_data[$key] = &$value;
return $this;
}
public function id(): false|string
{
return session_id();
}
public function getData(): array
{
return $this->_data;
}
public static function getDeviceHash(): string
{
$hash = Cookie::get('device_hash');
if (!$hash) {
$hash = md5(($_SERVER['HTTP_USER_AGENT'] ?? '') . uniqid('', true));
Cookie::set('device_hash', $hash, 365 * 24 * 3600);
}
return $hash;
}
}

90
System/Classes/View.php Normal file
View File

@ -0,0 +1,90 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description View.php
* @copyright (c) 03/06/2026
*/
namespace System\Classes;
use Throwable;
class View
{
/**
* @var string
*/
public string $_file;
/**
* @var array
*/
public array $_data = [];
/**
* @param string|null $file
* @param string $dir
* @param array|null $data
* @throws MyException
*/
public function __construct(?string $file = null, string $dir = '', ?array $data = null)
{
if ($file !== null) {
$this->setFilename($file, $dir);
}
if ($data !== null) {
// Добавить значения к текущим данным
$this->_data = $data + $this->_data;
}
}
/**
* @param string $file
* @param string $dir
* @return View
* @throws MyException
*/
public function setFilename(string $file, string $dir = ''): View
{
if ($dir === '') {
throw new MyException("Параметр dir не передан в View::setFilename для файла '$file'");
}
if (($path = Core::findFile($dir, $file, 'html')) === false) {
throw new MyException("При запросе view файл $dir/$file не найден");
}
// Store the file path locally
$this->_file = $path;
return $this;
}
/**
* @throws MyException
*/
public function render(): string
{
if (empty($this->_file)) {
throw new MyException('Нужно установить файл для использования во view перед преобразованием');
}
ob_start();
extract($this->_data, EXTR_SKIP);
try {
include $this->_file;
return ob_get_clean();
} catch (Throwable $e) {
ob_end_clean();
throw new MyException(
"Ошибка при render файла: {$e->getFile()} на строке {$e->getLine()} - {$e->getMessage()}", 0, $e
);
}
}
}

View File

@ -0,0 +1,10 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description 403.html
* @copyright (c) 03/06/2026
*/
?>

View File

@ -0,0 +1,20 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description 404.html
* @copyright (c) 03/06/2026
*/
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>404 — Страница не найдена</title>
</head>
<body>
<h1>404</h1>
<p>Страница не найдена.</p>
</body>
</html>

View File

@ -0,0 +1,10 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description 500.html
* @copyright (c) 03/06/2026
*/
?>

View File

@ -0,0 +1,40 @@
<?php
/**
* @package MMH
* @author Egor Isaev
* @description error.html
* @copyright (c) 12/12/2023
*
* @var $class
* @var $code
* @var $message
* @var $file_
* @var $line
* @var $xdebug_message
*/
use System\Classes\Core;
// Unique error identifier
$error_id = uniqid('error', true);
?>
<div>
<h1>
<span class="type">
<?=$class?> [ <?=$code?> ]:</span> <span class="message">
<?=htmlspecialchars((string)$message, ENT_QUOTES | ENT_IGNORE, Core::$charset)?>
</span>
</h1>
<div id="<?=$error_id?>" class="content">
<p>
<span class="file">
<?=$file_?> [<?=$line?>]
</span>
</p>
</div>
</div>
<table><?=$xdebug_message?></table>

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bicycle</title>
</head>
<body>
<?= $content ?>
</body>
</html>

36
composer.json Normal file
View File

@ -0,0 +1,36 @@
{
"require": {
"php": ">=8.2",
"ext-pdo": "*",
"phpoffice/phpspreadsheet": "5.*",
"phpoffice/phpword": "1.1.0",
"phpmailer/phpmailer": "v6.9.*",
"twbs/bootstrap": "v5.3.*",
"twbs/bootstrap-icons": "v1.13.*",
"select2/select2": "4.1.*",
"components/jquery": "v3.7.1",
"components/jqueryui": "1.12.*",
"dompdf/dompdf": "v3.1.*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "App",
"Services\\": "Services",
"System\\": "System"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"composer/installers": true
}
}
}

3202
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

34
index.php Normal file
View File

@ -0,0 +1,34 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description index.php
* @copyright (c) 03/06/2026
*/
use System\Classes\Core;
use System\Classes\Request;
const EXT = '.php';
error_reporting(E_ALL);
ini_set('display_errors', '1');
define("DOCROOT", realpath(__DIR__));
define('APPPATH', realpath('App'));
define('SYSPATH', realpath('System'));
date_default_timezone_set('Europe/Moscow');
setlocale(LC_ALL, 'ru_RU.UTF-8', 'Rus');
require_once DOCROOT . '/vendor/autoload.php';
if (getenv('MMH_ENV'))
{
Core::$environment = Core::getConst(strtoupper(getenv('MMH_ENV')));
}
Core::init();
echo Request::factory()->execute()->body();

11
phpunit.xml Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php"
colors="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnTestsThatTriggerErrors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>

56
tests/Unit/ConfigTest.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\Config;
class ConfigTest extends TestCase
{
protected function tearDown(): void
{
// Сбрасываем загруженный конфиг через рефлексию
$ref = new \ReflectionClass(Config::class);
$prop = $ref->getProperty('config');
$prop->setAccessible(true);
$prop->setValue(null, null);
}
public function testGetSectionReturnsArray(): void
{
$db = Config::get('db');
$this->assertIsArray($db);
$this->assertArrayHasKey('default', $db);
}
public function testGetGroupReturnsValue(): void
{
$params = Config::get('session', 'cookie_params');
$this->assertIsArray($params);
$this->assertArrayHasKey('lifetime', $params);
}
public function testGetMissingKeyReturnsNull(): void
{
$this->assertNull(Config::get('nonexistent'));
$this->assertNull(Config::get('nonexistent', 'group'));
}
public function testSetOverridesValue(): void
{
Config::set('foo', ['bar' => 'baz']);
$this->assertSame(['bar' => 'baz'], Config::get('foo'));
}
public function testMergeDeep(): void
{
$base = ['a' => ['x' => 1, 'y' => 2], 'b' => 3];
$override = ['a' => ['y' => 99, 'z' => 4], 'b' => 10];
$result = Config::merge($base, $override);
$this->assertSame(1, $result['a']['x']);
$this->assertSame(99, $result['a']['y']);
$this->assertSame(4, $result['a']['z']);
$this->assertSame(10, $result['b']);
}
}

53
tests/Unit/CookieTest.php Normal file
View File

@ -0,0 +1,53 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\Cookie;
class CookieTest extends TestCase
{
protected function setUp(): void
{
Cookie::$expiration = 0;
Cookie::$path = '/';
Cookie::$domain = null;
Cookie::$secure = false;
Cookie::$httponly = true;
Cookie::$samesite = 'Lax';
}
public function testGetReturnsNullWhenNotSet(): void
{
unset($_COOKIE['test_key']);
$this->assertNull(Cookie::get('test_key'));
}
public function testGetReturnsDefault(): void
{
unset($_COOKIE['test_key']);
$this->assertSame('default', Cookie::get('test_key', 'default'));
}
public function testGetReturnsValue(): void
{
$_COOKIE['test_key'] = 'hello';
$this->assertSame('hello', Cookie::get('test_key'));
unset($_COOKIE['test_key']);
}
public function testDeleteUnsetsFromGlobal(): void
{
$_COOKIE['to_delete'] = 'value';
Cookie::delete('to_delete');
$this->assertArrayNotHasKey('to_delete', $_COOKIE);
}
public function testDefaultsAreCorrect(): void
{
$this->assertSame('/', Cookie::$path);
$this->assertTrue(Cookie::$httponly);
$this->assertFalse(Cookie::$secure);
$this->assertSame('Lax', Cookie::$samesite);
}
}

76
tests/Unit/CoreTest.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\Core;
class CoreTest extends TestCase
{
public function testFindFileInSyspath(): void
{
$path = Core::findFile('view/errors', '404', 'html');
$this->assertIsString($path);
$this->assertStringEndsWith('view/errors/404.html', $path);
$this->assertStringContainsString('System', $path);
}
public function testFindFileInApppath(): void
{
$path = Core::findFile('view/Index', 'index', 'html');
$this->assertIsString($path);
$this->assertStringEndsWith('index.html', $path);
$this->assertStringContainsString('App', $path);
}
public function testFindFileReturnsFalseWhenNotFound(): void
{
$result = Core::findFile('view', 'nonexistent_xyz_file', 'html');
$this->assertFalse($result);
}
public function testFindFileWithDefaultExtension(): void
{
// EXT = '.php', findFile без третьего аргумента ищет .php
$path = Core::findFile('Classes', 'Core');
$this->assertIsString($path);
$this->assertStringEndsWith('Classes/Core.php', $path);
}
public function testFindFileReturnsFullPath(): void
{
$path = Core::findFile('view/errors', '404', 'html');
$this->assertStringStartsWith(SYSPATH, $path);
$this->assertFileExists($path);
}
public function testGetConstProduction(): void
{
$this->assertSame(10, Core::getConst('PRODUCTION'));
}
public function testGetConstStaging(): void
{
$this->assertSame(20, Core::getConst('STAGING'));
}
public function testGetConstTesting(): void
{
$this->assertSame(30, Core::getConst('TESTING'));
}
public function testGetConstDevelopment(): void
{
$this->assertSame(40, Core::getConst('DEVELOPMENT'));
}
public function testDefaultEnvironmentIsDevelopment(): void
{
$this->assertSame(Core::DEVELOPMENT, Core::$environment);
}
public function testCharsetIsUtf8(): void
{
$this->assertSame('utf-8', Core::$charset);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\HTTP\Exception\HTTPException_403;
use System\Classes\HTTP\Exception\HTTPException_404;
use System\Classes\HTTP\HTTPException;
use System\Classes\Response;
class HTTPExceptionTest extends TestCase
{
public function testFactory404ReturnsCorrectClass(): void
{
$e = HTTPException::factory(404);
$this->assertInstanceOf(HTTPException_404::class, $e);
$this->assertSame(404, $e->getCode());
}
public function testFactory403ReturnsCorrectClass(): void
{
$e = HTTPException::factory(403);
$this->assertInstanceOf(HTTPException_403::class, $e);
$this->assertSame(403, $e->getCode());
}
public function testFactoryWithMessage(): void
{
$e = HTTPException::factory(404, 'Страница не найдена');
$this->assertSame('Страница не найдена', $e->getMessage());
}
public function testFactoryWithVariables(): void
{
$e = HTTPException::factory(404, 'Файл :name не найден', [':name' => 'test.html']);
$this->assertSame('Файл test.html не найден', $e->getMessage());
}
public function testFactoryUnknownCodeReturnsBaseClass(): void
{
$e = HTTPException::factory(418);
$this->assertInstanceOf(HTTPException::class, $e);
$this->assertSame(418, $e->getCode());
}
public function testGetResponseReturns404Response(): void
{
$e = HTTPException::factory(404);
$response = $e->get_response();
$this->assertInstanceOf(Response::class, $response);
$this->assertSame(404, $response->status());
}
public function testGetResponseReturns403Response(): void
{
$e = HTTPException::factory(403);
$response = $e->get_response();
$this->assertInstanceOf(Response::class, $response);
$this->assertSame(403, $response->status());
}
public function testGetResponseBodyContainsHtml(): void
{
$e = HTTPException::factory(404);
$body = $e->get_response()->body();
$this->assertStringContainsString('404', $body);
}
public function testExtendsMyException(): void
{
$e = HTTPException::factory(404);
$this->assertInstanceOf(\System\Classes\MyException::class, $e);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\HTTP\Client\Request;
class HttpClientRequestTest extends TestCase
{
public function testFactoryCreatesInstance(): void
{
$req = Request::factory('https://example.com/api');
$this->assertInstanceOf(Request::class, $req);
$this->assertSame('https://example.com/api', $req->getUrl());
}
public function testDefaultMethodIsGet(): void
{
$req = Request::factory('https://example.com');
$this->assertSame('GET', $req->getMethod());
}
public function testMethodSetter(): void
{
$req = Request::factory('https://example.com')->method('post');
$this->assertSame('POST', $req->getMethod());
}
public function testHeaderSetter(): void
{
$req = Request::factory('https://example.com')
->header('Authorization', 'Bearer token123');
$this->assertSame(['Authorization' => 'Bearer token123'], $req->getHeaders());
}
public function testBodySetter(): void
{
$req = Request::factory('https://example.com')->body('raw body');
$this->assertSame('raw body', $req->getBody());
}
public function testDefaultBodyIsNull(): void
{
$req = Request::factory('https://example.com');
$this->assertNull($req->getBody());
}
public function testJsonSetsBodyAndHeaders(): void
{
$req = Request::factory('https://example.com')->json(['key' => 'value']);
$this->assertStringContainsString('"key":"value"', $req->getBody());
$this->assertSame('application/json', $req->getHeaders()['Content-Type']);
$this->assertSame('application/json', $req->getHeaders()['Accept']);
}
public function testJsonEncodesUnicode(): void
{
$req = Request::factory('https://example.com')->json(['name' => 'Иван']);
$this->assertStringContainsString('Иван', $req->getBody());
}
public function testTimeoutDefault(): void
{
$req = Request::factory('https://example.com');
$this->assertSame(30, $req->getTimeout());
}
public function testTimeoutSetter(): void
{
$req = Request::factory('https://example.com')->timeout(10);
$this->assertSame(10, $req->getTimeout());
}
public function testFluentInterface(): void
{
$req = Request::factory('https://example.com')
->method('POST')
->header('X-Custom', 'value')
->timeout(5)
->body('data');
$this->assertSame('POST', $req->getMethod());
$this->assertSame('value', $req->getHeaders()['X-Custom']);
$this->assertSame(5, $req->getTimeout());
$this->assertSame('data', $req->getBody());
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\MyException;
class MyExceptionTest extends TestCase
{
public function testMessageWithoutVariables(): void
{
$e = new MyException('Something went wrong');
$this->assertSame('Something went wrong', $e->getMessage());
}
public function testMessageWithVariables(): void
{
$e = new MyException('File :file not found', [':file' => 'test.html']);
$this->assertSame('File test.html not found', $e->getMessage());
}
public function testMultipleVariables(): void
{
$e = new MyException(':a + :b = :c', [':a' => '1', ':b' => '2', ':c' => '3']);
$this->assertSame('1 + 2 = 3', $e->getMessage());
}
public function testNullVariablesLeavesMessageUntouched(): void
{
$e = new MyException('Plain :message', null);
$this->assertSame('Plain :message', $e->getMessage());
}
public function testCodeIsSet(): void
{
$e = new MyException('Error', null, 42);
$this->assertSame(42, $e->getCode());
}
public function testDefaultCode(): void
{
$e = new MyException('Error');
$this->assertSame(0, $e->getCode());
}
public function testPreviousException(): void
{
$prev = new \Exception('original');
$e = new MyException('Wrapped', null, 0, $prev);
$this->assertSame($prev, $e->getPrevious());
}
public function testExtendsException(): void
{
$e = new MyException('test');
$this->assertInstanceOf(\Exception::class, $e);
}
}

212
tests/Unit/RequestTest.php Normal file
View File

@ -0,0 +1,212 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\Request;
class RequestTest extends TestCase
{
protected function setUp(): void
{
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['REQUEST_METHOD'] = 'GET';
}
protected function tearDown(): void
{
Request::$current = null;
Request::$initial = null;
unset($_SERVER['REQUEST_URI'], $_SERVER['REQUEST_METHOD'], $_SERVER['HTTP_X_REQUESTED_WITH']);
$_GET = [];
$_POST = [];
}
public function testMethodGet(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$req = Request::factory();
$this->assertSame('GET', $req->method());
}
public function testMethodPost(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$req = Request::factory();
$this->assertSame('POST', $req->method());
}
public function testMethodDefaultsToGet(): void
{
unset($_SERVER['REQUEST_METHOD']);
$req = Request::factory();
$this->assertSame('GET', $req->method());
}
public function testMethodNormalizesToUppercase(): void
{
$_SERVER['REQUEST_METHOD'] = 'delete';
$req = Request::factory();
$this->assertSame('DELETE', $req->method());
}
public function testFactoryParsesRootUri(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$this->assertSame('Index', $req->controller());
$this->assertSame('index', $req->action());
$this->assertSame([], $req->params());
}
public function testFactoryParsesControllerAndAction(): void
{
$_SERVER['REQUEST_URI'] = '/index/about';
$req = Request::factory();
$this->assertSame('Index', $req->controller());
$this->assertSame('about', $req->action());
}
public function testFactoryParsesParams(): void
{
$_SERVER['REQUEST_URI'] = '/index/show/hello/world';
$req = Request::factory();
$this->assertSame(['hello', 'world'], $req->params());
}
public function testParamByIndex(): void
{
$_SERVER['REQUEST_URI'] = '/index/show/42/extra';
$req = Request::factory();
$this->assertSame('42', $req->param(0));
$this->assertSame('extra', $req->param(1));
}
public function testParamDefaultValue(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$this->assertNull($req->param(0));
$this->assertSame('fallback', $req->param(0, 'fallback'));
}
public function testFactorySetsCurrentRequest(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$this->assertSame($req, Request::$current);
}
public function testUriGetter(): void
{
$_SERVER['REQUEST_URI'] = '/index/test';
$req = Request::factory();
$this->assertSame('index/test', $req->uri());
}
public function testUriQueryStringIsStripped(): void
{
$_SERVER['REQUEST_URI'] = '/index/test?foo=bar&baz=1';
$req = Request::factory();
$this->assertSame('index/test', $req->uri());
}
public function testInitialIsSetOnFirstCall(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$this->assertSame($req, Request::$initial);
}
public function testPostArraySetter(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$req->post(['name' => 'Ivan', 'age' => '30']);
$this->assertSame(['name' => 'Ivan', 'age' => '30'], $req->post());
}
public function testPostKeyGetter(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$req->post(['name' => 'Ivan']);
$this->assertSame('Ivan', $req->post('name'));
$this->assertNull($req->post('missing'));
}
public function testPostKeySetter(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$req->post('key', 'value');
$this->assertSame('value', $req->post('key'));
}
public function testQueryArraySetter(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$req->query(['page' => '2', 'sort' => 'name']);
$this->assertSame(['page' => '2', 'sort' => 'name'], $req->query());
}
public function testQueryKeyGetter(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$req->query(['page' => '3']);
$this->assertSame('3', $req->query('page'));
$this->assertNull($req->query('missing'));
}
public function testBodyGetterSetter(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$this->assertSame('', $req->body());
$req->body('{"key":"val"}');
$this->assertSame('{"key":"val"}', $req->body());
}
public function testIsAjaxFalseByDefault(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$this->assertFalse($req->is_ajax());
}
public function testIsAjaxTrueWhenHeaderSet(): void
{
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest';
$req = Request::factory();
$this->assertTrue($req->is_ajax());
}
public function testMethodSetter(): void
{
$_SERVER['REQUEST_URI'] = '/';
$req = Request::factory();
$same = $req->method('patch');
$this->assertSame($req, $same);
$this->assertSame('PATCH', $req->method());
}
public function testFactoryPopulatesGetFromGlobals(): void
{
$_SERVER['REQUEST_URI'] = '/';
$_GET = ['foo' => 'bar'];
$req = Request::factory();
$this->assertSame('bar', $req->query('foo'));
}
public function testFactoryPopulatesPostFromGlobals(): void
{
$_SERVER['REQUEST_URI'] = '/';
$_SERVER['REQUEST_METHOD'] = 'POST';
$_POST = ['field' => 'hello'];
$req = Request::factory();
$this->assertSame('hello', $req->post('field'));
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\Response;
class ResponseTest extends TestCase
{
public function testDefaultValues(): void
{
$r = new Response();
$this->assertSame('', $r->body());
$this->assertSame(200, $r->status());
}
public function testConstructorStatus(): void
{
$r = new Response(404);
$this->assertSame(404, $r->status());
}
public function testBodySetterReturnsThis(): void
{
$r = new Response();
$same = $r->body('hello');
$this->assertSame($r, $same);
$this->assertSame('hello', $r->body());
}
public function testStatusSetterReturnsThis(): void
{
$r = new Response();
$same = $r->status(301);
$this->assertSame($r, $same);
$this->assertSame(301, $r->status());
}
public function testBodyOverwrite(): void
{
$r = (new Response())->body('first');
$r->body('second');
$this->assertSame('second', $r->body());
}
public function testBodyEmpty(): void
{
$r = (new Response())->body('content');
$r->body('');
$this->assertSame('', $r->body());
}
public function testFluentChain(): void
{
$r = new Response();
$body = $r->status(201)->body('created')->body();
$this->assertSame('created', $body);
$this->assertSame(201, $r->status());
}
}

76
tests/Unit/RouteTest.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\Route;
class RouteTest extends TestCase
{
public function testEmptyUri(): void
{
$r = Route::resolve('');
$this->assertSame(['controller' => 'Index', 'action' => 'index', 'params' => []], $r);
}
public function testControllerOnly(): void
{
$r = Route::resolve('index');
$this->assertSame('Index', $r['controller']);
$this->assertSame('index', $r['action']);
$this->assertSame([], $r['params']);
}
public function testControllerAndAction(): void
{
$r = Route::resolve('index/about');
$this->assertSame('Index', $r['controller']);
$this->assertSame('about', $r['action']);
$this->assertSame([], $r['params']);
}
public function testControllerActionAndParams(): void
{
$r = Route::resolve('index/show/42/extra');
$this->assertSame('Index', $r['controller']);
$this->assertSame('show', $r['action']);
$this->assertSame(['42', 'extra'], $r['params']);
}
public function testSingleParam(): void
{
$r = Route::resolve('index/view/99');
$this->assertSame(['99'], $r['params']);
}
public function testCaseInsensitiveController(): void
{
$r = Route::resolve('INDEX/show');
// Файловая система находит IndexController.php → возвращает 'Index'
$this->assertSame('Index', $r['controller']);
$this->assertSame('show', $r['action']);
}
public function testUnknownControllerFallsBackToUcfirst(): void
{
$r = Route::resolve('nonexistent');
$this->assertSame('Nonexistent', $r['controller']);
$this->assertSame('index', $r['action']);
$this->assertSame([], $r['params']);
}
public function testUnknownControllerWithAction(): void
{
$r = Route::resolve('nonexistent/doSomething');
$this->assertSame('Nonexistent', $r['controller']);
$this->assertSame('doSomething', $r['action']);
}
public function testUnknownControllerWithParams(): void
{
$r = Route::resolve('nonexistent/action/1/2');
$this->assertSame('Nonexistent', $r['controller']);
$this->assertSame('action', $r['action']);
$this->assertSame(['1', '2'], $r['params']);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\Session;
class SessionTest extends TestCase
{
private Session $session;
protected function setUp(): void
{
// Используем реальную сессию в CLI — заголовки не отправляются
$this->session = Session::instance('test');
}
protected function tearDown(): void
{
$this->session->destroy();
// Сбрасываем синглтон через рефлексию
$ref = new \ReflectionClass(Session::class);
$prop = $ref->getProperty('instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
public function testSetAndGet(): void
{
$this->session->set('foo', 'bar');
$this->assertSame('bar', $this->session->get('foo'));
}
public function testGetDefault(): void
{
$this->assertNull($this->session->get('nonexistent'));
$this->assertSame('default', $this->session->get('nonexistent', 'default'));
}
public function testDelete(): void
{
$this->session->set('key', 'value');
$this->session->delete('key');
$this->assertNull($this->session->get('key'));
}
public function testSetReturnsThis(): void
{
$result = $this->session->set('x', 1);
$this->assertSame($this->session, $result);
}
public function testDeleteReturnsThis(): void
{
$result = $this->session->delete('x');
$this->assertSame($this->session, $result);
}
public function testGetDataReturnsArray(): void
{
$this->session->set('a', 1)->set('b', 2);
$data = $this->session->getData();
$this->assertIsArray($data);
$this->assertSame(1, $data['a']);
$this->assertSame(2, $data['b']);
}
public function testIdIsString(): void
{
$this->assertIsString($this->session->id());
$this->assertNotEmpty($this->session->id());
}
public function testInstanceReturnsSameSingleton(): void
{
$a = Session::instance('test');
$b = Session::instance('test');
$this->assertSame($a, $b);
}
public function testBindByReference(): void
{
$value = 'initial';
$this->session->bind('ref', $value);
$value = 'changed';
$this->assertSame('changed', $this->session->get('ref'));
}
public function testDestroyReturnsTrueAndClearsData(): void
{
$this->session->set('key', 'val');
$result = $this->session->destroy();
$this->assertTrue($result);
$this->assertEmpty($this->session->getData());
}
}

120
tests/Unit/ViewTest.php Normal file
View File

@ -0,0 +1,120 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\View;
use System\Classes\MyException;
class ViewTest extends TestCase
{
private string $tmpFile;
protected function setUp(): void
{
$this->tmpFile = sys_get_temp_dir() . '/bicycle_view_' . uniqid() . '.html';
}
protected function tearDown(): void
{
if (file_exists($this->tmpFile)) {
unlink($this->tmpFile);
}
}
public function testRenderStaticContent(): void
{
file_put_contents($this->tmpFile, 'hello world');
$view = new View();
$view->_file = $this->tmpFile;
$this->assertSame('hello world', $view->render());
}
public function testRenderWithData(): void
{
file_put_contents($this->tmpFile, '<?php echo $name; ?>');
$view = new View();
$view->_file = $this->tmpFile;
$view->_data = ['name' => 'bicycle'];
$this->assertSame('bicycle', $view->render());
}
public function testRenderMultipleVars(): void
{
file_put_contents($this->tmpFile, '<?php echo "$a-$b"; ?>');
$view = new View();
$view->_file = $this->tmpFile;
$view->_data = ['a' => 'foo', 'b' => 'bar'];
$this->assertSame('foo-bar', $view->render());
}
public function testRenderThrowsWhenFileNotSet(): void
{
$this->expectException(MyException::class);
(new View())->render();
}
public function testSetFilenameFindsFileInAppPath(): void
{
$view = new View();
$view->setFilename('index', 'view/Index');
$this->assertStringContainsString('App', $view->_file);
$this->assertStringEndsWith('index.html', $view->_file);
$this->assertFileExists($view->_file);
}
public function testSetFilenameFindsFileInSysPath(): void
{
$view = new View();
$view->setFilename('404', 'view/errors');
$this->assertStringContainsString('System', $view->_file);
$this->assertFileExists($view->_file);
}
public function testSetFilenameThrowsWhenNotFound(): void
{
$this->expectException(MyException::class);
(new View())->setFilename('nonexistent_xyz', 'view/Index');
}
public function testSetFilenameThrowsWhenDirEmpty(): void
{
$this->expectException(MyException::class);
(new View())->setFilename('index', '');
}
public function testConstructorWithFileAndDir(): void
{
$view = new View('index', 'view/Index');
$this->assertNotEmpty($view->_file);
$this->assertFileExists($view->_file);
}
public function testConstructorWithData(): void
{
file_put_contents($this->tmpFile, '<?php echo $val; ?>');
$view = new View();
$view->_file = $this->tmpFile;
$view->_data = ['val' => 'test_value'];
$this->assertSame('test_value', $view->render());
}
public function testExtractSkipDoesNotOverrideLocalVar(): void
{
// EXTR_SKIP не перезаписывает уже существующие переменные в шаблоне
file_put_contents($this->tmpFile, '<?php $x = "local"; extract([], EXTR_SKIP); echo $x; ?>');
$view = new View();
$view->_file = $this->tmpFile;
$view->_data = ['x' => 'from_data'];
// Здесь x уже определена в шаблоне ДО extract — EXTR_SKIP не перезапишет
// Но View делает extract ДО include, поэтому $x = 'from_data' попадёт в шаблон
$this->assertSame('local', $view->render());
}
public function testSetFilenameReturnsSelf(): void
{
$view = new View();
$result = $view->setFilename('index', 'view/Index');
$this->assertSame($view, $result);
}
}

11
tests/bootstrap.php Normal file
View File

@ -0,0 +1,11 @@
<?php
const EXT = '.php';
define('DOCROOT', dirname(__DIR__));
define('APPPATH', DOCROOT . '/App');
define('SYSPATH', DOCROOT . '/System');
require_once DOCROOT . '/vendor/autoload.php';
// Core::init() не вызывается намеренно: его обработчики ошибок конфликтуют
// с PHPUnit. Core::findFile() инициализирует $_paths лениво при первом вызове.