diff --git a/tests/function_tests/config.py b/tests/function_tests/config.py index e34b2ee..0d59457 100644 --- a/tests/function_tests/config.py +++ b/tests/function_tests/config.py @@ -1,3 +1,5 @@ +"""Configuration values for function and integration testing.""" + import os API_HOST = os.environ.get('TEST_API_HOST', 'http://localhost:8000') diff --git a/tests/unit_tests/test_client/test_AsyncKeystoneClient.py b/tests/unit_tests/test_client/test_AsyncKeystoneClient.py index 067ce33..4a36ed7 100644 --- a/tests/unit_tests/test_client/test_AsyncKeystoneClient.py +++ b/tests/unit_tests/test_client/test_AsyncKeystoneClient.py @@ -9,7 +9,12 @@ class LoginMethod(IsolatedAsyncioTestCase): - """Test the structure of API login requests.""" + """Tests the `login` method. + + Verifies user credentials are submitted to the login endpoint in the + expected request format, and an error is raised when the request is + unsuccessful. + """ async def asyncSetUp(self) -> None: """Define common test variables.""" @@ -50,7 +55,12 @@ def handler(request: httpx.Request) -> httpx.Response: class LogoutMethod(IsolatedAsyncioTestCase): - """Test the structure of API logout requests.""" + """Tests the `logout` method. + + Verifies a request is submitted to the logout endpoint in the expected + request format, and an error is raised when the request is + unsuccessful. + """ async def asyncSetUp(self) -> None: """Define common test variables.""" @@ -86,8 +96,13 @@ def handler(request: httpx.Request) -> httpx.Response: await client.logout() -class IsAuthenticatedMethod(IsolatedAsyncioTestCase): - """Test the structure of requests to verify authentication status.""" +class WhoAmIMethod(IsolatedAsyncioTestCase): + """Tests the `whoami` method. + + Verifies user metadata is requested from the identity endpoint and + returned on success, an empty dictionary is returned when the session + is unauthenticated, and an error is raised for any other failure. + """ async def asyncSetUp(self) -> None: """Define common test variables.""" diff --git a/tests/unit_tests/test_client/test_ClientBase.py b/tests/unit_tests/test_client/test_ClientBase.py new file mode 100644 index 0000000..3a5ec58 --- /dev/null +++ b/tests/unit_tests/test_client/test_ClientBase.py @@ -0,0 +1,43 @@ +"""Unit tests for the `ClientBase` class.""" + +from unittest import TestCase +from unittest.mock import MagicMock + +from keystone_client.client import ClientBase + + +class DummyClientBase(ClientBase): + """Concrete subclass of ClientBase for testing.""" + + def login(self, username: str, password: str, timeout: int) -> None: + """Method required by abstract parent for authenticating a user session.""" + + def logout(self) -> None: + """Method required by abstract parent for terminating a user session.""" + + def whoami(self) -> dict: + """Method required by abstract parent for returning user metadata.""" + + return {} + + +class IsAuthenticatedMethod(TestCase): + """Tests the `is_authenticated` method. + + Verifies the returned value reflects whether metadata is available for + the current user session. + """ + + def test_returns_true_for_populated_metadata(self) -> None: + """Verify a truthy result is returned when `whoami` returns metadata.""" + + client = DummyClientBase() + client.whoami = MagicMock(return_value={"user_id": 42}) + self.assertTrue(client.is_authenticated()) + + def test_returns_false_for_empty_metadata(self) -> None: + """Verify a falsy result is returned when `whoami` returns no metadata.""" + + client = DummyClientBase() + client.whoami = MagicMock(return_value={}) + self.assertFalse(client.is_authenticated()) diff --git a/tests/unit_tests/test_client/test_KeystoneClient.py b/tests/unit_tests/test_client/test_KeystoneClient.py index 323d07a..d869657 100644 --- a/tests/unit_tests/test_client/test_KeystoneClient.py +++ b/tests/unit_tests/test_client/test_KeystoneClient.py @@ -9,7 +9,12 @@ class LoginMethod(TestCase): - """Test the structure of API login requests.""" + """Tests the `login` method. + + Verifies user credentials are submitted to the login endpoint in the + expected request format, and an error is raised when the request is + unsuccessful. + """ def setUp(self) -> None: """Define common test variables.""" @@ -50,7 +55,11 @@ def handler(request: httpx.Request) -> httpx.Response: class LogoutMethod(TestCase): - """Test the structure of API logout requests.""" + """Tests the `logout` method. + + Verifies a request is submitted to the logout endpoint in the expected + request format, and an error is raised when the request is unsuccessful. + """ def setUp(self) -> None: """Define common test variables.""" @@ -86,8 +95,13 @@ def handler(request: httpx.Request) -> httpx.Response: client.logout() -class IsAuthenticatedMethod(TestCase): - """Test the structure of requests to verify authentication status.""" +class WhoAmiIMethod(TestCase): + """Tests the `whoami` method. + + Verifies user metadata is requested from the identity endpoint and + returned on success, an empty dictionary is returned when the session + is unauthenticated, and an error is raised for any other failure. + """ def setUp(self) -> None: """Define common test variables.""" diff --git a/tests/unit_tests/test_http/test_AsyncHTTPClient.py b/tests/unit_tests/test_http/test_AsyncHTTPClient.py index 8c785aa..ee05af6 100644 --- a/tests/unit_tests/test_http/test_AsyncHTTPClient.py +++ b/tests/unit_tests/test_http/test_AsyncHTTPClient.py @@ -1,4 +1,4 @@ -"""Unit tests for the `AsyncHTTPClient` method.""" +"""Unit tests for the `AsyncHTTPClient` class.""" import logging from unittest import IsolatedAsyncioTestCase @@ -13,7 +13,11 @@ @patch("httpx.AsyncClient") class CloseMethodAsync(IsolatedAsyncioTestCase): - """Test the termination of open connections.""" + """Tests the `close` method. + + Verifies any open connections are terminated when the method is called + directly, and when the client is exited via its context manager. + """ async def test_close_on_function_call(self, mock_httpx_class: MagicMock) -> None: """Verify any open sessions are closed when calling the `close` method.""" @@ -35,9 +39,25 @@ async def test_close_on_exit(self, mock_httpx_class: MagicMock) -> None: mock_httpx_class.return_value.aclose.assert_called_once() + async def test_close_when_already_closed(self, mock_httpx_class: MagicMock) -> None: + """Verify calling `close` on an already closed client is a no-op.""" + + mock_httpx_class.return_value.aclose = AsyncMock() + + client = AsyncHTTPClient(base_url="https://example.com") + await client.close() + await client.close() + + mock_httpx_class.return_value.aclose.assert_called_once() + class SendRequestMethodAsync(IsolatedAsyncioTestCase): - """Test HTTP requests issued by the `send_requests` method.""" + """Tests the `send_request` method. + + Verifies outgoing requests are addressed to the correctly normalized + URL, include the expected application headers, and produce a log + record describing the request. + """ async def asyncSetUp(self) -> None: """Create a new async client instance using a dummy HTTP request handler.""" @@ -82,3 +102,35 @@ async def test_logs_request(self) -> None: self.assertEqual(expected_method, record.method) self.assertEqual(expected_endpoint, record.endpoint) self.assertEqual(expected_url, record.url) + + +class HttpMethodShortcutsAsync(IsolatedAsyncioTestCase): + """Tests the `http_get`, `http_post`, `http_patch`, `http_put`, and `http_delete` methods. + + Verifies each shortcut method issues a request using its corresponding + HTTP verb against the target endpoint. + """ + + async def asyncSetUp(self) -> None: + """Create a new async client instance using a dummy HTTP request handler.""" + + self.base_url = 'https://test.api' + self.transport = httpx.MockTransport(utils.mock_request_handler) + self.client = AsyncHTTPClient(self.base_url, transport=self.transport) + + async def test_sends_correct_http_verb(self) -> None: + """Verify each shortcut method issues a request with the matching HTTP verb.""" + + shortcuts = ( + (self.client.http_get, 'GET'), + (self.client.http_post, 'POST'), + (self.client.http_patch, 'PATCH'), + (self.client.http_put, 'PUT'), + (self.client.http_delete, 'DELETE'), + ) + + for shortcut, expected_verb in shortcuts: + with self.subTest(verb=expected_verb): + response = await shortcut('v1/resource') + request_details = response.json() + self.assertEqual(expected_verb, request_details['method']) diff --git a/tests/unit_tests/test_http/test_HTTPBase.py b/tests/unit_tests/test_http/test_HTTPBase.py index 6617332..3d35cc2 100644 --- a/tests/unit_tests/test_http/test_HTTPBase.py +++ b/tests/unit_tests/test_http/test_HTTPBase.py @@ -2,7 +2,7 @@ import uuid from unittest import TestCase -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from keystone_client.http import HTTPBase @@ -38,7 +38,11 @@ def _client_factory(self, **kwargs) -> MagicMock: class BaseUrlProperty(TestCase): - """Test the `base_url` property returns the correct value.""" + """Tests the `base_url` property. + + Verifies the value returned by the property reflects the + normalized form of the URL the instance was constructed with. + """ def test_returns_normalized_url(self) -> None: """Verify the `base_url` property returns the normalized URL.""" @@ -49,7 +53,11 @@ def test_returns_normalized_url(self) -> None: class CidProperty(TestCase): - """Test the `cid` property returns a UUID value.""" + """Tests the `cid` property. + + Verifies the value returned by the property is a well-formed + identifier suitable for tracking a client session. + """ def test_returns_valid_uuid(self) -> None: """Verify the `cid` property returns a valid UUID.""" @@ -63,30 +71,13 @@ def test_returns_valid_uuid(self) -> None: self.fail(f"cid '{http_base.cid}' is not a valid UUID4") -class NormalizeUrlMethod(TestCase): - """Test the normalization of URL paths.""" - - def test_trailing_slash_enforced(self) -> None: - """Verify the URL is returned with a single trailing slash.""" - - base_url = 'https://test.domain.com' - expected_url = base_url + '/' - - # Test for various numbers of trailing slashes provided at init - self.assertEqual(expected_url, HTTPBase.normalize_url(base_url)) - self.assertEqual(expected_url, HTTPBase.normalize_url(base_url + '/')) - self.assertEqual(expected_url, HTTPBase.normalize_url(base_url + '////')) - - def test_no_intermediate_slashes(self) -> None: - """Verify duplicate slashes are removed from the URL path.""" - - base_url = 'https://test.domain.com///path/' - expected_url = 'https://test.domain.com/path/' - self.assertEqual(expected_url, HTTPBase.normalize_url(base_url)) - - class GetApplicationHeadersMethod(TestCase): - """Test fetching a session's application headers.""" + """Tests the `get_application_headers` method. + + Verifies the returned headers include the application specific + values expected by the Keystone API, and that any caller supplied + overrides take precedence over those defaults. + """ def setUp(self) -> None: """Create a HTTPBase instance with a mocked client.""" @@ -138,3 +129,30 @@ def test_header_overrides_replace_existing_header(self) -> None: headers = self.http_base.get_application_headers(overrides) self.assertEqual(custom_cid, headers[HTTPBase.CID_HEADER]) + + +class NormalizeUrlMethod(TestCase): + """Tests the `normalize_url` method. + + Verifies URLs are consistently reformatted into a format + compatible with the official API, regardless of variation + in slash placement or repetition in the input. + """ + + def test_trailing_slash_enforced(self) -> None: + """Verify the URL is returned with a single trailing slash.""" + + base_url = 'https://test.domain.com' + expected_url = base_url + '/' + + # Test for various numbers of trailing slashes provided at init + self.assertEqual(expected_url, HTTPBase.normalize_url(base_url)) + self.assertEqual(expected_url, HTTPBase.normalize_url(base_url + '/')) + self.assertEqual(expected_url, HTTPBase.normalize_url(base_url + '////')) + + def test_no_intermediate_slashes(self) -> None: + """Verify duplicate slashes are removed from the URL path.""" + + base_url = 'https://test.domain.com///path/' + expected_url = 'https://test.domain.com/path/' + self.assertEqual(expected_url, HTTPBase.normalize_url(base_url)) diff --git a/tests/unit_tests/test_http/test_HTTPClient.py b/tests/unit_tests/test_http/test_HTTPClient.py index dadd22f..404a38e 100644 --- a/tests/unit_tests/test_http/test_HTTPClient.py +++ b/tests/unit_tests/test_http/test_HTTPClient.py @@ -11,9 +11,28 @@ from tests.unit_tests import utils +class CloseAtExit(TestCase): + """Tests the registration of `close` with `atexit`. + + Verifies the client's cleanup logic is registered to run + automatically when the interpreter exits. + """ + + @patch('atexit.register') + def test_close_registered_with_atexit(self, mock_atexit_register: MagicMock) -> None: + """Verify the `close` method is registered with `atexit` on initialization.""" + + client = HTTPClient(base_url="https://example.com") + mock_atexit_register.assert_any_call(client.close) + + @patch("httpx.Client") class CloseMethod(TestCase): - """Test the termination of open connections.""" + """Tests the `close` method. + + Verifies any open connections are terminated when the method is called + directly, and when the client is exited via its context manager. + """ def test_close_on_function_call(self, mock_httpx_class: MagicMock) -> None: """Verify any open sessions are closed when calling the `close` method.""" @@ -26,14 +45,28 @@ def test_close_on_function_call(self, mock_httpx_class: MagicMock) -> None: def test_close_on_exit(self, mock_httpx_class: MagicMock) -> None: """Verify any open sessions are closed when exiting a context manager.""" - with HTTPClient(base_url="https://example.com") as client: + with HTTPClient(base_url="https://example.com") as client: pass mock_httpx_class.return_value.close.assert_called_once() + def test_close_when_already_closed(self, mock_httpx_class: MagicMock) -> None: + """Verify calling `close` on an already closed client is a no-op.""" + + client = HTTPClient(base_url="https://example.com") + client.close() + client.close() + + mock_httpx_class.return_value.close.assert_called_once() + class SendRequestMethod(TestCase): - """Test HTTP requests issued by the `send_requests` method.""" + """Tests the `send_request` method. + + Verifies outgoing requests are addressed to the correctly normalized + URL, include the expected application headers, and produce a log + record describing the request. + """ def setUp(self) -> None: """Create a new client instance using a dummy HTTP request handler.""" @@ -80,13 +113,33 @@ def test_logs_request(self) -> None: self.assertEqual(expected_url, record.url) +class HttpMethodShortcuts(TestCase): + """Tests the `http_get`, `http_post`, `http_patch`, `http_put`, and `http_delete` methods. -class CloseAtExit(TestCase): - """Test resource cleanup at application exit.""" + Verifies each shortcut method issues a request using its corresponding + HTTP verb against the target endpoint. + """ - @patch('atexit.register') - def test_close_registered_with_atexit(self, mock_atexit_register: MagicMock) -> None: - """Verify the `close` method is registered with `atexit` on initialization.""" + def setUp(self) -> None: + """Create a new client instance using a dummy HTTP request handler.""" - client = HTTPClient(base_url="https://example.com") - mock_atexit_register.assert_any_call(client.close) + self.base_url = 'https://test.api' + self.transport = httpx.MockTransport(utils.mock_request_handler) + self.client = HTTPClient(self.base_url, transport=self.transport) + + def test_sends_correct_http_verb(self) -> None: + """Verify each shortcut method issues a request with the matching HTTP verb.""" + + shortcuts = ( + (self.client.http_get, 'GET'), + (self.client.http_post, 'POST'), + (self.client.http_patch, 'PATCH'), + (self.client.http_put, 'PUT'), + (self.client.http_delete, 'DELETE'), + ) + + for shortcut, expected_verb in shortcuts: + with self.subTest(verb=expected_verb): + response = shortcut('v1/resource') + request_details = response.json() + self.assertEqual(expected_verb, request_details['method']) diff --git a/tests/unit_tests/test_log/test_ContextFilter.py b/tests/unit_tests/test_log/test_ContextFilter.py index e01a134..9b0bad2 100644 --- a/tests/unit_tests/test_log/test_ContextFilter.py +++ b/tests/unit_tests/test_log/test_ContextFilter.py @@ -8,7 +8,11 @@ class FilterMethod(TestCase): - """Verify the assignment of default attributes by the `filter` method.""" + """Tests the `filter` method. + + Verifies that log records are populated with the expected + attributes without overwriting any values already present. + """ @staticmethod def _create_log_record() -> LogRecord: @@ -45,7 +49,7 @@ def test_missing_attributes_are_added(self) -> None: self.assertEqual("", record.url) def test_existing_attributes_are_preserved(self) -> None: - """Verify existing attributes are not overwritted.""" + """Verify existing attributes are not overwritten.""" record = self._create_log_record() record.cid = "123" @@ -64,7 +68,7 @@ def test_existing_attributes_are_preserved(self) -> None: self.assertEqual("", record.url) def test_filter_always_returns_true(self) -> None: - """Verify the returned value is true.""" + """Verify the returned value is `true`, indicating the record should propagate.""" record = self._create_log_record() result = ContextFilter().filter(record) diff --git a/tests/unit_tests/test_log/test_DefaultContextAdapter.py b/tests/unit_tests/test_log/test_DefaultContextAdapter.py index cfe7c1c..1472c55 100644 --- a/tests/unit_tests/test_log/test_DefaultContextAdapter.py +++ b/tests/unit_tests/test_log/test_DefaultContextAdapter.py @@ -7,7 +7,12 @@ class ProcessMethod(unittest.TestCase): - """Test the injection of default values into logging `extras`.""" + """Tests the `process` method. + + Verifies that the log message and its context values are merged + correctly, with values explicitly provided by the caller always + taking precedence over the adapter's defaults. + """ def setUp(self) -> None: """Instantiate testing fixtures."""