From 47a1f3c9a930a9f21b1decbaae3b594f807f46f6 Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Fri, 19 Sep 2025 05:29:27 -0700 Subject: [PATCH] Add tests --- README.md | 28 +++++++++- tests/test_api.py | 103 +++++++++++++++++++++++++++++++++++++ tests/test_config_utils.py | 29 +++++++++++ tests/test_utils.py | 24 +++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/test_api.py create mode 100644 tests/test_config_utils.py create mode 100644 tests/test_utils.py diff --git a/README.md b/README.md index 16c2c9f..2d45e4a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,33 @@ Install the built wheel elsewhere: uv pip install dist/*.whl ``` +## Testing + +Run the included unit tests with the standard library `unittest` runner. + +```bash +# Using uv +uv run python -m unittest discover -s tests -v + +# Or with an activated venv +python -m unittest discover -s tests -v +``` + +Run a single test file: + +```bash +uv run python -m unittest tests/test_api.py -v +``` + +## Development + +Run directly from source without installing (useful while iterating): + +```bash +PYTHONPATH=src uv run python -m hysteria_panel +``` + ## Troubleshooting -- No module named `hysteria-panel`: ensure the project is installed (`uv sync` or `pip install -e .`). +- No module named `hysteria_panel`: ensure the project is installed (`uv sync` or `pip install -e .`). - Cannot connect: verify host/port/secret and whether HTTPS is required. diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..7a3d399 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,103 @@ +import unittest +from unittest.mock import patch +from typing import Callable, Dict, Tuple, Any + +import requests + +from hysteria_panel.api import HysteriaAPI + + +class FauxResponse: + def __init__(self, status: int = 200, headers: Dict[str, str] | None = None, json_data: Any = None, text: str = "OK"): + self.status_code = status + self.headers = headers or {} + self._json_data = json_data + self.text = text + + def raise_for_status(self): + if 400 <= self.status_code: + raise requests.HTTPError(f"HTTP {self.status_code}") + + def json(self): + return self._json_data + + +class FakeSession: + def __init__(self, responder: Callable[[str, str], FauxResponse]): + self.headers: Dict[str, str] = {} + self._responder = responder + + def request(self, method: str, url: str, headers=None, json=None, params=None, timeout=None): + # Extract only the path for routing in tests + # URL format: scheme://host:port/path + path = url.split("//", 1)[-1] + path = path[path.find(":") + 1:] # remove host:port + path = path[path.find("/"):] # keep leading /path + return self._responder(method.lower(), path) + + +class TestHysteriaAPI(unittest.TestCase): + def _make_responder(self, mapping: Dict[Tuple[str, str], FauxResponse]): + def responder(method: str, path: str) -> FauxResponse: + # Default to 200 OK text response + return mapping.get((method, path), FauxResponse()) + return responder + + def test_init_sets_base_url_and_headers_http(self): + mapping = {('get', '/'): FauxResponse(status=200, text='ok')} + + with patch('requests.Session', lambda: FakeSession(self._make_responder(mapping))): + api = HysteriaAPI('example.com', 9999, 'secret', use_https=False) + self.assertEqual(api.base_url, 'http://example.com:9999') + # Authorization header is set + self.assertIn('Authorization', api._session.headers) + self.assertEqual(api._session.headers['Authorization'], 'secret') + # User-Agent prefix + ua = api._session.headers.get('User-Agent') + # Support both str and bytes user-agent values + if isinstance(ua, (bytes, bytearray)): + self.assertTrue(ua.startswith(b'HysteriaPanel/')) + else: + self.assertTrue(str(ua).startswith('HysteriaPanel/')) + + def test_init_sets_base_url_https(self): + mapping = {('get', '/'): FauxResponse(status=200, text='ok')} + + with patch('requests.Session', lambda: FakeSession(self._make_responder(mapping))): + api = HysteriaAPI('example.com', 443, 'secret', use_https=True) + self.assertEqual(api.base_url, 'https://example.com:443') + + def test_request_json_and_kick(self): + mapping = { + ('get', '/'): FauxResponse(status=200, text='ok'), + ('get', '/traffic'): FauxResponse(status=200, headers={'Content-Type': 'application/json'}, json_data={'alice': {'tx': 1, 'rx': 2}}), + ('post', '/kick'): FauxResponse(status=204, headers={}), + } + + with patch('requests.Session', lambda: FakeSession(self._make_responder(mapping))): + api = HysteriaAPI('h', 1, 's') + ok, data = api.get_traffic() + self.assertTrue(ok) + self.assertIsInstance(data, dict) + self.assertIn('alice', data) + + ok, data = api.kick_users(['alice']) + self.assertTrue(ok) + self.assertIsNone(data) + + def test_request_error(self): + def responder(method: str, path: str) -> FauxResponse: + if path == '/': + return FauxResponse(status=200, text='ok') + raise requests.RequestException("boom") + + with patch('requests.Session', lambda: FakeSession(responder)): + api = HysteriaAPI('h', 1, 's') + ok, data = api._request('get', '/error') + self.assertFalse(ok) + self.assertIn('boom', data) + + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/test_config_utils.py b/tests/test_config_utils.py new file mode 100644 index 0000000..cf58286 --- /dev/null +++ b/tests/test_config_utils.py @@ -0,0 +1,29 @@ +import unittest +import tempfile +from pathlib import Path +from unittest.mock import patch + +from hysteria_panel import utils + + +class TestConfigUtils(unittest.TestCase): + def test_save_load_clear_config(self): + with tempfile.TemporaryDirectory() as td: + temp_dir = Path(td) + temp_file = temp_dir / "config.json" + + with patch.object(utils, 'CONFIG_DIR', temp_dir), patch.object(utils, 'CONFIG_FILE', temp_file): + data = {"host": "127.0.0.1", "port": 9999, "secret": "abc", "https": True} + utils.save_config(data) + self.assertTrue(temp_file.exists()) + + loaded = utils.load_config() + self.assertEqual(loaded, data) + + utils.clear_config() + self.assertFalse(temp_file.exists()) + + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9bdddcf --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,24 @@ +import unittest + +from hysteria_panel import utils + + +class TestUtils(unittest.TestCase): + def test_format_bytes(self): + self.assertEqual(utils.format_bytes(0), "0 B") + self.assertEqual(utils.format_bytes(1), "1.00 B") + self.assertEqual(utils.format_bytes(1024), "1.00 KB") + self.assertEqual(utils.format_bytes(1024**2), "1.00 MB") + self.assertEqual(utils.format_bytes(1536), "1.50 KB") + + def test_format_speed(self): + self.assertEqual(utils.format_speed(0), "0 B/s") + self.assertEqual(utils.format_speed(1), "1.00 B/s") + self.assertEqual(utils.format_speed(1024), "1.00 KB/s") + self.assertEqual(utils.format_speed(1024**2), "1.00 MB/s") + self.assertEqual(utils.format_speed(1536), "1.50 KB/s") + + +if __name__ == "__main__": + unittest.main() +