Skip to content

Commit d357617

Browse files
committed
expand test coverage
1 parent 57141b5 commit d357617

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

src/tests/unit/client/test_raw_client.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,30 @@
1+
from collections.abc import AsyncGenerator
2+
3+
import httpx
4+
import pytest
5+
import respx
6+
7+
from bubble_data_api_client import configure
18
from bubble_data_api_client.client import raw_client
9+
from bubble_data_api_client.pool import close_clients
10+
11+
12+
@pytest.fixture
13+
async def clean_client_pool() -> AsyncGenerator[None]:
14+
"""Ensure client pool is clean before and after each test."""
15+
await close_clients()
16+
yield
17+
await close_clients()
18+
19+
20+
@pytest.fixture
21+
def configured_client(clean_client_pool: None) -> None:
22+
"""Configure the client for testing."""
23+
configure(
24+
data_api_root_url="https://test.example.com",
25+
api_key="test-key",
26+
retry=None,
27+
)
228

329

430
async def test_raw_client_init() -> None:
@@ -14,3 +40,159 @@ async def test_raw_client_init() -> None:
1440
# test creating with async context manager
1541
async with raw_client.RawClient() as client_instance:
1642
assert isinstance(client_instance, raw_client.RawClient)
43+
44+
45+
@respx.mock
46+
async def test_replace(configured_client: None) -> None:
47+
"""Test that replace uses PUT to fully replace a thing."""
48+
route = respx.put("https://test.example.com/customer/123x456").mock(return_value=httpx.Response(204))
49+
50+
async with raw_client.RawClient() as client:
51+
response = await client.replace(
52+
typename="customer",
53+
uid="123x456",
54+
data={"name": "New Name", "email": "new@example.com"},
55+
)
56+
57+
assert response.status_code == 204
58+
assert route.call_count == 1
59+
60+
61+
@respx.mock
62+
async def test_bulk_create(configured_client: None) -> None:
63+
"""Test that bulk_create posts newline-delimited JSON."""
64+
route = respx.post("https://test.example.com/customer/bulk").mock(
65+
return_value=httpx.Response(200, json={"status": "success", "count": 2})
66+
)
67+
68+
async with raw_client.RawClient() as client:
69+
response = await client.bulk_create(
70+
typename="customer",
71+
data=[{"name": "Alice"}, {"name": "Bob"}],
72+
)
73+
74+
assert response.status_code == 200
75+
assert route.call_count == 1
76+
# verify it sent newline-delimited JSON
77+
request_content = route.calls[0].request.content.decode()
78+
assert request_content == '{"name": "Alice"}\n{"name": "Bob"}'
79+
80+
81+
@respx.mock
82+
async def test_find_with_parameters(configured_client: None) -> None:
83+
"""Test that find passes optional parameters correctly."""
84+
route = respx.get("https://test.example.com/customer").mock(
85+
return_value=httpx.Response(200, json={"response": {"results": [], "count": 0, "remaining": 0}})
86+
)
87+
88+
async with raw_client.RawClient() as client:
89+
await client.find(
90+
typename="customer",
91+
cursor=10,
92+
limit=50,
93+
sort_field="name",
94+
descending=True,
95+
exclude_remaining=True,
96+
)
97+
98+
assert route.call_count == 1
99+
request = route.calls[0].request
100+
assert "cursor=10" in str(request.url)
101+
assert "limit=50" in str(request.url)
102+
assert "sort_field=name" in str(request.url)
103+
assert "descending=true" in str(request.url)
104+
assert "exclude_remaining=true" in str(request.url)
105+
106+
107+
@respx.mock
108+
async def test_find_with_additional_sort_fields(configured_client: None) -> None:
109+
"""Test that find passes additional_sort_fields correctly."""
110+
route = respx.get("https://test.example.com/customer").mock(
111+
return_value=httpx.Response(200, json={"response": {"results": [], "count": 0, "remaining": 0}})
112+
)
113+
114+
async with raw_client.RawClient() as client:
115+
await client.find(
116+
typename="customer",
117+
additional_sort_fields=[{"sort_field": "age", "descending": False}],
118+
)
119+
120+
assert route.call_count == 1
121+
request = route.calls[0].request
122+
assert "additional_sort_fields" in str(request.url)
123+
124+
125+
@respx.mock
126+
async def test_count(configured_client: None) -> None:
127+
"""Test that count returns total from count + remaining."""
128+
respx.get("https://test.example.com/customer").mock(
129+
return_value=httpx.Response(200, json={"response": {"results": [], "count": 5, "remaining": 95}})
130+
)
131+
132+
async with raw_client.RawClient() as client:
133+
total = await client.count(typename="customer")
134+
135+
assert total == 100
136+
137+
138+
@respx.mock
139+
async def test_exists_by_uid_found(configured_client: None) -> None:
140+
"""Test exists returns True when record found by uid."""
141+
respx.get("https://test.example.com/customer/123x456").mock(
142+
return_value=httpx.Response(200, json={"response": {"_id": "123x456"}})
143+
)
144+
145+
async with raw_client.RawClient() as client:
146+
result = await client.exists(typename="customer", uid="123x456")
147+
148+
assert result is True
149+
150+
151+
@respx.mock
152+
async def test_exists_by_uid_not_found(configured_client: None) -> None:
153+
"""Test exists returns False when record not found by uid."""
154+
respx.get("https://test.example.com/customer/123x456").mock(
155+
return_value=httpx.Response(404, json={"status": "NOT_FOUND"})
156+
)
157+
158+
async with raw_client.RawClient() as client:
159+
result = await client.exists(typename="customer", uid="123x456")
160+
161+
assert result is False
162+
163+
164+
@respx.mock
165+
async def test_exists_by_uid_error_reraises(configured_client: None) -> None:
166+
"""Test exists re-raises non-404 HTTP errors."""
167+
respx.get("https://test.example.com/customer/123x456").mock(
168+
return_value=httpx.Response(500, json={"error": "server error"})
169+
)
170+
171+
async with raw_client.RawClient() as client:
172+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
173+
await client.exists(typename="customer", uid="123x456")
174+
175+
assert exc_info.value.response.status_code == 500
176+
177+
178+
@respx.mock
179+
async def test_exists_by_constraints(configured_client: None) -> None:
180+
"""Test exists with constraints uses find."""
181+
respx.get("https://test.example.com/customer").mock(
182+
return_value=httpx.Response(200, json={"response": {"results": [{"_id": "1x1"}], "count": 1, "remaining": 0}})
183+
)
184+
185+
async with raw_client.RawClient() as client:
186+
result = await client.exists(
187+
typename="customer",
188+
constraints=[{"key": "email", "constraint_type": "equals", "value": "test@example.com"}],
189+
)
190+
191+
assert result is True
192+
193+
194+
async def test_exists_uid_and_constraints_raises(configured_client: None) -> None:
195+
"""Test exists raises when both uid and constraints provided."""
196+
async with raw_client.RawClient() as client:
197+
with pytest.raises(ValueError, match="Cannot specify both"):
198+
await client.exists(typename="customer", uid="123x456", constraints=[{"key": "x"}])

src/tests/unit/test_config.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Tests for bubble_data_api_client.config module."""
2+
3+
from bubble_data_api_client.config import (
4+
NOT_SET,
5+
configure,
6+
get_config,
7+
set_config_provider,
8+
)
9+
10+
11+
def test_not_set_repr():
12+
"""NOT_SET sentinel should have readable repr."""
13+
assert repr(NOT_SET) == "NOT_SET"
14+
15+
16+
def test_set_config_provider():
17+
"""Config provider should override static config."""
18+
# set static config first
19+
configure(data_api_root_url="https://static.example.com", api_key="static-key")
20+
assert get_config()["data_api_root_url"] == "https://static.example.com"
21+
22+
# set provider - should override static config
23+
set_config_provider(lambda: {"data_api_root_url": "https://dynamic.example.com", "api_key": "dynamic-key"})
24+
assert get_config()["data_api_root_url"] == "https://dynamic.example.com"
25+
assert get_config()["api_key"] == "dynamic-key"
26+
27+
# calling configure should clear the provider
28+
configure(data_api_root_url="https://new-static.example.com", api_key="new-static-key")
29+
assert get_config()["data_api_root_url"] == "https://new-static.example.com"

src/tests/unit/test_pool.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Tests for bubble_data_api_client.pool module."""
2+
3+
from collections.abc import AsyncGenerator
4+
5+
import httpx
6+
import pytest
7+
8+
from bubble_data_api_client import configure
9+
from bubble_data_api_client.exceptions import ConfigurationError
10+
from bubble_data_api_client.pool import client_scope, close_clients, get_client
11+
12+
13+
@pytest.fixture
14+
async def clean_client_pool() -> AsyncGenerator[None]:
15+
"""Ensure client pool is clean before and after each test."""
16+
await close_clients()
17+
yield
18+
await close_clients()
19+
20+
21+
async def test_get_client_empty_url_raises(clean_client_pool: None) -> None:
22+
"""get_client should raise ConfigurationError when data_api_root_url is empty."""
23+
configure(data_api_root_url="", api_key="valid-key")
24+
with pytest.raises(ConfigurationError, match="data_api_root_url"):
25+
get_client()
26+
27+
28+
async def test_get_client_empty_api_key_raises(clean_client_pool: None) -> None:
29+
"""get_client should raise ConfigurationError when api_key is empty."""
30+
configure(data_api_root_url="https://example.com", api_key="")
31+
with pytest.raises(ConfigurationError, match="api_key"):
32+
get_client()
33+
34+
35+
async def test_client_scope_closes_clients(clean_client_pool: None) -> None:
36+
"""client_scope should close clients on exit."""
37+
configure(data_api_root_url="https://example.com", api_key="valid-key", retry=None)
38+
39+
async with client_scope():
40+
client = get_client()
41+
assert not client.is_closed
42+
43+
# client should be closed after exiting scope
44+
assert client.is_closed
45+
46+
47+
async def test_close_clients_skips_already_closed(clean_client_pool: None) -> None:
48+
"""close_clients should skip clients that are already closed."""
49+
configure(data_api_root_url="https://example.com", api_key="valid-key", retry=None)
50+
51+
client = get_client()
52+
# manually close the client first
53+
await client.aclose()
54+
assert client.is_closed
55+
56+
# close_clients should not raise when client is already closed
57+
await close_clients()
58+
59+
60+
def test_get_client_outside_async_context() -> None:
61+
"""get_client should return uncached client when called outside async context."""
62+
configure(data_api_root_url="https://example.com", api_key="valid-key", retry=None)
63+
64+
# called from sync context - no running event loop
65+
client = get_client()
66+
assert isinstance(client, httpx.AsyncClient)
67+
assert not client.is_closed

0 commit comments

Comments
 (0)