This commit is contained in:
Egor Isaev 2026-08-07 14:01:09 +03:00
parent 8fdcd0b901
commit 9e20ae6c5f
13 changed files with 75 additions and 12 deletions

View File

@ -14,3 +14,4 @@
- [Roadmap: слой БД](roadmap.md) — MariaDB/PDO реализован и протестирован (реальное подключение); Mongo/Elasticsearch написаны, ждут внешней инфры от пользователя - [Roadmap: слой БД](roadmap.md) — MariaDB/PDO реализован и протестирован (реальное подключение); Mongo/Elasticsearch написаны, ждут внешней инфры от пользователя
- [Работа с референс-кодом из других проектов](feedback_reference_code_handling.md) — пользователь кидает код из eoffice_v3 и т.п.: адаптировать критически, не копировать вслепую, но уважать явные повторные сигналы по неймингу - [Работа с референс-кодом из других проектов](feedback_reference_code_handling.md) — пользователь кидает код из eoffice_v3 и т.п.: адаптировать критически, не копировать вслепую, но уважать явные повторные сигналы по неймингу
- [Идея: CLI-точка входа](idea_cli_entrypoint.md) — отложенный cli.php для миграций/крона - [Идея: CLI-точка входа](idea_cli_entrypoint.md) — отложенный cli.php для миграций/крона
- [Никакого inline JS в HTML](feedback_no_inline_js.md) — только jQuery `.on()` в отдельном .js-файле, даже для мелочи вроде onchange

View File

@ -0,0 +1,22 @@
---
name: feedback-no-inline-js
description: Никогда не писать inline JS-атрибуты (onchange/onclick/...) в HTML — только jQuery через .on()
metadata:
type: feedback
---
Пользователь прямо запретил inline JS-обработчики в HTML (`onchange="this.form.submit()"` и подобные) —
даже для мелочи вроде автосабмита `<select>` при смене значения.
**Why:** Явно сказал «никогда не пиши onchange="this.form.submit() такое в html» и прислал пример из
своего другого проекта — везде используется jQuery с делегированием событий через `.on()` на контейнер
(`$('#content').on('click', '#selector', fn)`), а не атрибуты `onXxx=` в разметке.
**How to apply:** Для любого интерактивного поведения (submit по смене select, клики, любые обработчики)
писать JS в отдельном файле (`App/media/js/*.js`, подключается через `$this->setScript('name.js')` — см.
`System\Classes\View`), с jQuery-биндингом `$(function () { $(container).on(event, selector, handler); })`.
Никаких `onclick=`/`onchange=`/`onsubmit=` и т.п. атрибутов в `.html`-шаблонах — даже одна строчка.
Это касается и уже написанного `System\Classes\ProfilerToolbar` (там сейчас `onclick` инлайном для
переключения панели/вкладок, самодостаточности ради) — при следующей правке этого файла стоит переписать
на тот же паттерн, если пользователь укажет, что это тоже нужно поправить (сейчас не просил явно).

View File

@ -44,6 +44,7 @@ return [
], ],
'log' => [ 'log' => [
'enabled' => false,
'path' => APPPATH . '/logs', 'path' => APPPATH . '/logs',
'threshold' => 'debug', 'threshold' => 'debug',
], ],

6
App/media/js/logs.js Normal file
View File

@ -0,0 +1,6 @@
$(function () {
// Смена даты/канала/уровня — сразу отправляет форму; текст ищем по кнопке "Найти".
$('#logs-filters').on('change', 'select[name="date"], select[name="channel"], select[name="level"]', function () {
$(this).closest('form').trigger('submit');
});
});

View File

@ -15,10 +15,12 @@ $level_class = static fn(string $l): string => [
'info' => 'primary', 'info' => 'primary',
'debug' => 'secondary', 'debug' => 'secondary',
][$l] ?? 'secondary'; ][$l] ?? 'secondary';
$this->setScript('logs.js');
?> ?>
<h1 class="h3 mb-3">Логи</h1> <h1 class="h3 mb-3">Логи</h1>
<form method="get" class="row g-2 mb-3"> <form method="get" class="row g-2 mb-3" id="logs-filters">
<div class="col-auto"> <div class="col-auto">
<select name="date" class="form-select"> <select name="date" class="form-select">
<?php if (!in_array($filters['date'], $dates, true)): ?> <?php if (!in_array($filters['date'], $dates, true)): ?>

View File

@ -495,6 +495,8 @@ Log::error('Сбой оплаты');
Log::debug(...); Log::warning(...); Log::debug(...); Log::warning(...);
``` ```
- Вкл/выкл целиком — `Log::$enabled` → `Config::get('log','enabled')` → `true` по умолчанию;
`false` — `write()` ничего не пишет ни в один файл, независимо от уровня/порога.
- Уровни: `debug(100) < info(200) < warning(300) < error(400)`. Пишутся только уровни не ниже - Уровни: `debug(100) < info(200) < warning(300) < error(400)`. Пишутся только уровни не ниже
порога `Log::$threshold` (по умолчанию из `Config::get('log','threshold')` → `debug`). порога `Log::$threshold` (по умолчанию из `Config::get('log','threshold')` → `debug`).
- Канал по уровню: `info`/`debug` → файл `action-…`, `warning`/`error` → файл `error-…`. - Канал по уровню: `info`/`debug` → файл `action-…`, `warning`/`error` → файл `error-…`.
@ -620,7 +622,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии | | `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии |
| `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки | | `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/ControllerAfterTest.php` | `after()` веб-`Controller`: заголовок `X-Profiler` только для admin, содержит SQL-сводку — через `xdebug_get_headers()`, иначе `markTestSkipped()` |
| `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` | | `tests/Unit/LogTest.php` | уровни/порог, `enabled` (выкл — ничего не пишет), каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` |
| `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` | | `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` |
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла | | `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение | | `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
@ -636,7 +638,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `tests/Unit/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` | | `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 | | `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT |
**247 тестов, 384 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).** **248 тестов, 386 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).**
### Frontend dependencies (через Composer) ### Frontend dependencies (через Composer)

View File

@ -40,6 +40,9 @@ class Log
self::ERROR => 'error', self::ERROR => 'error',
]; ];
/** @var bool|null Писать ли логи вообще; null → Config('log','enabled') → true */
public static ?bool $enabled = null;
/** @var string|null Каталог логов; null → Config('log','path') → APPPATH/logs */ /** @var string|null Каталог логов; null → Config('log','path') → APPPATH/logs */
public static ?string $directory = null; public static ?string $directory = null;
@ -59,6 +62,10 @@ class Log
*/ */
public static function write(string $level, string $message, array $variables = []): void public static function write(string $level, string $message, array $variables = []): void
{ {
if (!self::enabled()) {
return;
}
if (self::weight($level) < self::weight(self::threshold())) { if (self::weight($level) < self::weight(self::threshold())) {
return; return;
} }
@ -68,8 +75,8 @@ class Log
} }
$dir = self::directory(); $dir = self::directory();
if (!is_dir($dir)) { if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
mkdir($dir, 0775, true); return;
} }
$line = sprintf("[%s] %s: %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message); $line = sprintf("[%s] %s: %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message);
@ -143,6 +150,16 @@ class Log
); );
} }
/**
* Писать ли логи вообще (из свойства, конфига или дефолт true).
*
* @return bool
*/
private static function enabled(): bool
{
return self::$enabled ?? Config::get('log', 'enabled') ?? true;
}
/** /**
* Минимальный уровень записи (из свойства, конфига или дефолт debug). * Минимальный уровень записи (из свойства, конфига или дефолт debug).
* *

View File

@ -32,7 +32,7 @@ class ControllerAfterTest extends TestCase
header_remove(); header_remove();
$this->users_file = sys_get_temp_dir() . '/bicycle_after_users_' . uniqid() . '.php'; $this->users_file = sys_get_temp_dir() . '/bicycle_after_users_' . uniqid('', true) . '.php';
file_put_contents($this->users_file, '<?php return ' . var_export([ file_put_contents($this->users_file, '<?php return ' . var_export([
'admin' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'admin'], 'admin' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'admin'],
'bob' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'manager'], 'bob' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'manager'],

View File

@ -13,7 +13,7 @@ class FileAuthDriverTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->file = sys_get_temp_dir() . '/bicycle_auth_users_' . uniqid() . '.php'; $this->file = sys_get_temp_dir() . '/bicycle_auth_users_' . uniqid('', true) . '.php';
file_put_contents($this->file, '<?php return ' . var_export([ file_put_contents($this->file, '<?php return ' . var_export([
'bob' => [ 'bob' => [
@ -108,7 +108,7 @@ class FileAuthDriverTest extends TestCase
public function testMissingUsersFileBehavesAsEmptyList(): void public function testMissingUsersFileBehavesAsEmptyList(): void
{ {
$driver = new FileAuthDriver(sys_get_temp_dir() . '/does_not_exist_' . uniqid() . '.php'); $driver = new FileAuthDriver(sys_get_temp_dir() . '/does_not_exist_' . uniqid('', true) . '.php');
$this->assertFalse($driver->login('bob', 'secret')); $this->assertFalse($driver->login('bob', 'secret'));
} }
} }

View File

@ -13,7 +13,7 @@ class FileLogReaderTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->dir = sys_get_temp_dir() . '/bicycle_reader_' . uniqid(); $this->dir = sys_get_temp_dir() . '/bicycle_reader_' . uniqid('', true);
$this->today = date('Y-m-d'); $this->today = date('Y-m-d');
mkdir($this->dir); mkdir($this->dir);
Log::$directory = $this->dir; Log::$directory = $this->dir;

View File

@ -11,9 +11,10 @@ class LogTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->dir = sys_get_temp_dir() . '/bicycle_log_' . uniqid(); $this->dir = sys_get_temp_dir() . '/bicycle_log_' . uniqid('', true);
Log::$directory = $this->dir; Log::$directory = $this->dir;
Log::$threshold = Log::DEBUG; Log::$threshold = Log::DEBUG;
Log::$enabled = true;
} }
protected function tearDown(): void protected function tearDown(): void
@ -26,6 +27,7 @@ class LogTest extends TestCase
} }
Log::$directory = null; Log::$directory = null;
Log::$threshold = null; Log::$threshold = null;
Log::$enabled = null;
} }
private function logFile(string $channel): string private function logFile(string $channel): string
@ -64,6 +66,16 @@ class LogTest extends TestCase
$this->assertStringContainsString('WARNING: предупреждение', file_get_contents($this->logFile('error'))); $this->assertStringContainsString('WARNING: предупреждение', file_get_contents($this->logFile('error')));
} }
public function testDisabledSkipsWritingEntirely(): void
{
Log::$enabled = false;
Log::error('не должно записаться');
$this->assertFileDoesNotExist($this->logFile('action'));
$this->assertFileDoesNotExist($this->logFile('error'));
}
public function testThresholdFiltersLowerLevels(): void public function testThresholdFiltersLowerLevels(): void
{ {
Log::$threshold = Log::WARNING; Log::$threshold = Log::WARNING;

View File

@ -18,7 +18,7 @@ class ProfilerToolbarTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->users_file = sys_get_temp_dir() . '/bicycle_toolbar_users_' . uniqid() . '.php'; $this->users_file = sys_get_temp_dir() . '/bicycle_toolbar_users_' . uniqid('', true) . '.php';
file_put_contents($this->users_file, '<?php return ' . var_export([ file_put_contents($this->users_file, '<?php return ' . var_export([
'admin' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'admin'], 'admin' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'admin'],
'bob' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'manager'], 'bob' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'manager'],

View File

@ -12,7 +12,7 @@ class ViewTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->tmpFile = sys_get_temp_dir() . '/bicycle_view_' . uniqid() . '.html'; $this->tmpFile = sys_get_temp_dir() . '/bicycle_view_' . uniqid('', true) . '.html';
} }
protected function tearDown(): void protected function tearDown(): void