dev
This commit is contained in:
parent
9ff9cc54e6
commit
8fdcd0b901
@ -20,8 +20,6 @@ use System\Classes\MyException;
|
||||
class IndexController extends Controller
|
||||
{
|
||||
/**
|
||||
* Главная страница: GET-запрос к внешнему API, вывод todo в шаблон.
|
||||
*
|
||||
* @return string
|
||||
* @throws MyException
|
||||
*/
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
namespace App\Repositories;
|
||||
|
||||
use PDO;
|
||||
use System\Classes\MyException;
|
||||
use System\Classes\Repository;
|
||||
|
||||
/**
|
||||
@ -24,7 +23,6 @@ class BudgetRepository extends Repository
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @throws MyException
|
||||
*/
|
||||
public function getB(): array
|
||||
{
|
||||
|
||||
37
CLAUDE.md
37
CLAUDE.md
@ -80,7 +80,7 @@ PhpStorm может показывать предупреждение «Namespac
|
||||
|---|---|
|
||||
| `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды |
|
||||
| `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` |
|
||||
| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()`/`renderContent()` + авто-CSRF в `before()` + `json()` |
|
||||
| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()`/`renderContent()` + авто-CSRF в `before()` + `json()` + заголовок `X-Profiler` в `after()` для admin |
|
||||
| `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include`; `setStyle()`/`setScript()`/`setManifest()`+`getStyles()`/`getScripts()` — подключение CSS/JS из шаблона |
|
||||
| `System\Classes\ProfilerToolbar` | Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana): время/память/SQL из `Profiler`; `render()` — пусто, если пользователь не admin |
|
||||
| `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` |
|
||||
@ -439,9 +439,8 @@ GeSHi) из оригинала не переносились — устарев
|
||||
|
||||
- **SQL** — из `Services\DataBase\Classes\Profiler` (см. раздел DataBase выше): время, текст запроса, параметры,
|
||||
`EXPLAIN` для SELECT-запросов (прогоняется заново через `Database::instance()` в момент рендера панели,
|
||||
не при исполнении самого запроса; ошибка `EXPLAIN` → `—`, не ломает панель). Сам `EXPLAIN` — тоже
|
||||
обычный `query()`, поэтому попадает в `Profiler`, но счётчик/время в шапке панели ("SQL: N (X ms)")
|
||||
захватываются до его выполнения — иначе панель считала бы свои же debug-запросы.
|
||||
не при исполнении самого запроса; ошибка `EXPLAIN` → `—`, не ломает панель). Сам `EXPLAIN` выполняется
|
||||
с `Statement::$skip_profiling = true` — иначе попал бы в тот же `Profiler` и засорял бы свой же счётчик.
|
||||
- **Vars** — `GET`/`POST`/`COOKIE`/`SESSION`/`SERVER`; чувствительные ключи (`Log::$mask_keys`:
|
||||
`password`, `pass`, `csrf_token`, `token`) маскируются `***` — та же маска, что и в `Log`.
|
||||
- **Files** — все подключённые к запросу файлы (`get_included_files()`) с размером и общим итогом.
|
||||
@ -455,6 +454,33 @@ GeSHi) из оригинала не переносились — устарев
|
||||
в проекте). Верхняя строка сворачивает/разворачивает панель, кнопки переключают вкладки — инлайн
|
||||
`onclick` + один `<script>`/`<style>` внутри самого `render()`, без внешних файлов — панель самодостаточна.
|
||||
|
||||
**Ajax/API-ответы панель не видят** — `renderContent()`/`json()` не проходят через `layout.html`, где она
|
||||
подключена. Вместо HTML-панели `Controller::after()` (см. таблицу Core classes) для admin ставит заголовок
|
||||
`X-Profiler` — JSON с полной картиной запроса (`time_ms`, `memory_mb`, `sql_count`, `sql_time_ms`, список
|
||||
`sql` с временем каждого) — виден в devtools → Network → заголовки ответа для любого запроса, включая ajax;
|
||||
тело ответа не трогается. `EXPLAIN` панели (см. выше) не участвует — `skip_profiling`.
|
||||
|
||||
**На странице ошибки панель тоже есть** — `System/view/exception/error.html` (см. `MyException::handler()`,
|
||||
показывается только в DEVELOPMENT) заканчивается тем же `<?= \System\Classes\ProfilerToolbar::render() ?>`,
|
||||
что и `layout.html` — видно, какие SQL-запросы успели выполниться до падения. Аналог `ProfilerToolbar::render(true)`
|
||||
в конце `views/kohana/error.php` у оригинала; в отличие от оригинала — без своей подсветки исходников через
|
||||
GeSHi и без разбора стека вручную, это уже даёт `$xdebug_message` (готовая таблица от Xdebug: файл/строка/
|
||||
память/время по каждому кадру), если расширение включено.
|
||||
|
||||
**Панель полностью самодостаточна по стилям — общий сброс `#profiler-toolbar, #profiler-toolbar * {...}`**
|
||||
(font-family/font-size/color/margin/padding и т.д. явно на каждом потомке), не полагаясь на наследование.
|
||||
Понадобилось из-за двух независимых багов, которые проявлялись по-разному на разных страницах:
|
||||
- на странице ошибки нет `<!DOCTYPE html>` (`error.html` рендерится как голый фрагмент, без layout) —
|
||||
браузер в quirks mode, где `<table>` в некоторых движках не наследует `color` от предков → текст в
|
||||
таблицах панели становился чёрным на тёмном фоне именно там;
|
||||
- на обычных страницах (`layout.html`) подключён Bootstrap, который стилизует голые теги без класса —
|
||||
`code {color:#d63384}` (розовый), `h4 {font-size:1.5rem}` (крупный) — эти правила побеждали
|
||||
наследование от `#profiler-toolbar`, потому что *явное* правило на самом элементе всегда сильнее
|
||||
унаследованного значения, независимо от специфичности правила у предка.
|
||||
|
||||
Розовый акцент для `<code>` (SQL/пути к файлам/ключи) — теперь свой, явный (`#profiler-toolbar code {color:#ff79c6}`),
|
||||
не случайно унаследованный от Bootstrap — поэтому одинаково выглядит что на обычной странице, что на странице ошибки.
|
||||
|
||||
### Log (System\Classes\Log)
|
||||
|
||||
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
|
||||
@ -593,6 +619,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
|
||||
| `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent |
|
||||
| `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии |
|
||||
| `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки |
|
||||
| `tests/Unit/ControllerAfterTest.php` | `after()` веб-`Controller`: заголовок `X-Profiler` только для admin, содержит SQL-сводку — через `xdebug_get_headers()`, иначе `markTestSkipped()` |
|
||||
| `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` |
|
||||
| `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` |
|
||||
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
|
||||
@ -609,7 +636,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
|
||||
| `tests/Unit/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` |
|
||||
| `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT |
|
||||
|
||||
**242 теста, 379 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).**
|
||||
**247 тестов, 384 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).**
|
||||
|
||||
### Frontend dependencies (через Composer)
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ use PDO;
|
||||
use PDOException;
|
||||
use PDOStatement;
|
||||
use System\Classes\MyException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover),
|
||||
@ -37,7 +38,6 @@ class PdoDriver
|
||||
* Не делает ничего, если подключение уже установлено.
|
||||
*
|
||||
* @return void
|
||||
* @throws MyException Если не удалось подключиться ни к одному хосту
|
||||
*/
|
||||
public function connect(): void
|
||||
{
|
||||
@ -78,7 +78,6 @@ class PdoDriver
|
||||
|
||||
/**
|
||||
* @return PDO Подключается при первом обращении
|
||||
* @throws MyException
|
||||
*/
|
||||
public function pdo(): PDO
|
||||
{
|
||||
@ -92,7 +91,6 @@ class PdoDriver
|
||||
*
|
||||
* @param string $sql SQL-запрос
|
||||
* @return false|PDOStatement
|
||||
* @throws MyException
|
||||
*/
|
||||
public function prepare(string $sql): false|PDOStatement
|
||||
{
|
||||
@ -105,7 +103,6 @@ class PdoDriver
|
||||
* @param string $sql SQL-запрос
|
||||
* @param array $params Параметры для execute()
|
||||
* @return false|PDOStatement
|
||||
* @throws MyException
|
||||
*/
|
||||
public function query(string $sql, array $params = []): false|PDOStatement
|
||||
{
|
||||
@ -118,7 +115,6 @@ class PdoDriver
|
||||
/**
|
||||
* @param string $sql SQL-запрос
|
||||
* @return int Число задетых строк
|
||||
* @throws MyException
|
||||
*/
|
||||
public function exec(string $sql): int
|
||||
{
|
||||
@ -129,7 +125,6 @@ class PdoDriver
|
||||
* Начинает транзакцию; на вложенном уровне — SAVEPOINT.
|
||||
*
|
||||
* @return bool
|
||||
* @throws MyException
|
||||
*/
|
||||
public function beginTransaction(): bool
|
||||
{
|
||||
@ -146,7 +141,6 @@ class PdoDriver
|
||||
* Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT.
|
||||
*
|
||||
* @return bool
|
||||
* @throws MyException
|
||||
*/
|
||||
public function commit(): bool
|
||||
{
|
||||
@ -161,7 +155,6 @@ class PdoDriver
|
||||
* Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT.
|
||||
*
|
||||
* @return bool
|
||||
* @throws MyException
|
||||
*/
|
||||
public function rollback(): bool
|
||||
{
|
||||
@ -187,7 +180,7 @@ class PdoDriver
|
||||
*
|
||||
* @param callable $callback
|
||||
* @return mixed Результат $callback
|
||||
* @throws MyException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function transaction(callable $callback): mixed
|
||||
{
|
||||
@ -198,7 +191,7 @@ class PdoDriver
|
||||
$this->commit();
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->rollback();
|
||||
|
||||
throw $e;
|
||||
@ -207,7 +200,6 @@ class PdoDriver
|
||||
|
||||
/**
|
||||
* @return string|false ID последней вставленной строки
|
||||
* @throws MyException
|
||||
*/
|
||||
public function lastInsertId(): string|false
|
||||
{
|
||||
|
||||
@ -25,6 +25,12 @@ class Statement extends PDOStatement
|
||||
/** @var array<int|string,mixed> Значения, забинженные через bindValue()/execute() */
|
||||
protected array $_bound_params = [];
|
||||
|
||||
/**
|
||||
* @var bool Не логировать этот execute() в Profiler — для служебных запросов вроде EXPLAIN
|
||||
* внутри самой панели (ProfilerToolbar), которые иначе засоряли бы её же счётчик SQL.
|
||||
*/
|
||||
public bool $skip_profiling = false;
|
||||
|
||||
/**
|
||||
* Конструктор вызывается только изнутри PDO (PDO::ATTR_STATEMENT_CLASS) — не создавать напрямую.
|
||||
*
|
||||
@ -56,7 +62,9 @@ class Statement extends PDOStatement
|
||||
$start = microtime(true);
|
||||
$result = parent::execute($params);
|
||||
|
||||
if (!$this->skip_profiling) {
|
||||
Profiler::log($this->queryString, $this->_bound_params, (microtime(true) - $start) * 1000);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
namespace System\Classes;
|
||||
|
||||
use Services\Auth;
|
||||
use Services\DataBase\Classes\Profiler;
|
||||
use System\Classes\HTTP\HTTPException;
|
||||
use System\Classes\HTTP\Request as HTTPRequest;
|
||||
|
||||
@ -69,6 +70,42 @@ class Controller extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Для admin — заголовок X-Profiler с полной картиной запроса (время, память, список
|
||||
* SQL-запросов с временем каждого). Виден в devtools (Network → заголовки ответа) для
|
||||
* любого ответа, включая ajax — там HTML-панель ProfilerToolbar не рендерится вовсе
|
||||
* (renderContent()/json() не проходят через layout.html, где она подключена). Тело
|
||||
* ответа не трогаем осознанно — ajax-эндпоинты отдают JSON/HTML, менять их форму ради
|
||||
* дебага не нужно.
|
||||
*
|
||||
* @return void
|
||||
* @throws \JsonException
|
||||
*/
|
||||
protected function after(): void
|
||||
{
|
||||
$user = Auth::instance()->getUser();
|
||||
|
||||
if (($user['role'] ?? null) !== 'admin' || headers_sent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
|
||||
|
||||
$data = [
|
||||
'time_ms' => round($time_ms, 1),
|
||||
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
|
||||
'sql_count' => Profiler::count(),
|
||||
'sql_time_ms' => round(Profiler::totalTime(), 1),
|
||||
'sql' => array_map(
|
||||
static fn (array $entry) => ['sql' => $entry['sql'], 'time_ms' => round($entry['time_ms'], 2)],
|
||||
Profiler::entries()
|
||||
),
|
||||
];
|
||||
|
||||
header('X-Profiler: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Рендерит шаблон контента внутри layout вместе с верхним и боковым меню.
|
||||
*
|
||||
|
||||
@ -8,14 +8,19 @@
|
||||
|
||||
namespace System\Classes;
|
||||
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Кастомное исключение с подстановкой переменных в сообщение (strtr).
|
||||
* Зарегистрировано через set_exception_handler в Core::init().
|
||||
*
|
||||
* extends RuntimeException (не Exception) — PhpStorm по умолчанию считает RuntimeException
|
||||
* unchecked-исключением (см. "Unchecked Exceptions" в Settings | PHP | Analysis, встроенный
|
||||
* дефолт платформы, без какой-либо настройки проекта) и не требует @throws MyException
|
||||
* на каждом методе по всей цепочке вызовов, как для обычного Exception.
|
||||
*/
|
||||
class MyException extends Exception
|
||||
class MyException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* @param string $message Сообщение, может содержать плейсхолдеры для $variables
|
||||
|
||||
@ -109,7 +109,11 @@ class ProfilerToolbar
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = Database::instance()->query('EXPLAIN ' . $sql, $params)->fetchAll();
|
||||
// skip_profiling — иначе сам EXPLAIN попадёт в Profiler и засорит счётчик SQL панели.
|
||||
$stmt = Database::instance()->prepare('EXPLAIN ' . $sql);
|
||||
$stmt->skip_profiling = true;
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
} catch (\Throwable) {
|
||||
return '—';
|
||||
}
|
||||
@ -298,15 +302,26 @@ class ProfilerToolbar
|
||||
<div id="profiler-toolbar" style="position:fixed;left:0;right:0;bottom:0;z-index:99999;
|
||||
font:12px/1.4 monospace;background:#1b1b1b;color:#ddd;border-top:2px solid #444;">
|
||||
<style>
|
||||
/* Общий сброс — панель должна выглядеть одинаково независимо от того, что уже
|
||||
загружено на странице-хосте (Bootstrap на обычных страницах красит code в розовый
|
||||
и даёт h4 крупный шрифт; на error.html Bootstrap не подключён вовсе — там того
|
||||
же текста, наоборот, не хватало цвета из-за quirks mode без <!DOCTYPE html>).
|
||||
Задаём всё явно на каждом потомке, а не полагаемся на наследование. */
|
||||
#profiler-toolbar, #profiler-toolbar * {
|
||||
font-family:ui-monospace,"SF Mono","Cascadia Code",monospace;
|
||||
font-size:12px;font-weight:400;line-height:1.4;color:#ddd;
|
||||
margin:0;padding:0;text-align:left;background:none;border:0;box-sizing:border-box;
|
||||
}
|
||||
#profiler-toolbar code {color:#ff79c6;}
|
||||
#profiler-toolbar .ptb-table {width:100%;border-collapse:collapse;}
|
||||
#profiler-toolbar .ptb-table th, #profiler-toolbar .ptb-table td {padding:3px 10px;text-align:left;border-bottom:1px solid #333;vertical-align:top;}
|
||||
#profiler-toolbar .ptb-table th {color:#999;}
|
||||
#profiler-toolbar .ptb-explain {font-size:11px;}
|
||||
#profiler-toolbar .ptb-explain td, #profiler-toolbar .ptb-explain th {padding:2px 6px;}
|
||||
#profiler-toolbar .ptb-h4 {margin:8px 10px 2px;color:#8bc34a;}
|
||||
#profiler-toolbar .ptb-h4 {margin:8px 10px 2px;color:#8bc34a;font-weight:600;}
|
||||
#profiler-toolbar .ptb-pre {margin:4px 10px;white-space:pre-wrap;word-break:break-all;}
|
||||
#profiler-toolbar .ptb-empty {margin:4px 10px;color:#777;}
|
||||
#profiler-toolbar .ptb-tab-btn {background:none;border:none;color:#ddd;padding:6px 12px;cursor:pointer;font:inherit;border-bottom:2px solid transparent;}
|
||||
#profiler-toolbar .ptb-tab-btn {padding:6px 12px;cursor:pointer;border-bottom:2px solid transparent;}
|
||||
#profiler-toolbar .ptb-tab-btn-active {border-bottom-color:#8bc34a;color:#8bc34a;}
|
||||
</style>
|
||||
<div style="display:flex;gap:16px;align-items:center;padding:4px 12px;cursor:pointer;"
|
||||
|
||||
@ -45,7 +45,6 @@ abstract class Repository
|
||||
* @param string $table Таблица
|
||||
* @param class-string<T> $obj_class Класс строки (fetchObject()); по умолчанию — лёгкий Model
|
||||
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||
* @throws MyException
|
||||
*/
|
||||
public function __construct(string $table, string $obj_class = Model::class, ?string $connection = null)
|
||||
{
|
||||
@ -61,7 +60,6 @@ abstract class Repository
|
||||
* в одной внешней транзакции).
|
||||
*
|
||||
* @return bool
|
||||
* @throws MyException
|
||||
*/
|
||||
public function beginTransaction(): bool
|
||||
{
|
||||
@ -72,7 +70,6 @@ abstract class Repository
|
||||
* Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT.
|
||||
*
|
||||
* @return bool
|
||||
* @throws MyException
|
||||
*/
|
||||
public function commit(): bool
|
||||
{
|
||||
@ -83,7 +80,6 @@ abstract class Repository
|
||||
* Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT.
|
||||
*
|
||||
* @return bool
|
||||
* @throws MyException
|
||||
*/
|
||||
public function rollBack(): bool
|
||||
{
|
||||
@ -96,7 +92,6 @@ abstract class Repository
|
||||
*
|
||||
* @param callable $callback
|
||||
* @return mixed
|
||||
* @throws MyException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function transaction(callable $callback): mixed
|
||||
@ -120,7 +115,6 @@ abstract class Repository
|
||||
*
|
||||
* @param int|string $id
|
||||
* @return T|false
|
||||
* @throws MyException
|
||||
*/
|
||||
public function get(int|string $id): mixed
|
||||
{
|
||||
@ -137,7 +131,6 @@ abstract class Repository
|
||||
*
|
||||
* @param string $where SQL-условие после WHERE
|
||||
* @return T|false|null null, если $where пуст
|
||||
* @throws MyException
|
||||
*/
|
||||
public function getItemWhere(string $where): mixed
|
||||
{
|
||||
@ -159,7 +152,6 @@ abstract class Repository
|
||||
* @param string $sort ASC|DESC
|
||||
* @param int $fetch Режим PDO::FETCH_*; для FETCH_CLASS (по умолчанию) строки — $this->obj_class
|
||||
* @return bool|T[]
|
||||
* @throws MyException
|
||||
*/
|
||||
public function getList(
|
||||
string $cols, array $where = [], string $order = '', string $sort = 'ASC', int $fetch = PDO::FETCH_CLASS
|
||||
@ -198,7 +190,6 @@ abstract class Repository
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
* @return int lastInsertId
|
||||
* @throws MyException
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
@ -224,7 +215,6 @@ abstract class Repository
|
||||
*
|
||||
* @param array<string,mixed> $data [$primary_col обязателен]
|
||||
* @return int Число задетых строк
|
||||
* @throws MyException
|
||||
*/
|
||||
public function update(array $data): int
|
||||
{
|
||||
@ -254,7 +244,6 @@ abstract class Repository
|
||||
*
|
||||
* @param int|string $id
|
||||
* @return bool
|
||||
* @throws MyException
|
||||
*/
|
||||
public function delete(int|string $id): bool
|
||||
{
|
||||
@ -271,7 +260,6 @@ abstract class Repository
|
||||
* @param string $where SQL-условие после WHERE (с плейсхолдерами)
|
||||
* @param array<int|string,mixed> $params Параметры для плейсхолдеров
|
||||
* @return bool
|
||||
* @throws MyException
|
||||
*/
|
||||
public function deleteWhere(string $where, array $params): bool
|
||||
{
|
||||
|
||||
@ -46,7 +46,7 @@ class View
|
||||
* @param string|null $file Имя файла шаблона без расширения
|
||||
* @param string $dir Каталог шаблона относительно базовых путей
|
||||
* @param array|null $data Данные для шаблона
|
||||
* @throws MyException
|
||||
|
||||
*/
|
||||
public function __construct(?string $file = null, string $dir = '', ?array $data = null)
|
||||
{
|
||||
@ -66,9 +66,8 @@ class View
|
||||
* @param string $file Имя файла шаблона без расширения
|
||||
* @param string $dir Каталог шаблона (обязателен)
|
||||
* @return View
|
||||
* @throws MyException Если $dir пуст или файл не найден
|
||||
*/
|
||||
public function setFilename(string $file, string $dir = ''): View
|
||||
public function setFilename(string $file, string $dir = ''): self
|
||||
{
|
||||
if ($dir === '') {
|
||||
throw new MyException("Параметр dir не передан в View::setFilename для файла '$file'");
|
||||
|
||||
@ -38,3 +38,5 @@ $error_id = uniqid('error', true);
|
||||
</div>
|
||||
|
||||
<table><?=$xdebug_message?></table>
|
||||
|
||||
<?= \System\Classes\ProfilerToolbar::render() ?>
|
||||
|
||||
104
tests/Unit/ControllerAfterTest.php
Normal file
104
tests/Unit/ControllerAfterTest.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Services\Auth;
|
||||
use Services\DataBase\Classes\Profiler;
|
||||
use System\Classes\Config;
|
||||
use System\Classes\Controller;
|
||||
use System\Classes\Session;
|
||||
|
||||
class AfterTestController extends Controller
|
||||
{
|
||||
protected bool $_csrf_protection = false;
|
||||
|
||||
public function pingAction(): string
|
||||
{
|
||||
return 'pong';
|
||||
}
|
||||
}
|
||||
|
||||
class ControllerAfterTest extends TestCase
|
||||
{
|
||||
private string $users_file;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (!function_exists('xdebug_get_headers')) {
|
||||
$this->markTestSkipped('Нужен xdebug_get_headers() для проверки заголовков ответа');
|
||||
}
|
||||
|
||||
header_remove();
|
||||
|
||||
$this->users_file = sys_get_temp_dir() . '/bicycle_after_users_' . uniqid() . '.php';
|
||||
file_put_contents($this->users_file, '<?php return ' . var_export([
|
||||
'admin' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'admin'],
|
||||
'bob' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'manager'],
|
||||
], true) . ';');
|
||||
|
||||
Config::set('auth', ['driver' => 'file', 'users_file' => $this->users_file, 'session_key' => 'auth_user']);
|
||||
Profiler::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
header_remove();
|
||||
unlink($this->users_file);
|
||||
Session::instance()->destroy();
|
||||
Profiler::reset();
|
||||
|
||||
foreach ([Session::class, Auth::class] as $class) {
|
||||
$ref = new ReflectionClass($class);
|
||||
$prop = $ref->getProperty($class === Session::class ? 'instances' : '_instances');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, []);
|
||||
}
|
||||
|
||||
$ref = new ReflectionClass(Config::class);
|
||||
$prop = $ref->getProperty('config');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, null);
|
||||
}
|
||||
|
||||
public function testNoXProfilerHeaderForGuest(): void
|
||||
{
|
||||
(new AfterTestController())->executeAction('pingAction');
|
||||
|
||||
$headers = implode("\n", xdebug_get_headers());
|
||||
$this->assertStringNotContainsString('X-Profiler:', $headers);
|
||||
}
|
||||
|
||||
public function testNoXProfilerHeaderForNonAdmin(): void
|
||||
{
|
||||
Session::instance()->set('auth_user', 'bob');
|
||||
|
||||
(new AfterTestController())->executeAction('pingAction');
|
||||
|
||||
$headers = implode("\n", xdebug_get_headers());
|
||||
$this->assertStringNotContainsString('X-Profiler:', $headers);
|
||||
}
|
||||
|
||||
public function testXProfilerHeaderForAdminContainsSqlSummary(): void
|
||||
{
|
||||
Session::instance()->set('auth_user', 'admin');
|
||||
Profiler::log('SELECT * FROM budget', [], 12.34);
|
||||
|
||||
(new AfterTestController())->executeAction('pingAction');
|
||||
|
||||
$header = null;
|
||||
foreach (xdebug_get_headers() as $h) {
|
||||
if (str_starts_with($h, 'X-Profiler:')) {
|
||||
$header = $h;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull($header);
|
||||
|
||||
$data = json_decode(substr($header, strlen('X-Profiler: ')), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->assertSame(1, $data['sql_count']);
|
||||
$this->assertSame('SELECT * FROM budget', $data['sql'][0]['sql']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user