Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions tests/function_tests/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Configuration values for function and integration testing."""

import os

API_HOST = os.environ.get('TEST_API_HOST', 'http://localhost:8000')
Expand Down
23 changes: 19 additions & 4 deletions tests/unit_tests/test_client/test_AsyncKeystoneClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
43 changes: 43 additions & 0 deletions tests/unit_tests/test_client/test_ClientBase.py
Original file line number Diff line number Diff line change
@@ -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())
22 changes: 18 additions & 4 deletions tests/unit_tests/test_client/test_KeystoneClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
58 changes: 55 additions & 3 deletions tests/unit_tests/test_http/test_AsyncHTTPClient.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Unit tests for the `AsyncHTTPClient` method."""
"""Unit tests for the `AsyncHTTPClient` class."""

import logging
from unittest import IsolatedAsyncioTestCase
Expand All @@ -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."""
Expand All @@ -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."""
Expand Down Expand Up @@ -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'])
70 changes: 44 additions & 26 deletions tests/unit_tests/test_http/test_HTTPBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand All @@ -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."""
Expand All @@ -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."""
Expand Down Expand Up @@ -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))
Loading
Loading