feat: add routes, lang, tests, stubs, docs, and docker configurations

This commit is contained in:
2026-05-21 16:05:16 +07:00
parent fad70d096b
commit 28a06315b8
3385 changed files with 177070 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
<?php
test('that true is true', function () {
expect(true)->toBeTrue();
});
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Exceptions\BackupOperationException;
use App\Exceptions\MonitoringException;
use App\Exceptions\SystemConfigException;
test('SystemConfigException factories produce typed messages', function () {
$e = SystemConfigException::unknownKey('foo_bar');
expect($e)->toBeInstanceOf(SystemConfigException::class);
expect($e->getMessage())->toContain('foo_bar');
$e2 = SystemConfigException::imageUploadFailed('app_logo', 'no write perm');
expect($e2->getMessage())->toContain('app_logo')->toContain('no write perm');
});
test('BackupOperationException factories produce typed messages', function () {
expect(BackupOperationException::missingBinary('pg_dump')->getMessage())
->toContain('pg_dump');
expect(BackupOperationException::diskNotConfigured('s3')->getMessage())
->toContain('s3');
expect(BackupOperationException::restoreFailed('crc mismatch')->getMessage())
->toContain('crc mismatch');
});
test('MonitoringException factories produce typed messages', function () {
expect(MonitoringException::unsupportedOs('BeOS')->getMessage())
->toContain('BeOS');
expect(MonitoringException::probeFailed('cpu', 'no nproc')->getMessage())
->toContain('cpu')->toContain('no nproc');
});
test('all exception types extend RuntimeException', function () {
expect(SystemConfigException::unknownKey('x'))->toBeInstanceOf(RuntimeException::class);
expect(BackupOperationException::missingBinary('x'))->toBeInstanceOf(RuntimeException::class);
expect(MonitoringException::unsupportedOs('x'))->toBeInstanceOf(RuntimeException::class);
});
+75
View File
@@ -0,0 +1,75 @@
<?php
use App\Helpers\SessionHelper;
test('parseUserAgent returns Unknown for null', function () {
$result = SessionHelper::parseUserAgent(null);
expect($result['browser'])->toBe('Unknown');
expect($result['os'])->toBe('Unknown');
expect($result['browser_icon'])->toBe('bi-question-circle');
expect($result['os_icon'])->toBe('bi-question-circle');
});
test('parseUserAgent returns Unknown for empty string', function () {
expect(SessionHelper::parseUserAgent('')['browser'])->toBe('Unknown');
});
test('parseUserAgent detects Android', function () {
$ua = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/120.0.0.0 Mobile Safari/537.36';
$r = SessionHelper::parseUserAgent($ua);
expect($r['os'])->toBe('Android');
expect($r['os_icon'])->toBe('bi-phone');
});
test('parseUserAgent detects iOS', function () {
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15';
expect(SessionHelper::parseUserAgent($ua)['os'])->toBe('iOS');
});
test('parseUserAgent detects Windows', function () {
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0';
$r = SessionHelper::parseUserAgent($ua);
expect($r['os'])->toBe('Windows');
expect($r['os_icon'])->toBe('bi-windows');
});
test('parseUserAgent detects macOS', function () {
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
$r = SessionHelper::parseUserAgent($ua);
expect($r['os'])->toBe('macOS');
expect($r['os_icon'])->toBe('bi-apple');
});
test('parseUserAgent detects Linux desktop', function () {
$ua = 'Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/121.0';
$r = SessionHelper::parseUserAgent($ua);
expect($r['os'])->toBe('Linux');
});
test('parseUserAgent detects Edge before Chrome', function () {
$ua = 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120 Safari/537.36 Edg/120.0';
expect(SessionHelper::parseUserAgent($ua)['browser'])->toBe('Edge');
});
test('parseUserAgent detects Chrome', function () {
$ua = 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0 Safari/537.36';
expect(SessionHelper::parseUserAgent($ua)['browser'])->toBe('Chrome');
});
test('parseUserAgent detects Firefox', function () {
$ua = 'Mozilla/5.0 (Windows NT 10.0; rv:121.0) Gecko/20100101 Firefox/121.0';
expect(SessionHelper::parseUserAgent($ua)['browser'])->toBe('Firefox');
});
test('parseUserAgent detects Safari (without Chrome)', function () {
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15';
expect(SessionHelper::parseUserAgent($ua)['browser'])->toBe('Safari');
});
test('parseUserAgent falls back to bi-globe icon when browser unknown', function () {
$ua = 'Some-Strange-Bot/1.0';
$r = SessionHelper::parseUserAgent($ua);
expect($r['browser'])->toBe('Unknown');
expect($r['browser_icon'])->toBe('bi-globe');
});
@@ -0,0 +1,74 @@
<?php
use App\Services\Monitoring\MonitoringFormatter;
test('bytes returns B for sub-KB values', function () {
expect(MonitoringFormatter::bytes(0))->toBe('0 B');
expect(MonitoringFormatter::bytes(500))->toBe('500 B');
expect(MonitoringFormatter::bytes(1023))->toBe('1023 B');
});
test('bytes converts to KB, MB, GB, TB', function () {
expect(MonitoringFormatter::bytes(1024))->toBe('1 KB');
expect(MonitoringFormatter::bytes(1024 * 1024))->toBe('1 MB');
expect(MonitoringFormatter::bytes(1024 * 1024 * 1024))->toBe('1 GB');
expect(MonitoringFormatter::bytes(1024 ** 4))->toBe('1 TB');
});
test('bytes caps at TB for very large values', function () {
expect(MonitoringFormatter::bytes(1024 ** 5))->toBe('1024 TB');
});
test('bytes respects precision parameter', function () {
expect(MonitoringFormatter::bytes(1536, 0))->toBe('2 KB');
expect(MonitoringFormatter::bytes(1536, 1))->toBe('1.5 KB');
expect(MonitoringFormatter::bytes(1536, 3))->toBe('1.5 KB');
});
test('bytes clamps negative input to zero', function () {
expect(MonitoringFormatter::bytes(-100))->toBe('0 B');
});
test('duration shows < 1m for sub-minute values', function () {
expect(MonitoringFormatter::duration(0))->toBe('< 1m');
expect(MonitoringFormatter::duration(59))->toBe('< 1m');
});
test('duration formats minutes only', function () {
expect(MonitoringFormatter::duration(60))->toBe('1m');
expect(MonitoringFormatter::duration(60 * 59))->toBe('59m');
});
test('duration formats hours and minutes', function () {
expect(MonitoringFormatter::duration(3600))->toBe('1h');
expect(MonitoringFormatter::duration(3600 + 60 * 30))->toBe('1h 30m');
});
test('duration formats days, hours, minutes', function () {
expect(MonitoringFormatter::duration(86400))->toBe('1d');
expect(MonitoringFormatter::duration(86400 + 3600 * 5 + 60 * 30))->toBe('1d 5h 30m');
});
test('duration skips zero components', function () {
expect(MonitoringFormatter::duration(86400 + 60))->toBe('1d 1m');
expect(MonitoringFormatter::duration(3600 * 2))->toBe('2h');
});
test('parseBytes round-trips whole units', function () {
expect(MonitoringFormatter::parseBytes('1 KB'))->toBe(1024.0);
expect(MonitoringFormatter::parseBytes('1 MB'))->toBe(1048576.0);
expect(MonitoringFormatter::parseBytes('2 GB'))->toBe(2.0 * 1024 * 1024 * 1024);
expect(MonitoringFormatter::parseBytes('512 B'))->toBe(512.0);
});
test('parseBytes handles plain numeric strings', function () {
expect(MonitoringFormatter::parseBytes('4096'))->toBe(4096.0);
});
test('parseBytes ignores unknown units', function () {
expect(MonitoringFormatter::parseBytes('10 XB'))->toBe(10.0);
});
test('parseBytes handles fractional values', function () {
expect(MonitoringFormatter::parseBytes('1.5 KB'))->toBe(1536.0);
});
+112
View File
@@ -0,0 +1,112 @@
<?php
use App\Services\System\ActivityFormatter;
test('getFriendlyModelName returns System for null', function () {
expect(ActivityFormatter::getFriendlyModelName(null))->toBe('System');
});
test('getFriendlyModelName maps known classes', function () {
expect(ActivityFormatter::getFriendlyModelName('App\\Models\\SystemSetting'))->toBe('System Config');
expect(ActivityFormatter::getFriendlyModelName('App\\Models\\User'))->toBe('User Profile');
expect(ActivityFormatter::getFriendlyModelName('App\\Models\\Role'))->toBe('Access Role');
expect(ActivityFormatter::getFriendlyModelName('App\\Models\\Permission'))->toBe('Access Permission');
});
test('getFriendlyModelName headlines unknown class basename', function () {
expect(ActivityFormatter::getFriendlyModelName('App\\Models\\OtherSetting'))->toBe('Other Setting');
});
test('getEventBadgeClass returns correct class per event', function (string $event, string $expected) {
expect(ActivityFormatter::getEventBadgeClass($event))->toBe($expected);
})->with([
['created', 'text-bg-success'],
['updated', 'text-bg-warning'],
['deleted', 'text-bg-danger'],
['restored', 'text-bg-info'],
['login', 'text-bg-info'],
['logout', 'text-bg-secondary'],
['password_changed', 'text-bg-primary'],
['unknown_event', 'text-bg-theme-1'],
]);
test('getEventBadgeClass is case insensitive', function () {
expect(ActivityFormatter::getEventBadgeClass('CREATED'))->toBe('text-bg-success');
expect(ActivityFormatter::getEventBadgeClass('Login'))->toBe('text-bg-info');
});
test('getEventIcon returns correct icon per event', function (string $event, string $expected) {
expect(ActivityFormatter::getEventIcon($event))->toBe($expected);
})->with([
['created', 'bi-plus-circle'],
['updated', 'bi-pencil-square'],
['deleted', 'bi-trash'],
['logout', 'bi-box-arrow-right'],
['unknown', 'bi-info-circle'],
]);
test('formatChanges on creation shows all non-sensitive fields', function () {
$result = ActivityFormatter::formatChanges([
'old' => [],
'attributes' => ['name' => 'Jane', 'email' => 'j@x.com', 'password' => 'secret'],
]);
expect($result)->toHaveCount(2);
$fields = array_column($result, 'field');
expect($fields)->toContain('Name');
expect($fields)->toContain('Email');
expect($fields)->not->toContain('Password');
});
test('formatChanges on update returns only changed fields', function () {
$result = ActivityFormatter::formatChanges([
'old' => ['name' => 'Old', 'email' => 'same@x.com'],
'attributes' => ['name' => 'New', 'email' => 'same@x.com'],
]);
expect($result)->toHaveCount(1);
expect($result[0]['field'])->toBe('Name');
expect($result[0]['old'])->toBe('Old');
expect($result[0]['new'])->toBe('New');
});
test('formatChanges filters out sensitive fields like password and remember_token', function () {
$result = ActivityFormatter::formatChanges([
'old' => ['password' => 'a', 'remember_token' => 'b', '2fa_secret' => 'c', 'api_token' => 'd'],
'attributes' => ['password' => 'a2', 'remember_token' => 'b2', '2fa_secret' => 'c2', 'api_token' => 'd2'],
]);
expect($result)->toBeEmpty();
});
test('formatChanges formats null, bool, array, empty string', function () {
$result = ActivityFormatter::formatChanges([
'old' => [],
'attributes' => [
'a_null' => null,
'a_bool_true' => true,
'a_bool_false' => false,
'a_array' => ['x' => 1],
'a_empty' => '',
],
]);
$byField = collect($result)->keyBy('field');
expect($byField['A Null']['new'])->toBe('NULL');
expect($byField['A Bool True']['new'])->toBe('TRUE');
expect($byField['A Bool False']['new'])->toBe('FALSE');
expect($byField['A Array']['new'])->toBe(json_encode(['x' => 1], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
expect($byField['A Empty']['new'])->toBe('[empty]');
});
test('formatChanges truncates strings longer than 200 chars', function () {
$long = str_repeat('x', 250);
$result = ActivityFormatter::formatChanges([
'old' => [],
'attributes' => ['big' => $long],
]);
expect($result[0]['new'])->toEndWith('...');
expect(strlen($result[0]['new']))->toBe(200);
});
@@ -0,0 +1,87 @@
<?php
use App\Services\SystemConfig\SettingValueCaster;
test('normalize bool accepts truthy and falsy values', function (mixed $input, bool $expected) {
expect(SettingValueCaster::normalize($input, 'bool'))->toBe($expected);
})->with([
['true', true],
['1', true],
[1, true],
[true, true],
['on', true],
['false', false],
['0', false],
[0, false],
[false, false],
['', false],
]);
test('normalize int returns null for empty string and null', function () {
expect(SettingValueCaster::normalize('', 'int'))->toBeNull();
expect(SettingValueCaster::normalize(null, 'int'))->toBeNull();
});
test('normalize int casts numeric strings', function () {
expect(SettingValueCaster::normalize('42', 'int'))->toBe(42);
expect(SettingValueCaster::normalize('0', 'int'))->toBe(0);
});
test('normalize float casts numeric strings', function () {
expect(SettingValueCaster::normalize('3.14', 'float'))->toBe(3.14);
});
test('normalize json passes through arrays and parses strings', function () {
expect(SettingValueCaster::normalize(['a' => 1], 'json'))->toBe(['a' => 1]);
expect(SettingValueCaster::normalize('{"b":2}', 'json'))->toBe(['b' => 2]);
expect(SettingValueCaster::normalize('not-json', 'json'))->toBe([]);
});
test('normalize string trims whitespace', function () {
expect(SettingValueCaster::normalize(' hello ', 'string'))->toBe('hello');
});
test('normalize image_path returns trimmed string or null', function () {
expect(SettingValueCaster::normalize(' assets/img/logo.png ', 'image_path'))->toBe('assets/img/logo.png');
expect(SettingValueCaster::normalize(null, 'image_path'))->toBeNull();
});
test('serialize bool emits 1 or 0', function () {
expect(SettingValueCaster::serialize(true))->toBe('1');
expect(SettingValueCaster::serialize(false))->toBe('0');
});
test('serialize null returns null', function () {
expect(SettingValueCaster::serialize(null))->toBeNull();
});
test('serialize array emits json', function () {
expect(SettingValueCaster::serialize(['x' => 'y']))->toBe('{"x":"y"}');
});
test('serialize scalar returns string', function () {
expect(SettingValueCaster::serialize(42))->toBe('42');
expect(SettingValueCaster::serialize('abc'))->toBe('abc');
});
test('deserialize round-trips bool, int, float, json', function () {
expect(SettingValueCaster::deserialize('1', 'bool'))->toBeTrue();
expect(SettingValueCaster::deserialize('0', 'bool'))->toBeFalse();
expect(SettingValueCaster::deserialize('42', 'int'))->toBe(42);
expect(SettingValueCaster::deserialize('3.14', 'float'))->toBe(3.14);
expect(SettingValueCaster::deserialize('{"a":1}', 'json'))->toBe(['a' => 1]);
});
test('deserialize null returns null', function () {
expect(SettingValueCaster::deserialize(null, 'bool'))->toBeNull();
expect(SettingValueCaster::deserialize(null, 'json'))->toBeNull();
});
test('isUnchanged compares serialized form', function () {
expect(SettingValueCaster::isUnchanged(true, '1'))->toBeTrue();
expect(SettingValueCaster::isUnchanged(false, '0'))->toBeTrue();
expect(SettingValueCaster::isUnchanged(null, null))->toBeTrue();
expect(SettingValueCaster::isUnchanged('abc', 'abc'))->toBeTrue();
expect(SettingValueCaster::isUnchanged('abc', 'xyz'))->toBeFalse();
expect(SettingValueCaster::isUnchanged(['a' => 1], ['a' => 1]))->toBeTrue();
});