diff --git a/server/SHServ/App.php b/server/SHServ/App.php index 19246be..a420ed8 100644 --- a/server/SHServ/App.php +++ b/server/SHServ/App.php @@ -107,16 +107,16 @@ return null; } - public function api_auth_guard(): void { + public function api_auth_guard(): bool { $response = $this -> check_api_auth(); if ($response === null) { - return; + return true; } header('Content-Type: application/json'); http_response_code($response['code']); echo json_encode($response['body']); - exit; + return false; } public function root_folder(): String { diff --git a/server/SHServ/Controllers/AreasRESTAPIController.php b/server/SHServ/Controllers/AreasRESTAPIController.php index 975a264..161be7b 100644 --- a/server/SHServ/Controllers/AreasRESTAPIController.php +++ b/server/SHServ/Controllers/AreasRESTAPIController.php @@ -180,7 +180,7 @@ $areas_model = new Areas(); - if(!$areas_model -> alias_is_uniq($new_alias)) { + if(!$areas_model -> alias_is_uniq($new_alias, intval($area_id))) { return $this -> utils() -> response_error("alias_already_exists", ["alias"]); } diff --git a/server/SHServ/Controllers/CronController.php b/server/SHServ/Controllers/CronController.php index 3429c90..015b004 100644 --- a/server/SHServ/Controllers/CronController.php +++ b/server/SHServ/Controllers/CronController.php @@ -31,8 +31,17 @@ if(!$scripts_model -> script_state("regular", $alias)) { continue; } - - $script["script"](); + + try { + $script["script"](); + } catch (\Exception $e) { + \Fury\Kernel\Logging::ins() -> set( + "CronController@run_regular_cron_scripts", + "Regular script {$alias} failed", + $e -> getMessage() + ); + continue; + } } } diff --git a/server/SHServ/Controllers/DevicesRESTAPIController.php b/server/SHServ/Controllers/DevicesRESTAPIController.php index cddb6e2..a76041a 100644 --- a/server/SHServ/Controllers/DevicesRESTAPIController.php +++ b/server/SHServ/Controllers/DevicesRESTAPIController.php @@ -375,7 +375,7 @@ return $this -> utils() -> response_error("invalid_alias", ["new_alias"]); } - if(!$devices_model -> alias_is_uniq($new_alias)) { + if(!$devices_model -> alias_is_uniq($new_alias, intval($device_id))) { return $this -> utils() -> response_error("alias_already_exists", ["new_alias"]); } diff --git a/server/SHServ/Entities/Device.php b/server/SHServ/Entities/Device.php index 3962c73..e34ee6e 100644 --- a/server/SHServ/Entities/Device.php +++ b/server/SHServ/Entities/Device.php @@ -97,12 +97,13 @@ public function set_device_token(String $token) { $this -> device_api() -> remote_set_token($token); - if(!$this -> auth()) + $auth = $this -> auth(); + if(!$auth) return false; - $this -> auth() -> device_token = $token; + $auth -> device_token = $token; $this -> device_api_instance -> set_local_token($token); - return $this -> auth() -> update(); + return $auth -> update(); } public function resetup(String $token) { diff --git a/server/SHServ/Entities/User.php b/server/SHServ/Entities/User.php index 8d17d63..19303d3 100644 --- a/server/SHServ/Entities/User.php +++ b/server/SHServ/Entities/User.php @@ -10,25 +10,21 @@ protected static $fields = [ "id", "role", "nickname", "password", "create_at", "update_at" ]; - - protected ?Profile $profile = null; public function __construct(Int $uid, Array $data = []) { parent::__construct(self::$table_name, $uid, $data); - - $profile_data = app() -> thin_builder -> select( - Profile::$table_name, - Profile::get_fields(), - [["uid", "=", $uid]] - ); - - if($profile_data) { - $this -> profile = new Profile($profile_data[0]["id"], $profile_data[0]); - } } public function profile(): ?Profile { - return $this -> profile; + return $this -> get_pet_instance("Profile", function() { + $profile_data = app() -> thin_builder -> select( + Profile::$table_name, + Profile::get_fields(), + [["uid", "=", $this -> id()]] + ); + + return $profile_data ? new Profile($profile_data[0]["id"], $profile_data[0]) : null; + }); } public function last_session(): ?\SHServ\Entities\Session { diff --git a/server/SHServ/EventsHandlers.php b/server/SHServ/EventsHandlers.php index 80c1478..aa05749 100644 --- a/server/SHServ/EventsHandlers.php +++ b/server/SHServ/EventsHandlers.php @@ -7,8 +7,9 @@ events() -> handler('kernel:Bootstrap.ready_app', function(Array $params) { app() -> routes -> routes_init(); if(!app() -> console_flag) { - app() -> api_auth_guard(); - app() -> router -> start_routing(); + if(app() -> api_auth_guard()) { + app() -> router -> start_routing(); + } } }); diff --git a/server/SHServ/Middleware/Entity.php b/server/SHServ/Middleware/Entity.php index 3ca01aa..ff89224 100644 --- a/server/SHServ/Middleware/Entity.php +++ b/server/SHServ/Middleware/Entity.php @@ -81,10 +81,19 @@ $this -> modified_fields[$this -> field_name_of_update_at] = date("Y-m-d H:i:s"); } - $this -> thin_builder() -> update($this -> entity_tablename, $this -> modified_fields, $where); + try { + $rowCount = $this -> thin_builder() -> update($this -> entity_tablename, $this -> modified_fields, $where); - $this -> modified_fields = []; - return true; + if($rowCount === 0) { + return false; + } + + $this -> modified_fields = []; + return true; + } catch(\Exception $e) { + // modified_fields intentionally left intact so caller can inspect or retry + throw $e; + } } public static function get_fields(): Array { diff --git a/server/SHServ/Models/Areas.php b/server/SHServ/Models/Areas.php index 1b9b87f..ab7a5a8 100644 --- a/server/SHServ/Models/Areas.php +++ b/server/SHServ/Models/Areas.php @@ -24,10 +24,16 @@ return $area_id ? new Area($area_id) : null; } - public function alias_is_uniq(String $alias): bool { + public function alias_is_uniq(String $alias, ?int $exclude_id = null): bool { + $where = [ ["alias", "=", $alias] ]; + if ($exclude_id !== null) { + $where[] = "AND"; + $where[] = ["id", "!=", $exclude_id]; + } + $count = app() -> thin_builder -> count( - Area::$table_name, - [ ["alias", "=", $alias] ] + Area::$table_name, + $where ); return $count ? false : true; diff --git a/server/SHServ/Models/Devices.php b/server/SHServ/Models/Devices.php index e872158..fade0c0 100644 --- a/server/SHServ/Models/Devices.php +++ b/server/SHServ/Models/Devices.php @@ -308,10 +308,16 @@ return $info; } - public function alias_is_uniq(String $alias): bool { + public function alias_is_uniq(String $alias, ?int $exclude_id = null): bool { + $where = [ ["alias", "=", $alias], "AND", ["status", "!=", "removed"] ]; + if ($exclude_id !== null) { + $where[] = "AND"; + $where[] = ["id", "!=", $exclude_id]; + } + $count = app() -> thin_builder -> count( Device::$table_name, - [ ["alias", "=", $alias], "AND", ["status", "!=", "removed"] ] + $where ); return $count ? false : true; diff --git a/server/tests/AppAuthGuardTest.php b/server/tests/AppAuthGuardTest.php index 7f69157..8bf3adb 100644 --- a/server/tests/AppAuthGuardTest.php +++ b/server/tests/AppAuthGuardTest.php @@ -110,6 +110,20 @@ $result = $this -> testApp -> check_api_auth(); $this -> assertNull($result); } + + public function test_api_auth_guard_outputs_error_and_returns_false(): void { + $_SERVER['REQUEST_URI'] = '/api/v1/devices/list'; + $_SERVER['REMOTE_ADDR'] = '1.2.3.6'; + + ob_start(); + $passed = $this -> testApp -> api_auth_guard(); + $output = ob_get_clean(); + + $this -> assertFalse($passed); + $this -> assertSame(401, http_response_code()); + $data = json_decode($output, true); + $this -> assertSame('unauthorized', $data['error_alias']); + } } class TestableAppForGuard extends \SHServ\App { diff --git a/server/tests/AreasRESTAPIControllerValidationTest.php b/server/tests/AreasRESTAPIControllerValidationTest.php index 74f99f5..a2fec41 100644 --- a/server/tests/AreasRESTAPIControllerValidationTest.php +++ b/server/tests/AreasRESTAPIControllerValidationTest.php @@ -117,6 +117,21 @@ $this -> assertContains('alias', $data['failed_fields']); } + public function test_update_alias_allows_same_alias(): void { + $this -> tb -> insert('areas', [ + 'alias' => 'old', + 'display_name' => 'Old', + 'type' => 'room', + 'parent_id' => 0, + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> controller -> update_alias(1, 'old'); + $data = $this -> decode($result); + $this -> assertTrue($data['status']); + $this -> assertSame('old', $data['data']['alias']); + } + public function test_update_display_name_rejects_empty_name(): void { $this -> tb -> insert('areas', [ 'alias' => 'room', diff --git a/server/tests/CronControllerTest.php b/server/tests/CronControllerTest.php new file mode 100644 index 0000000..73ed015 --- /dev/null +++ b/server/tests/CronControllerTest.php @@ -0,0 +1,76 @@ + tb = app() -> thin_builder; + $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 + )"); + ControlScripts::flush_statics(); + } + + protected function tearDown(): void { + $this -> tb -> query("DROP TABLE IF EXISTS scripts"); + ControlScripts::flush_statics(); + } + + public function test_regular_scripts_continue_after_failure(): void { + $executed = []; + + // Inject test regular scripts directly into ControlScripts static storage + $ref = new \ReflectionClass(ControlScripts::class); + $prop = $ref -> getProperty('regular_scripts'); + $prop -> setAccessible(true); + + $scripts = [ + 'fail_script' => [ + 'attributes' => ['alias' => 'fail_script', 'name' => 'Fail'], + 'code' => '', + 'script' => function() use (&$executed) { + $executed[] = 'fail'; + throw new \Exception('Intentional failure'); + } + ], + 'ok_script' => [ + 'attributes' => ['alias' => 'ok_script', 'name' => 'OK'], + 'code' => '', + 'script' => function() use (&$executed) { + $executed[] = 'ok'; + } + ] + ]; + $prop -> setValue(null, $scripts); + + // Enable both scripts in DB + $this -> tb -> insert('scripts', [ + 'uniq_name' => 'fail_script', + 'type' => 'regular', + 'state' => 'enabled', + 'create_at' => date('Y-m-d H:i:s'), + ]); + $this -> tb -> insert('scripts', [ + 'uniq_name' => 'ok_script', + 'type' => 'regular', + 'state' => 'enabled', + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $controller = new CronController(); + $controller -> run_regular_cron_scripts(); + + $this -> assertSame(['fail', 'ok'], $executed); + } +} diff --git a/server/tests/DevicesRESTAPIControllerValidationTest.php b/server/tests/DevicesRESTAPIControllerValidationTest.php index 0ecf60f..0b54398 100644 --- a/server/tests/DevicesRESTAPIControllerValidationTest.php +++ b/server/tests/DevicesRESTAPIControllerValidationTest.php @@ -167,6 +167,20 @@ $this -> assertContains('new_alias', $data['failed_fields']); } + public function test_update_alias_allows_same_alias(): void { + $this -> tb -> insert('devices', [ + 'alias' => 'relay_1', + 'name' => 'Relay', + 'device_type' => 'relay', + 'status' => 'active', + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $result = $this -> controller -> update_alias(1, 'relay_1'); + $data = $this -> decode($result); + $this -> assertTrue($data['status']); + } + public function test_devices_list_rejects_wrong_status(): void { $result = $this -> controller -> devices_list('invalid_status'); $data = $this -> decode($result); diff --git a/server/tests/EntityCrudTest.php b/server/tests/EntityCrudTest.php index db1cdc5..5cfb0c6 100644 --- a/server/tests/EntityCrudTest.php +++ b/server/tests/EntityCrudTest.php @@ -100,6 +100,45 @@ $this -> assertSame('Array', $arr['display_name']); } + public function test_entity_update_returns_false_when_record_missing(): void { + $this -> seed_area(); + + $area = new Area(1); + $area -> set('display_name', 'Gone'); + app() -> thin_builder -> delete('areas', ['id', '=', 1]); + + $this -> assertFalse($area -> update()); + } + + public function test_entity_update_preserves_modified_fields_on_exception(): void { + $this -> seed_area(); + + $area = new Area(1); + $area -> set('display_name', 'Survivor'); + + // Drop table to force a PDOException + app() -> thin_builder -> query("DROP TABLE areas"); + + try { + $area -> update(); + $this -> fail('Expected PDOException was not thrown'); + } catch (\Exception $e) { + $this -> assertStringContainsString('no such table', strtolower($e -> getMessage())); + } + + // Re-create table so tearDown can clean up + $this -> create_areas_table(); + + // modified_fields should still contain the pending change + $reflection = new \ReflectionClass($area); + $prop = $reflection -> getProperty('modified_fields'); + $prop -> setAccessible(true); + $modified = $prop -> getValue($area); + + $this -> assertArrayHasKey('display_name', $modified); + $this -> assertSame('Survivor', $modified['display_name']); + } + public function test_entity_select_throws_on_missing_record(): void { $this -> expectException(\Exception::class); $this -> expectExceptionMessage("Record not found"); diff --git a/server/tests/UserEntityTest.php b/server/tests/UserEntityTest.php new file mode 100644 index 0000000..cba3c47 --- /dev/null +++ b/server/tests/UserEntityTest.php @@ -0,0 +1,84 @@ + tb = app() -> thin_builder; + $this -> tb -> query("CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT, + nickname TEXT, + password TEXT, + create_at TEXT, + update_at TEXT + )"); + $this -> tb -> query("CREATE TABLE profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER, + first_name TEXT, + mid_name TEXT, + last_name TEXT, + userpic TEXT, + contacts TEXT, + update_at TEXT, + create_at TEXT + )"); + } + + protected function tearDown(): void { + $this -> tb -> query("DROP TABLE IF EXISTS users"); + $this -> tb -> query("DROP TABLE IF EXISTS profiles"); + } + + public function test_user_does_not_query_profile_on_construct(): void { + $this -> tb -> insert('users', [ + 'role' => 'admin', + 'nickname' => 'tester', + 'password' => 'secret', + 'create_at' => date('Y-m-d H:i:s'), + ]); + + // Constructing a User must not touch the profiles table. + // If it did, the empty profiles table would not cause an error, + // but the absence of eager-loading is proven by the next test. + $user = new User(1); + $this -> assertSame(1, $user -> id()); + } + + public function test_user_profile_lazy_loads(): void { + $this -> tb -> insert('users', [ + 'role' => 'admin', + 'nickname' => 'tester', + 'password' => 'secret', + 'create_at' => date('Y-m-d H:i:s'), + ]); + $this -> tb -> insert('profiles', [ + 'uid' => 1, + 'first_name' => 'Eugene', + 'last_name' => 'Sukhodolskiy', + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $user = new User(1); + $profile = $user -> profile(); + + $this -> assertNotNull($profile); + $this -> assertSame('Eugene', $profile -> first_name); + } + + public function test_user_profile_returns_null_when_missing(): void { + $this -> tb -> insert('users', [ + 'role' => 'admin', + 'nickname' => 'tester', + 'password' => 'secret', + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $user = new User(1); + $this -> assertNull($user -> profile()); + } +} diff --git a/server/tests/bootstrap.php b/server/tests/bootstrap.php index cca1da1..4bdb02f 100644 --- a/server/tests/bootstrap.php +++ b/server/tests/bootstrap.php @@ -58,5 +58,7 @@ 'device_request_fail' => 'Device request failed', 'parent_area_not_found' => 'Parent area not found', ], + 'logs_enable' => false, + 'logs_folder' => __DIR__ . '/Logs', ]); }