diff --git a/docs/server-audit.md b/docs/server-audit.md index 00f4ab2..6dda026 100644 --- a/docs/server-audit.md +++ b/docs/server-audit.md @@ -525,15 +525,20 @@ | `ScriptsRESTAPIControllerValidationTest` | `run_action_script`, `set_*_state`, `place_in_area` validation | ✅ 10 тестов | | `SessionsTest` | `create()`, `get_session_by_token()`, `close()`, статус | ✅ 4 теста | | `UtilsTest` | `response_error`, `response_success`, `table_row_is_exists`, `generate_token`, `dayname_translate`, `fast_ping_tcp` | ✅ 7 тестов | +| `DeviceAPIBaseRetryTest` | retry/backoff с mock cURL, invalid JSON, `get_status` non-200, token requirement | ✅ 7 тестов | +| `AppAuthGuardTest` | `check_api_auth()` — Bearer/Cookie token, missing/invalid token, rate limit 429, window reset | ✅ 7 тестов | +| `DevicesModelTransactionTest` | `connect_new_device` happy path, rollback, setup mode check, device not found | ✅ 4 теста | +| `AreasControllerHappyPathTest` | `new_area`, `areas_list`, `update_display_name`, `update_alias`, `remove_area`, `place_in_area`, `exists_types` | ✅ 7 тестов | +| `ScriptsModelStateTest` | `get_scopes_list`, `script_state`, `enable/disable_script`, `set_script_state` errors | ✅ 8 тестов | -**Итого:** 80 тестов, 202 ассерта — все проходят. +**Итого:** 113 тестов, 285 ассертов — все проходят. ### Что ещё нужно покрыть -- `DeviceAPI\Base` — retry/backoff (mock cURL). -- Транзакции в `Models\Devices`. -- `App::api_auth_guard()` и `RateLimiter` integration (требует mock `$_SERVER` / `exit`). -- Controller happy-path (success flows) — создание/обновление сущностей. -- `Models\Scripts` — `get_scopes_list`, `script_state`, `enable/disable_script`. +- `DeviceAPI\Base` happy-path с реальным HTTP (интеграционные). +- `Models\Devices` — `remove_device`, `reboot_device` happy-path. +- `App::api_auth_guard()` exit/headers integration (end-to-end). +- `EventsController` валидация. +- `DeviceScanner` batch scanning. --- diff --git a/server/SHServ/App.php b/server/SHServ/App.php index de9e06e..4b4d36e 100644 --- a/server/SHServ/App.php +++ b/server/SHServ/App.php @@ -57,24 +57,29 @@ $this -> factory = new Factory(); } - public function api_auth_guard(): void { + /** + * Проверить авторизацию и rate limit для API запроса. + * + * @return array|null Null если запрос проходит, массив ['code' => int, 'body' => array] если нужно отклонить. + */ + public function check_api_auth(): ?array { $uri = $_SERVER['REQUEST_URI'] ?? ''; if (strpos($uri, '/api/v1/') !== 0) { - return; + return null; } // Rate limiting: 60 req/min per IP $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $rate_limiter = new \SHServ\Tools\RateLimiter(60, 60); if (!$rate_limiter -> check($ip)) { - header('Content-Type: application/json'); - http_response_code(429); - echo json_encode([ - 'status' => false, - 'error_alias' => 'rate_limit_exceeded', - 'msg' => 'Too many requests' - ]); - exit; + return [ + 'code' => 429, + 'body' => [ + 'status' => false, + 'error_alias' => 'rate_limit_exceeded', + 'msg' => 'Too many requests' + ] + ]; } $token = null; @@ -89,15 +94,29 @@ } if (!$token || !app() -> sessions -> get_session_by_token($token)) { - header('Content-Type: application/json'); - http_response_code(401); - echo json_encode([ - 'status' => false, - 'error_alias' => 'unauthorized', - 'msg' => 'Authentication required' - ]); - exit; + return [ + 'code' => 401, + 'body' => [ + 'status' => false, + 'error_alias' => 'unauthorized', + 'msg' => 'Authentication required' + ] + ]; } + + return null; + } + + public function api_auth_guard(): void { + $response = $this -> check_api_auth(); + if ($response === null) { + return; + } + + header('Content-Type: application/json'); + http_response_code($response['code']); + echo json_encode($response['body']); + exit; } public function root_folder(): String { @@ -142,4 +161,6 @@ } } -new App(); \ No newline at end of file +if (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST) { + new App(); +} \ No newline at end of file diff --git a/server/SHServ/Models/Devices.php b/server/SHServ/Models/Devices.php index 5591c31..c102199 100644 --- a/server/SHServ/Models/Devices.php +++ b/server/SHServ/Models/Devices.php @@ -8,10 +8,12 @@ use \SHServ\Entities\DeviceAuth; class Devices extends \SHServ\Middleware\Model { - public function connect_new_device(String $device_ip, String $alias = "", String $name = "", String $description = ""): Device | Array { + public function connect_new_device(String $device_ip, String $alias = "", String $name = "", String $description = "", ?Base $device_api = null): Device | Array { // validate device - $device_api = new Base($device_ip); + if ($device_api === null) { + $device_api = new Base($device_ip); + } $device_info = $device_api -> get_about(); if(!$device_info["data"]) { return [ diff --git a/server/SHServ/Tools/DeviceAPI/Base.php b/server/SHServ/Tools/DeviceAPI/Base.php index dd23cc7..e3bee9a 100644 --- a/server/SHServ/Tools/DeviceAPI/Base.php +++ b/server/SHServ/Tools/DeviceAPI/Base.php @@ -191,13 +191,12 @@ $error_message = null; while ($attempt < self::MAX_RETRIES) { - $raw_response = curl_exec($ch); + [$raw_response, $error_message] = $this->executeCurl($ch); if ($raw_response !== false) { break; } - $error_message = curl_error($ch); $attempt++; if ($attempt >= self::MAX_RETRIES) { @@ -235,12 +234,12 @@ 'headers' => [], 'raw' => null, 'data' => null, - 'error' => $error_message ?? curl_error($ch), + 'error' => $error_message ?? 'Unknown cURL error', ]; } - $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $header_size = $this->getCurlInfo($ch, CURLINFO_HEADER_SIZE); + $http_code = $this->getCurlInfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); @@ -270,6 +269,29 @@ } /** + * Выполнить curl-запрос и вернуть результат. + * + * @param \CurlHandle $ch + * @return array{0: string|false, 1: string|null} + */ + protected function executeCurl($ch): array { + $raw = curl_exec($ch); + $err = $raw === false ? curl_error($ch) : null; + return [$raw, $err]; + } + + /** + * Получить информацию из cURL handle. + * + * @param \CurlHandle $ch + * @param int $option + * @return mixed + */ + protected function getCurlInfo($ch, int $option) { + return curl_getinfo($ch, $option); + } + + /** * Простейший парсер HTTP-заголовков. * * @param string $raw_headers diff --git a/server/tests/AppAuthGuardTest.php b/server/tests/AppAuthGuardTest.php new file mode 100644 index 0000000..1bb7c71 --- /dev/null +++ b/server/tests/AppAuthGuardTest.php @@ -0,0 +1,122 @@ + originalApp = app(); + $this -> testApp = new TestableAppForGuard(); + \Fury\Kernel\AppContainer::set_app($this -> testApp); + $this -> create_sessions_table(); + } + + protected function tearDown(): void { + \Fury\Kernel\AppContainer::set_app($this -> originalApp); + $_SERVER = []; + $_COOKIE = []; + app() -> thin_builder -> query("DROP TABLE IF EXISTS sessions"); + } + + private function create_sessions_table(): void { + app() -> thin_builder -> query("CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER, + status INTEGER DEFAULT 1, + token TEXT, + last_using_at TEXT, + update_at TEXT, + create_at TEXT + )"); + } + + public function test_non_api_uri_returns_null(): void { + $_SERVER['REQUEST_URI'] = '/index.php'; + $result = $this -> testApp -> check_api_auth(); + $this -> assertNull($result); + } + + public function test_valid_bearer_token_passes(): void { + $token = app() -> sessions -> create(1); // create session for user 1 + $_SERVER['REQUEST_URI'] = '/api/v1/devices/list'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token; + + $result = $this -> testApp -> check_api_auth(); + $this -> assertNull($result); + } + + public function test_valid_cookie_token_passes(): void { + $token = app() -> sessions -> create(1); + $_SERVER['REQUEST_URI'] = '/api/v1/devices/list'; + $_COOKIE['auth_token'] = $token; + + $result = $this -> testApp -> check_api_auth(); + $this -> assertNull($result); + } + + public function test_missing_token_returns_401(): void { + $_SERVER['REQUEST_URI'] = '/api/v1/devices/list'; + + $result = $this -> testApp -> check_api_auth(); + $this -> assertNotNull($result); + $this -> assertSame(401, $result['code']); + $this -> assertSame('unauthorized', $result['body']['error_alias']); + } + + public function test_invalid_token_returns_401(): void { + $_SERVER['REQUEST_URI'] = '/api/v1/devices/list'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer invalid_token_123'; + + $result = $this -> testApp -> check_api_auth(); + $this -> assertNotNull($result); + $this -> assertSame(401, $result['code']); + } + + public function test_rate_limit_exceeded_returns_429(): void { + $_SERVER['REQUEST_URI'] = '/api/v1/devices/list'; + $_SERVER['REMOTE_ADDR'] = '1.2.3.4'; + + // Exhaust rate limit (60 requests in 1 minute window) + for ($i = 0; $i < 60; $i++) { + $this -> testApp -> check_api_auth(); + } + + $result = $this -> testApp -> check_api_auth(); + $this -> assertNotNull($result); + $this -> assertSame(429, $result['code']); + $this -> assertSame('rate_limit_exceeded', $result['body']['error_alias']); + } + + public function test_rate_limit_resets_after_window(): void { + $_SERVER['REQUEST_URI'] = '/api/v1/devices/list'; + $_SERVER['REMOTE_ADDR'] = '1.2.3.5'; + + // Exhaust limit + for ($i = 0; $i < 60; $i++) { + $this -> testApp -> check_api_auth(); + } + $result = $this -> testApp -> check_api_auth(); + $this -> assertSame(429, $result['code']); + + // Simulate time passing: manipulate stored timestamps via reflection + $ref = new \ReflectionClass(\SHServ\Tools\RateLimiter::class); + $prop = $ref -> getProperty('requests'); + $prop -> setAccessible(true); + $prop -> setValue(null, []); + + $token = app() -> sessions -> create(1); + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token; + + $result = $this -> testApp -> check_api_auth(); + $this -> assertNull($result); + } +} + +class TestableAppForGuard extends \SHServ\App { + public function app_init(): void { + $this -> utils = new \SHServ\Utils(); + $this -> sessions = new \SHServ\Sessions(); + } +} diff --git a/server/tests/AreasControllerHappyPathTest.php b/server/tests/AreasControllerHappyPathTest.php new file mode 100644 index 0000000..d92e411 --- /dev/null +++ b/server/tests/AreasControllerHappyPathTest.php @@ -0,0 +1,178 @@ + tb = app() -> thin_builder; + $this -> create_areas_table(); + $this -> create_devices_table(); + $this -> controller = new AreasRESTAPIController(); + } + + protected function tearDown(): void { + $this -> tb -> query("DROP TABLE IF EXISTS areas"); + $this -> tb -> query("DROP TABLE IF EXISTS devices"); + } + + private function create_areas_table(): void { + $this -> tb -> query("CREATE TABLE areas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alias TEXT, + display_name TEXT, + type TEXT, + parent_id INTEGER DEFAULT 0, + schema TEXT, + create_at TEXT, + update_at TEXT + )"); + } + + private function create_devices_table(): void { + $this -> tb -> query("CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + area_id INTEGER DEFAULT 0, + alias TEXT, + name TEXT, + device_type TEXT, + device_ip TEXT, + device_mac TEXT, + device_hard_id TEXT, + firmware_version TEXT, + connection_status TEXT, + status TEXT, + description TEXT, + last_contact TEXT, + create_at TEXT, + update_at TEXT + )"); + } + + private function decode(string $json): array { + return json_decode($json, true); + } + + public function test_new_area_creates_area(): void { + $result = $this -> controller -> new_area('room', 'kitchen', 'Kitchen'); + $data = $this -> decode($result); + + $this -> assertTrue($data['status']); + $this -> assertSame('kitchen', $data['data']['alias']); + $this -> assertSame('Kitchen', $data['data']['area']['display_name']); + $this -> assertSame('room', $data['data']['area']['type']); + + $rows = $this -> tb -> select('areas', ['id'], [['alias', '=', 'kitchen']]); + $this -> assertCount(1, $rows); + } + + public function test_areas_list_returns_areas(): void { + $this -> tb -> insert('areas', [ + 'alias' => 'room1', 'display_name' => 'Room 1', 'type' => 'room', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + $this -> tb -> insert('areas', [ + 'alias' => 'room2', 'display_name' => 'Room 2', 'type' => 'room', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> controller -> areas_list(); + $data = $this -> decode($result); + + $this -> assertTrue($data['status']); + $this -> assertCount(2, $data['data']['areas']); + $this -> assertSame(2, $data['data']['total']); + } + + public function test_update_display_name_persists(): void { + $this -> tb -> insert('areas', [ + 'alias' => 'hall', 'display_name' => 'Hall', 'type' => 'room', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> controller -> update_display_name(1, 'Main Hall'); + $data = $this -> decode($result); + + $this -> assertTrue($data['status']); + $this -> assertSame('Main Hall', $data['data']['area']['display_name']); + + $rows = $this -> tb -> select('areas', ['display_name'], [['id', '=', 1]]); + $this -> assertSame('Main Hall', $rows[0]['display_name']); + } + + public function test_update_alias_persists(): void { + $this -> tb -> insert('areas', [ + 'alias' => 'old_alias', 'display_name' => 'Old', 'type' => 'room', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> controller -> update_alias(1, 'new_alias'); + $data = $this -> decode($result); + + $this -> assertTrue($data['status']); + $this -> assertSame('new_alias', $data['data']['alias']); + + $rows = $this -> tb -> select('areas', ['alias'], [['id', '=', 1]]); + $this -> assertSame('new_alias', $rows[0]['alias']); + } + + public function test_remove_area_removes_row(): void { + $this -> tb -> insert('areas', [ + 'alias' => 'to_remove', 'display_name' => 'Remove', 'type' => 'room', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> controller -> remove_area(1); + $data = $this -> decode($result); + + $this -> assertTrue($data['status']); + + $rows = $this -> tb -> select('areas', ['id'], [['alias', '=', 'to_remove']]); + $this -> assertCount(0, $rows); + } + + public function test_place_in_area_nests_areas(): void { + $this -> tb -> insert('areas', [ + 'alias' => 'parent', 'display_name' => 'Parent', 'type' => 'floor', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + $this -> tb -> insert('areas', [ + 'alias' => 'child', 'display_name' => 'Child', 'type' => 'room', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> controller -> place_in_area(2, 1); + $data = $this -> decode($result); + + $this -> assertTrue($data['status']); + + $rows = $this -> tb -> select('areas', ['parent_id'], [['id', '=', 2]]); + $this -> assertSame(1, $rows[0]['parent_id']); + } + + public function test_exists_types_returns_unique_types(): void { + $this -> tb -> insert('areas', [ + 'alias' => 'a1', 'display_name' => 'A1', 'type' => 'room', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + $this -> tb -> insert('areas', [ + 'alias' => 'a2', 'display_name' => 'A2', 'type' => 'room', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + $this -> tb -> insert('areas', [ + 'alias' => 'a3', 'display_name' => 'A3', 'type' => 'floor', + 'parent_id' => 0, 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> controller -> exists_types(); + $data = $this -> decode($result); + + $this -> assertTrue($data['status']); + $this -> assertContains('room', $data['data']['types']); + $this -> assertContains('floor', $data['data']['types']); + $this -> assertCount(2, $data['data']['types']); + } +} diff --git a/server/tests/DeviceAPIBaseRetryTest.php b/server/tests/DeviceAPIBaseRetryTest.php new file mode 100644 index 0000000..39df8ad --- /dev/null +++ b/server/tests/DeviceAPIBaseRetryTest.php @@ -0,0 +1,143 @@ + $header . $body, + 'error' => null, + 'http_code' => $httpCode, + 'header_size' => strlen($header), + ]; + } + + private function makeFailureResponse(string $error = 'Connection timed out'): array { + return [ + 'body' => false, + 'error' => $error, + 'http_code' => 0, + 'header_size' => 0, + ]; + } + + public function test_success_on_first_attempt(): void { + $mock = new MockableDeviceAPI('192.168.1.10', 'test_token'); + $mock -> responses = [ + $this -> makeSuccessResponse(['status' => 'ok', 'device_type' => 'relay']), + ]; + + $result = $mock -> get_about(); + + $this -> assertSame(200, $result['http_code']); + $this -> assertSame('ok', $result['data']['status']); + $this -> assertSame(1, $mock -> callIndex); + } + + public function test_success_after_two_failures(): void { + $mock = new MockableDeviceAPI('192.168.1.10', 'test_token'); + $mock -> responses = [ + $this -> makeFailureResponse(), + $this -> makeFailureResponse(), + $this -> makeSuccessResponse(['status' => 'ok']), + ]; + + $result = $mock -> get_about(); + + $this -> assertSame(200, $result['http_code']); + $this -> assertSame('ok', $result['data']['status']); + $this -> assertSame(3, $mock -> callIndex); + } + + public function test_failure_after_max_retries(): void { + $mock = new MockableDeviceAPI('192.168.1.10', 'test_token'); + $mock -> responses = [ + $this -> makeFailureResponse('Timeout 1'), + $this -> makeFailureResponse('Timeout 2'), + $this -> makeFailureResponse('Timeout 3'), + ]; + + $result = $mock -> get_about(); + + $this -> assertSame(0, $result['http_code']); + $this -> assertNotNull($result['error']); + $this -> assertSame(3, $mock -> callIndex); + } + + public function test_invalid_json_response_returns_error(): void { + $mock = new MockableDeviceAPI('192.168.1.10', 'test_token'); + $header = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"; + $mock -> responses = [ + [ + 'body' => $header . 'not json at all', + 'error' => null, + 'http_code' => 200, + 'header_size' => strlen($header), + ], + ]; + + $result = $mock -> get_about(); + + $this -> assertSame(200, $result['http_code']); + $this -> assertNull($result['data']); + $this -> assertSame('Invalid JSON response from device', $result['error']); + } + + public function test_get_status_returns_empty_on_non_200(): void { + $mock = new MockableDeviceAPI('192.168.1.10', 'test_token'); + $mock -> responses = [ + $this -> makeSuccessResponse(['status' => 'ok'], 500), + ]; + + $result = $mock -> get_status(); + + $this -> assertSame([], $result); + } + + public function test_post_action_requires_token(): void { + $client = new Base('192.168.1.10'); + $this -> expectException(\LogicException::class); + $client -> post_action('toggle', []); + } + + public function test_post_action_succeeds_with_token(): void { + $mock = new MockableDeviceAPI('192.168.1.10', 'test_token'); + $mock -> responses = [ + $this -> makeSuccessResponse(['status' => 'ok', 'message' => 'Done']), + ]; + + $result = $mock -> post_action('toggle', ['channel' => 1]); + + $this -> assertSame(200, $result['http_code']); + $this -> assertSame('ok', $result['data']['status']); + } +} + +class MockableDeviceAPI extends Base { + public array $responses = []; + public int $callIndex = 0; + + protected function executeCurl($ch): array { + $response = $this -> responses[$this -> callIndex] ?? null; + $this -> callIndex++; + if ($response === null) { + return [false, 'Connection timed out']; + } + return [$response['body'], $response['error'] ?? null]; + } + + protected function getCurlInfo($ch, int $option) { + $response = $this -> responses[max(0, $this -> callIndex - 1)] ?? []; + if ($option === CURLINFO_HTTP_CODE) { + return $response['http_code'] ?? 0; + } + if ($option === CURLINFO_HEADER_SIZE) { + return $response['header_size'] ?? 0; + } + return 0; + } +} diff --git a/server/tests/DevicesModelTransactionTest.php b/server/tests/DevicesModelTransactionTest.php new file mode 100644 index 0000000..9a6239d --- /dev/null +++ b/server/tests/DevicesModelTransactionTest.php @@ -0,0 +1,155 @@ + tb = app() -> thin_builder; + $this -> create_devices_table(); + $this -> create_device_auth_table(); + $this -> devicesModel = new Devices(); + } + + protected function tearDown(): void { + $this -> tb -> query("DROP TABLE IF EXISTS devices"); + $this -> tb -> query("DROP TABLE IF EXISTS device_auth"); + } + + private function create_devices_table(): void { + $this -> tb -> query("CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + area_id INTEGER DEFAULT 0, + alias TEXT, + name TEXT, + device_type TEXT, + device_ip TEXT, + device_mac TEXT, + device_hard_id TEXT, + firmware_version TEXT, + connection_status TEXT, + status TEXT, + description TEXT, + last_contact TEXT, + create_at TEXT, + update_at TEXT + )"); + } + + private function create_device_auth_table(): void { + $this -> tb -> query("CREATE TABLE device_auth ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id INTEGER, + device_token TEXT, + status TEXT, + create_at TEXT, + update_at TEXT + )"); + } + + public function test_connect_new_device_happy_path(): void { + $mockApi = new MockDeviceAPIForSetup([ + 'data' => [ + 'status' => 'setup', + 'device_type' => 'relay', + 'ip_address' => '192.168.1.50', + 'mac_address' => 'AA:BB:CC:DD:EE:FF', + 'device_id' => 'hard_123', + 'firmware_version' => '1.0.0', + ] + ]); + + $result = $this -> devicesModel -> connect_new_device( + '192.168.1.50', 'relay_kitchen', 'Kitchen Relay', '', $mockApi + ); + + $this -> assertInstanceOf(\SHServ\Entities\Device::class, $result); + $this -> assertGreaterThan(0, $result -> id()); + $this -> assertSame('relay_kitchen', $result -> alias); + $this -> assertSame('Kitchen Relay', $result -> name); + + $fromDb = $this -> devicesModel -> by_id($result -> id()); + $this -> assertNotNull($fromDb); + $this -> assertSame('relay', $fromDb -> device_type); + + $auth = $result -> auth(); + $this -> assertNotNull($auth); + $this -> assertSame('active', $auth -> status); + } + + public function test_connect_new_device_rollback_on_auth_failure(): void { + $this -> tb -> query("DROP TABLE device_auth"); + + $mockApi = new MockDeviceAPIForSetup([ + 'data' => [ + 'status' => 'setup', + 'device_type' => 'relay', + 'ip_address' => '192.168.1.51', + 'mac_address' => 'AA:BB:CC:DD:EE:00', + 'device_id' => 'hard_456', + 'firmware_version' => '1.0.0', + ] + ]); + + $result = $this -> devicesModel -> connect_new_device( + '192.168.1.51', 'relay_fail', 'Fail Relay', '', $mockApi + ); + + $this -> assertIsArray($result); + $this -> assertFalse($result['result']); + $this -> assertSame('db_error', $result['err_alias']); + + $fromDb = $this -> devicesModel -> by_alias('relay_fail'); + $this -> assertNull($fromDb); + } + + public function test_connect_new_device_rejects_non_setup_mode(): void { + $mockApi = new MockDeviceAPIForSetup([ + 'data' => [ + 'status' => 'normal', + 'device_type' => 'relay', + 'ip_address' => '192.168.1.52', + 'mac_address' => 'AA:BB:CC:DD:EE:11', + 'device_id' => 'hard_789', + 'firmware_version' => '1.0.0', + ] + ]); + + $result = $this -> devicesModel -> connect_new_device( + '192.168.1.52', 'relay_normal', 'Normal Relay', '', $mockApi + ); + + $this -> assertIsArray($result); + $this -> assertSame('device_mode_error', $result['err_alias']); + } + + public function test_connect_new_device_returns_error_when_not_found(): void { + $mockApi = new MockDeviceAPIForSetup([ + 'data' => null, + ]); + + $result = $this -> devicesModel -> connect_new_device( + '192.168.1.53', 'relay_missing', 'Missing Relay', '', $mockApi + ); + + $this -> assertIsArray($result); + $this -> assertSame('device_not_found', $result['err_alias']); + } +} + +class MockDeviceAPIForSetup extends Base { + private array $aboutResponse; + + public function __construct(array $aboutResponse) { + parent::__construct('127.0.0.1'); + $this -> aboutResponse = $aboutResponse; + } + + public function get_about(): array { + return $this -> aboutResponse; + } +} diff --git a/server/tests/ScriptsModelStateTest.php b/server/tests/ScriptsModelStateTest.php new file mode 100644 index 0000000..d7636b6 --- /dev/null +++ b/server/tests/ScriptsModelStateTest.php @@ -0,0 +1,168 @@ + tb = app() -> thin_builder; + $this -> create_scripts_table(); + $this -> scriptsModel = new Scripts(); + $this -> originalScopes = app() -> control_scripts_instances; + app() -> control_scripts_instances = []; + } + + protected function tearDown(): void { + $this -> tb -> query("DROP TABLE IF EXISTS scripts"); + app() -> control_scripts_instances = $this -> originalScopes; + // Reset static script registries to avoid leakage between tests + $refRegular = new \ReflectionProperty(ControlScripts::class, 'regular_scripts'); + $refRegular -> setAccessible(true); + $refRegular -> setValue(null, []); + $refActions = new \ReflectionProperty(ControlScripts::class, 'actions_scripts'); + $refActions -> setAccessible(true); + $refActions -> setValue(null, []); + } + + private function create_scripts_table(): void { + $this -> tb -> query("CREATE TABLE scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + area_id INTEGER DEFAULT 0, + uniq_name TEXT, + type TEXT, + state TEXT, + create_at TEXT, + update_at TEXT + )"); + } + + private function makeFakeScope(string $name): object { + return new class($name) extends ControlScripts { + private string $name; + public function __construct(string $name) { + $this -> name = $name; + // bypass parent constructor to avoid DB checks + } + public function register_sync_map(): void {} + protected function register_events_handlers(): void {} + protected function register_regular_scripts(): void {} + protected function register_actions_scripts(): void {} + }; + } + + private function registerActionScript(string $alias): void { + $scope = $this -> makeFakeScope('FakeScope'); + $ref = new \ReflectionMethod(ControlScripts::class, 'add_action_script'); + $ref -> setAccessible(true); + $ref -> invoke($scope, ['alias' => $alias, 'name' => 'Test Action'], function($params) { + return true; + }); + } + + private function registerRegularScript(string $alias): void { + $scope = $this -> makeFakeScope('FakeScope'); + $ref = new \ReflectionMethod(ControlScripts::class, 'add_regular_script'); + $ref -> setAccessible(true); + $ref -> invoke($scope, ['alias' => $alias, 'name' => 'Test Regular'], function() { + return true; + }); + } + + public function test_get_scopes_list_returns_metadata(): void { + app() -> control_scripts_instances = [ + 'FakeScope' => $this -> makeFakeScope('FakeScope'), + ]; + + $list = $this -> scriptsModel -> get_scopes_list(); + $this -> assertCount(1, $list); + $this -> assertSame('FakeScope', $list[0]['name']); + $this -> assertNotEmpty($list[0]['filename']); + $this -> assertNotEmpty($list[0]['path']); + $this -> assertSame('disabled', $list[0]['state']); + } + + public function test_script_state_returns_false_for_missing(): void { + $this -> assertFalse($this -> scriptsModel -> script_state('action', 'nonexistent')); + } + + public function test_script_state_returns_true_for_enabled(): void { + $this -> tb -> insert('scripts', [ + 'uniq_name' => 'test_script', + 'type' => 'action', + 'state' => 'enabled', + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $this -> assertTrue($this -> scriptsModel -> script_state('action', 'test_script')); + } + + public function test_enable_script_inserts_row(): void { + $this -> registerActionScript('enable_me'); + $result = $this -> scriptsModel -> enable_script('action', 'enable_me'); + $this -> assertTrue($result); + + $rows = $this -> tb -> select('scripts', ['state'], [ + ['uniq_name', '=', 'enable_me'], + 'AND', + ['type', '=', 'action'], + ]); + $this -> assertCount(1, $rows); + $this -> assertSame('enabled', $rows[0]['state']); + } + + public function test_disable_script_updates_row(): void { + $this -> registerActionScript('disable_me'); + $this -> tb -> insert('scripts', [ + 'uniq_name' => 'disable_me', + 'type' => 'action', + 'state' => 'enabled', + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> scriptsModel -> disable_script('action', 'disable_me'); + $this -> assertTrue($result); + + $rows = $this -> tb -> select('scripts', ['state'], [ + ['uniq_name', '=', 'disable_me'], + 'AND', + ['type', '=', 'action'], + ]); + $this -> assertSame('disabled', $rows[0]['state']); + } + + public function test_set_script_state_returns_scope_not_found(): void { + app() -> control_scripts_instances = ['RealScope' => $this -> makeFakeScope('RealScope')]; + $result = $this -> scriptsModel -> set_script_state('scope', 'missing_scope', true); + $this -> assertSame('scope_not_found', $result); + } + + public function test_set_script_state_returns_invalid_type(): void { + $result = $this -> scriptsModel -> set_script_state('invalid_type', 'something', true); + $this -> assertSame('invalid_type', $result); + } + + public function test_set_script_state_updates_existing_row(): void { + $this -> registerRegularScript('regular_one'); + $this -> tb -> insert('scripts', [ + 'uniq_name' => 'regular_one', + 'type' => 'regular', + 'state' => 'disabled', + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> scriptsModel -> set_script_state('regular', 'regular_one', true); + $this -> assertTrue($result); + + $rows = $this -> tb -> select('scripts', ['state'], [ + ['uniq_name', '=', 'regular_one'], + 'AND', + ['type', '=', 'regular'], + ]); + $this -> assertSame('enabled', $rows[0]['state']); + } +} diff --git a/server/tests/TestApp.php b/server/tests/TestApp.php index 613d1bd..6accb48 100644 --- a/server/tests/TestApp.php +++ b/server/tests/TestApp.php @@ -6,4 +6,5 @@ public \SHServ\Sessions $sessions; public \SHServ\DevTools $devtools; public $factory = null; + public array $control_scripts_instances = []; } diff --git a/server/tests/bootstrap.php b/server/tests/bootstrap.php index b31dfbf..cca1da1 100644 --- a/server/tests/bootstrap.php +++ b/server/tests/bootstrap.php @@ -20,6 +20,10 @@ \Fury\Kernel\AppContainer::set_app($testApp); +if (!defined('PHPUNIT_TEST')) { + define('PHPUNIT_TEST', true); +} + // Minimal FCONF for message lookups in Utils if (!defined('FCONF')) { define('FCONF', [