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
7 changes: 4 additions & 3 deletions campus/audit/client/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,15 @@ def make_path(self, part: str | None = None) -> str:
"""Create a full path for a sub-resource or action."""
if part:
return (
f"/{self.root.make_path(self.path).lstrip(SLASH).rstrip(SLASH)}"
f"/{self.root.url_prefix.lstrip(SLASH)}"
f"/{self.path.lstrip(SLASH).rstrip(SLASH)}"
f"/{part.lstrip(SLASH)}"
)
return f"/{self.root.make_path(self.path).lstrip(SLASH)}"
return f"/{self.root.url_prefix.lstrip(SLASH)}/{self.path.lstrip(SLASH)}"

def make_url(self, part: str | None = None) -> str:
"""Create a full URL for a sub-resource or action."""
return f"{self.root.make_url()}{self.make_path(part)}"
return f"{self.root.base_url}{self.make_path(part)}"


class Resource:
Expand Down
40 changes: 23 additions & 17 deletions campus/audit/client/v1/traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from typing import Optional, Any

from campus.common.http.interface import JsonClient, JsonResponse
from ..interface import ResourceCollection, Resource, ResourceRoot
from ..interface import ResourceCollection, Resource, ResourceRoot, SLASH


class Traces(ResourceCollection):
Expand Down Expand Up @@ -178,19 +178,14 @@ def __init__(self, trace_id: str, parent: ResourceCollection):
trace_id: The trace identifier.
parent: The parent traces resource.
"""
super().__init__(trace_id, parent=parent, root=parent.root)
self.trace_id = trace_id
self.path = parent.make_path(trace_id)

@property
def client(self) -> JsonClient:
"""Get the JsonClient associated with this resource."""
return self.parent.client

@property
def root(self) -> ResourceRoot:
"""Get the root resource."""
return self.parent.root

def get_tree(self) -> JsonResponse:
"""Get the trace tree with hierarchical span structure.

Expand Down Expand Up @@ -231,17 +226,34 @@ def __init__(self, *, trace_id: str, parent: Resource):
trace_id: The trace identifier.
parent: The parent trace resource.
"""
self.trace_id = trace_id
self._client = None # Will use parent's client
# ResourceCollection requires root and path to be set
self.root = parent.root
self.path = "spans/"
self.parent = parent
self.root = parent.root
self._client = None # Will use parent's client
self.trace_id = trace_id

@property
def client(self) -> JsonClient:
"""Get the JsonClient associated with this resource."""
return self.parent.client

def make_path(self, part: str | None = None) -> str:
"""Create a full path for a sub-resource or action.

Override because Spans is nested under Trace (not directly under root).
"""
if part:
return (
f"{self.parent.path.rstrip(SLASH)}"
f"/{self.path.lstrip(SLASH).rstrip(SLASH)}"
f"/{part.lstrip(SLASH)}"
)
return (
f"{self.parent.path.rstrip(SLASH)}"
f"/{self.path.lstrip(SLASH)}"
)

def list(self) -> JsonResponse:
"""List all spans in the trace (flat list).

Expand Down Expand Up @@ -282,20 +294,14 @@ def __init__(self, span_id: str, parent: "Traces.Spans"):
span_id: The span identifier.
parent: The parent spans resource.
"""
super().__init__(span_id, parent=parent, root=parent.root)
self.span_id = span_id
self.trace_id = parent.trace_id
self.path = parent.make_path(span_id)

@property
def client(self) -> JsonClient:
"""Get the JsonClient associated with this resource."""
return self.parent.client

@property
def root(self) -> ResourceRoot:
"""Get the root resource."""
return self.parent.root

def get(self) -> JsonResponse:
"""Get the span details.

Expand Down
62 changes: 62 additions & 0 deletions tests/unit/audit/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Unit tests for campus.audit.client module.

These tests verify that the audit HTTP client fulfills its interface contracts
and properly constructs API requests to the audit service.

Test Principles:
- Test interface contracts, not implementation
- Test resource chaining and path construction
- Do not test actual HTTP requests (those are contract tests)
"""

import unittest
from unittest.mock import Mock, patch

from campus.audit.client import AuditClient


class TestAuditClientInitialization(unittest.TestCase):
"""Test AuditClient initialization and configuration."""

@patch("campus.audit.client._get_base_url")
@patch("campus.audit.client.DefaultClient")
def test_client_initialization(self, mock_client_class, mock_get_base_url):
"""Test that AuditClient initializes with proper configuration."""
mock_get_base_url.return_value = "https://audit.test"
mock_http_client = Mock()
mock_client_class.return_value = mock_http_client

client = AuditClient()

# Verify _get_base_url was called
mock_get_base_url.assert_called_once()
# Verify DefaultClient was instantiated with correct base_url
mock_client_class.assert_called_once_with(base_url="https://audit.test")


class TestAuditClientResources(unittest.TestCase):
"""Test AuditClient resource access."""

@patch("campus.audit.client._get_base_url")
@patch("campus.audit.client.DefaultClient")
def test_traces_property_returns_traces_resource(self, mock_client_class, mock_get_base_url):
"""Test that client.traces returns Traces resource."""
mock_get_base_url.return_value = "https://audit.test"
mock_http_client = Mock()
mock_http_client.base_url = "https://audit.test"
mock_client_class.return_value = mock_http_client

client = AuditClient()

# Access traces property
traces = client.traces

# Verify it's a Traces resource
from campus.audit.client.v1.traces import Traces
self.assertIsInstance(traces, Traces)
self.assertEqual(traces.root, client._root)
self.assertEqual(traces.client, mock_http_client)


if __name__ == "__main__":
unittest.main()
198 changes: 198 additions & 0 deletions tests/unit/audit/test_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""Unit tests for campus.audit.client.interface module.

These tests verify that the base resource classes (ResourceRoot, ResourceCollection,
Resource) properly handle path construction and client propagation.

Test Principles:
- Test path construction with trailing slashes
- Test client propagation from parent to child
- Test bracket access patterns
"""

import unittest

from campus.audit.client.interface import ResourceRoot, ResourceCollection, Resource
from unittest.mock import Mock


class MockJsonClient:
"""Mock JsonClient for testing."""
def __init__(self, base_url: str):
self.base_url = base_url


class TestResourceRoot(unittest.TestCase):
"""Test ResourceRoot base class."""

def test_resource_root_base_url(self):
"""Test that base_url property returns client's base_url."""
client = MockJsonClient("https://audit.test")
root = ResourceRoot(json_client=client)

self.assertEqual(root.base_url, "https://audit.test")

def test_resource_root_client_property(self):
"""Test that client property returns the JsonClient."""
client = MockJsonClient("https://audit.test")
root = ResourceRoot(json_client=client)

self.assertEqual(root.client, client)

def test_resource_root_make_path(self):
"""Test that make_path constructs correct paths."""
client = MockJsonClient("https://audit.test")
root = ResourceRoot(json_client=client)
root.url_prefix = "api/v1"

# Base path
self.assertEqual(root.make_path(), "/api/v1")

# With sub-path
self.assertEqual(root.make_path("users"), "/api/v1/users")

def test_resource_root_make_url(self):
"""Test that make_url constructs full URLs."""
client = MockJsonClient("https://audit.test")
root = ResourceRoot(json_client=client)
root.url_prefix = "api/v1"

self.assertEqual(root.make_url(), "https://audit.test/api/v1")


class MockCollection(ResourceCollection):
"""Mock ResourceCollection for testing."""
path = "users/"

def __init__(self, client=None, *, root: ResourceRoot):
self._client = client
self.root = root


class TestResourceCollection(unittest.TestCase):
"""Test ResourceCollection base class."""

def setUp(self):
"""Set up test fixtures."""
self.client = MockJsonClient("https://audit.test")
self.root = ResourceRoot(json_client=self.client)
self.root.url_prefix = "api/v1"

def test_resource_collection_path_must_have_trailing_slash(self):
"""Test that ResourceCollection path must have trailing slash."""
# Valid path
collection = MockCollection(client=self.client, root=self.root)
self.assertTrue(collection.path.endswith("/"))

# Invalid path should raise ValueError
class InvalidCollection(ResourceCollection):
path = "users" # No trailing slash

with self.assertRaises(ValueError):
InvalidCollection(client=self.client, root=self.root)

def test_resource_collection_make_path(self):
"""Test that make_path constructs correct paths."""
collection = MockCollection(client=self.client, root=self.root)

# Base path
self.assertEqual(collection.make_path(), "/api/v1/users/")

# With sub-path
self.assertEqual(collection.make_path("123"), "/api/v1/users/123")

def test_resource_collection_make_url(self):
"""Test that make_url constructs full URLs."""
collection = MockCollection(client=self.client, root=self.root)

self.assertEqual(collection.make_url(), "https://audit.test/api/v1/users/")
self.assertEqual(collection.make_url("123"), "https://audit.test/api/v1/users/123")

def test_resource_collection_client_propagation(self):
"""Test that client property returns parent's client."""
collection = MockCollection(client=None, root=self.root)

self.assertEqual(collection.client, self.client)

def test_resource_collection_root_property(self):
"""Test that root property returns the root resource."""
collection = MockCollection(client=self.client, root=self.root)

self.assertEqual(collection.root, self.root)


class MockResource(Resource):
"""Mock Resource for testing."""
def __init__(self, *parts, parent: Resource, root: ResourceRoot, client=None):
super().__init__(*parts, parent=parent, root=root, client=client)


class TestResource(unittest.TestCase):
"""Test Resource base class."""

def setUp(self):
"""Set up test fixtures."""
self.client = MockJsonClient("https://audit.test")
self.root = ResourceRoot(json_client=self.client)
self.root.url_prefix = "api/v1"
self.collection = MockCollection(client=self.client, root=self.root)

def test_resource_path_construction(self):
"""Test that path is constructed from parent + parts."""
resource = MockResource("123", parent=self.collection, root=self.root)

self.assertEqual(resource.path, "/api/v1/users/123")

def test_resource_with_multiple_parts(self):
"""Test path construction with multiple parts."""
resource = MockResource("123", "profile", parent=self.collection, root=self.root)

self.assertEqual(resource.path, "/api/v1/users/123/profile")

def test_resource_make_path(self):
"""Test that make_path constructs sub-resource paths."""
resource = MockResource("123", parent=self.collection, root=self.root)

self.assertEqual(resource.make_path(), "/api/v1/users/123")
self.assertEqual(resource.make_path("edit"), "/api/v1/users/123/edit")

def test_resource_make_path_with_end_slash(self):
"""Test that make_path respects end_slash parameter."""
resource = MockResource("123", parent=self.collection, root=self.root)

self.assertTrue(resource.make_path(end_slash=True).endswith("/"))
self.assertFalse(resource.make_path(end_slash=False).endswith("/"))

def test_resource_make_url(self):
"""Test that make_url constructs full URLs."""
resource = MockResource("123", parent=self.collection, root=self.root)

self.assertEqual(resource.make_url(), "https://audit.test/api/v1/users/123")
self.assertEqual(resource.make_url("edit"), "https://audit.test/api/v1/users/123/edit")

def test_resource_client_propagation(self):
"""Test that client property returns parent's client."""
resource = MockResource("123", parent=self.collection, root=self.root)

self.assertEqual(resource.client, self.client)

def test_resource_with_explicit_client(self):
"""Test that explicit client overrides parent's client."""
explicit_client = MockJsonClient("https://other.test")
resource = MockResource(
"123",
parent=self.collection,
root=self.root,
client=explicit_client
)

self.assertEqual(resource.client, explicit_client)

def test_resource_root_property(self):
"""Test that root property returns the root resource."""
resource = MockResource("123", parent=self.collection, root=self.root)

self.assertEqual(resource.root, self.root)


if __name__ == "__main__":
unittest.main()
Loading
Loading