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:
commit
b516ca07dc
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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/*
|
||||||
42
App/Controller/IndexController.php
Normal file
42
App/Controller/IndexController.php
Normal 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
30
App/config/config.php
Normal 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
11
App/view/Index/index.html
Normal 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
247
CLAUDE.md
Normal 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
65
System/Classes/Config.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
System/Classes/Controller.php
Normal file
29
System/Classes/Controller.php
Normal 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
53
System/Classes/Cookie.php
Normal 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
104
System/Classes/Core.php
Normal 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
46
System/Classes/HTTP.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
System/Classes/HTTP/Client/Curl.php
Normal file
55
System/Classes/HTTP/Client/Curl.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
System/Classes/HTTP/Client/Request.php
Normal file
66
System/Classes/HTTP/Client/Request.php
Normal 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; }
|
||||||
|
}
|
||||||
45
System/Classes/HTTP/Client/Response.php
Normal file
45
System/Classes/HTTP/Client/Response.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
System/Classes/HTTP/Exception/HTTPException_302.php
Normal file
24
System/Classes/HTTP/Exception/HTTPException_302.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
System/Classes/HTTP/Exception/HTTPException_403.php
Normal file
34
System/Classes/HTTP/Exception/HTTPException_403.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
System/Classes/HTTP/Exception/HTTPException_404.php
Normal file
34
System/Classes/HTTP/Exception/HTTPException_404.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
System/Classes/HTTP/HTTPException.php
Normal file
55
System/Classes/HTTP/HTTPException.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
System/Classes/HTTP/Header.php
Normal file
41
System/Classes/HTTP/Header.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
System/Classes/HTTP/Message.php
Normal file
17
System/Classes/HTTP/Message.php
Normal 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();
|
||||||
|
}
|
||||||
25
System/Classes/HTTP/Request.php
Normal file
25
System/Classes/HTTP/Request.php
Normal 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);
|
||||||
|
}
|
||||||
55
System/Classes/MyException.php
Normal file
55
System/Classes/MyException.php
Normal 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
176
System/Classes/Request.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
System/Classes/Response.php
Normal file
39
System/Classes/Response.php
Normal 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
112
System/Classes/Route.php
Normal 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
144
System/Classes/Session.php
Normal 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
90
System/Classes/View.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
System/view/errors/403.html
Normal file
10
System/view/errors/403.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description 403.html
|
||||||
|
* @copyright (c) 03/06/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
20
System/view/errors/404.html
Normal file
20
System/view/errors/404.html
Normal 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>
|
||||||
10
System/view/errors/500.html
Normal file
10
System/view/errors/500.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description 500.html
|
||||||
|
* @copyright (c) 03/06/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
40
System/view/exception/error.html
Normal file
40
System/view/exception/error.html
Normal 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>
|
||||||
11
System/view/views/layout.html
Normal file
11
System/view/views/layout.html
Normal 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
36
composer.json
Normal 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
3202
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
index.php
Normal file
34
index.php
Normal 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
11
phpunit.xml
Normal 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
56
tests/Unit/ConfigTest.php
Normal 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
53
tests/Unit/CookieTest.php
Normal 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
76
tests/Unit/CoreTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
tests/Unit/HTTPExceptionTest.php
Normal file
74
tests/Unit/HTTPExceptionTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
tests/Unit/HttpClientRequestTest.php
Normal file
87
tests/Unit/HttpClientRequestTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/Unit/MyExceptionTest.php
Normal file
58
tests/Unit/MyExceptionTest.php
Normal 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
212
tests/Unit/RequestTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/Unit/ResponseTest.php
Normal file
60
tests/Unit/ResponseTest.php
Normal 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
76
tests/Unit/RouteTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
tests/Unit/SessionTest.php
Normal file
97
tests/Unit/SessionTest.php
Normal 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
120
tests/Unit/ViewTest.php
Normal 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
11
tests/bootstrap.php
Normal 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 лениво при первом вызове.
|
||||||
Loading…
Reference in New Issue
Block a user