Skip to content

Commit f765b27

Browse files
test: add test suite; fix cancel_batch and add get_batch_results
Tests (58 passing): - test_imports: all public imports work, no email-validator required (#2254) - test_models: Pydantic models deserialize correctly with plain str email - test_client: URL construction, HTTP methods, auth headers, trace IDs - test_config: validation rules, defaults, User-Agent version - test_exceptions: hierarchy, error details, status codes Bug fixes: - cancel_batch: POST /batch/{id}/cancel → DELETE /batch/{id} (#2316) - get_batch_results: new method for GET /batch/{id}/results (#2317) Closes #2316, closes #2317
1 parent c026691 commit f765b27

8 files changed

Lines changed: 656 additions & 7 deletions

File tree

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Shared fixtures for ValidKit SDK tests."""
2+
3+
import pytest
4+
from unittest.mock import AsyncMock, patch
5+
from validkit import AsyncValidKit
6+
from validkit.config import ValidKitConfig
7+
8+
9+
@pytest.fixture
10+
def api_key():
11+
return "vk_test_abc123"
12+
13+
14+
@pytest.fixture
15+
def config(api_key):
16+
return ValidKitConfig(api_key=api_key)
17+
18+
19+
@pytest.fixture
20+
def client(api_key):
21+
return AsyncValidKit(api_key=api_key)
22+
23+
24+
@pytest.fixture
25+
def mock_request():
26+
"""Patch AsyncValidKit._request to capture calls without making HTTP requests."""
27+
with patch.object(AsyncValidKit, '_request', new_callable=AsyncMock) as mock:
28+
yield mock

tests/test_client.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""Tests for AsyncValidKit client.
2+
3+
Verifies URL construction, HTTP methods, auth headers, and request/response
4+
handling without making real HTTP calls.
5+
"""
6+
7+
import pytest
8+
from unittest.mock import AsyncMock, patch, MagicMock
9+
from datetime import datetime
10+
11+
from validkit.client import AsyncValidKit
12+
from validkit.config import ValidKitConfig
13+
from validkit.models import (
14+
EmailVerificationResult,
15+
CompactResult,
16+
BatchJob,
17+
BatchVerificationResult,
18+
BatchJobStatus,
19+
ResponseFormat,
20+
)
21+
from validkit.exceptions import (
22+
BatchSizeError,
23+
)
24+
25+
26+
class TestClientInit:
27+
def test_requires_api_key(self):
28+
with pytest.raises(ValueError, match="API key"):
29+
AsyncValidKit()
30+
31+
def test_accepts_api_key_string(self):
32+
client = AsyncValidKit(api_key="vk_test_123")
33+
assert client.config.api_key == "vk_test_123"
34+
35+
def test_accepts_config_object(self):
36+
config = ValidKitConfig(api_key="vk_test_456", timeout=60)
37+
client = AsyncValidKit(config=config)
38+
assert client.config.api_key == "vk_test_456"
39+
assert client.config.timeout == 60
40+
41+
def test_api_key_overrides_config(self):
42+
config = ValidKitConfig(api_key="from_config")
43+
client = AsyncValidKit(api_key="from_param", config=config)
44+
assert client.config.api_key == "from_param"
45+
46+
47+
class TestURLConstruction:
48+
def test_default_api_url(self):
49+
config = ValidKitConfig(api_key="test")
50+
assert config.api_url == "https://api.validkit.com/api/v1"
51+
52+
def test_custom_base_url(self):
53+
config = ValidKitConfig(api_key="test", base_url="https://custom.api.com")
54+
assert config.api_url == "https://custom.api.com/api/v1"
55+
56+
def test_trailing_slash_stripped(self):
57+
config = ValidKitConfig(api_key="test", base_url="https://api.validkit.com/")
58+
assert config.api_url == "https://api.validkit.com/api/v1"
59+
60+
61+
class TestAuthHeaders:
62+
def test_sends_api_key_header(self):
63+
config = ValidKitConfig(api_key="vk_test_secret")
64+
headers = config.headers
65+
assert headers["X-API-Key"] == "vk_test_secret"
66+
67+
def test_sends_user_agent(self):
68+
config = ValidKitConfig(api_key="test")
69+
assert "ValidKit-Python" in config.headers["User-Agent"]
70+
71+
def test_sends_json_content_type(self):
72+
config = ValidKitConfig(api_key="test")
73+
assert config.headers["Content-Type"] == "application/json"
74+
75+
76+
class TestVerifyEmail:
77+
"""Tests for verify_email.
78+
79+
Note: default config has compact_format=True, so verify_email always
80+
takes the compact response path (expects response['result']).
81+
Tests use compact-shaped mock responses to match this behavior.
82+
"""
83+
84+
@pytest.mark.asyncio
85+
async def test_calls_correct_endpoint(self, client, mock_request):
86+
mock_request.return_value = {"result": {"v": True}}
87+
await client.verify_email("test@example.com")
88+
mock_request.assert_called_once()
89+
args = mock_request.call_args
90+
assert args[0][0] == "POST"
91+
assert args[0][1] == "verify"
92+
93+
@pytest.mark.asyncio
94+
async def test_returns_compact_result_by_default(self, client, mock_request):
95+
mock_request.return_value = {"result": {"v": True, "d": False}}
96+
result = await client.verify_email("user@test.com")
97+
assert isinstance(result, CompactResult)
98+
assert result.v is True
99+
100+
@pytest.mark.asyncio
101+
async def test_full_format_with_compact_disabled(self, mock_request):
102+
"""When compact_format=False and format=FULL, returns EmailVerificationResult."""
103+
client = AsyncValidKit(
104+
config=ValidKitConfig(api_key="test", compact_format=False)
105+
)
106+
mock_request.return_value = {
107+
"success": True,
108+
"email": "t@t.com",
109+
"valid": True,
110+
}
111+
result = await client.verify_email("t@t.com")
112+
assert isinstance(result, EmailVerificationResult)
113+
assert result.email == "t@t.com"
114+
115+
@pytest.mark.asyncio
116+
async def test_passes_trace_id(self, client, mock_request):
117+
mock_request.return_value = {"result": {"v": True}}
118+
await client.verify_email("t@t.com", trace_id="trace-123")
119+
call_args = mock_request.call_args
120+
headers = call_args.kwargs.get("headers") or call_args[1].get("headers", {})
121+
assert headers.get("X-Trace-ID") == "trace-123"
122+
123+
124+
class TestVerifyBatch:
125+
@pytest.mark.asyncio
126+
async def test_calls_agent_bulk_endpoint(self, client, mock_request):
127+
mock_request.return_value = {
128+
"results": {"a@b.com": {"v": True}},
129+
}
130+
await client.verify_batch(["a@b.com"])
131+
args = mock_request.call_args
132+
assert args[0][0] == "POST"
133+
assert args[0][1] == "verify/bulk/agent"
134+
135+
@pytest.mark.asyncio
136+
async def test_rejects_oversized_batch(self, client, mock_request):
137+
emails = [f"user{i}@test.com" for i in range(10001)]
138+
with pytest.raises(BatchSizeError):
139+
await client.verify_batch(emails)
140+
141+
142+
class TestGetBatchStatus:
143+
@pytest.mark.asyncio
144+
async def test_calls_correct_endpoint(self, client, mock_request):
145+
now = datetime.now().isoformat()
146+
mock_request.return_value = {
147+
"id": "batch-abc",
148+
"status": "processing",
149+
"total_emails": 100,
150+
"processed": 50,
151+
"created_at": now,
152+
"updated_at": now,
153+
}
154+
result = await client.get_batch_status("batch-abc")
155+
args = mock_request.call_args
156+
assert args[0][0] == "GET"
157+
assert args[0][1] == "batch/batch-abc"
158+
assert isinstance(result, BatchJob)
159+
160+
161+
class TestCancelBatch:
162+
"""Tests for cancel_batch — regression tests for #2316.
163+
164+
The original bug: SDK sent POST to /batch/{id}/cancel.
165+
The API expects: DELETE to /batch/{id}.
166+
"""
167+
168+
@pytest.mark.asyncio
169+
async def test_uses_delete_method(self, client, mock_request):
170+
"""cancel_batch MUST use DELETE, not POST (#2316)."""
171+
now = datetime.now().isoformat()
172+
mock_request.return_value = {
173+
"id": "batch-abc",
174+
"status": "cancelled",
175+
"total_emails": 100,
176+
"processed": 50,
177+
"created_at": now,
178+
"updated_at": now,
179+
}
180+
await client.cancel_batch("batch-abc")
181+
args = mock_request.call_args
182+
assert args[0][0] == "DELETE", (
183+
f"cancel_batch must use DELETE, got {args[0][0]}. "
184+
"See #2316: API route is DELETE /v1/batch/:batchId"
185+
)
186+
187+
@pytest.mark.asyncio
188+
async def test_calls_correct_path(self, client, mock_request):
189+
"""cancel_batch path must be batch/{id}, not batch/{id}/cancel."""
190+
now = datetime.now().isoformat()
191+
mock_request.return_value = {
192+
"id": "batch-xyz",
193+
"status": "cancelled",
194+
"total_emails": 10,
195+
"processed": 5,
196+
"created_at": now,
197+
"updated_at": now,
198+
}
199+
await client.cancel_batch("batch-xyz")
200+
args = mock_request.call_args
201+
assert args[0][1] == "batch/batch-xyz", (
202+
f"cancel_batch path must be 'batch/batch-xyz', got '{args[0][1]}'. "
203+
"See #2316: no /cancel suffix"
204+
)
205+
206+
@pytest.mark.asyncio
207+
async def test_returns_batch_job(self, client, mock_request):
208+
now = datetime.now().isoformat()
209+
mock_request.return_value = {
210+
"id": "batch-abc",
211+
"status": "cancelled",
212+
"total_emails": 100,
213+
"processed": 50,
214+
"created_at": now,
215+
"updated_at": now,
216+
}
217+
result = await client.cancel_batch("batch-abc")
218+
assert isinstance(result, BatchJob)
219+
assert result.status == BatchJobStatus.CANCELLED
220+
221+
222+
class TestGetBatchResults:
223+
"""Tests for get_batch_results — regression tests for #2317.
224+
225+
This method was entirely missing from the original SDK.
226+
The API has GET /v1/batch/:batchId/results.
227+
"""
228+
229+
@pytest.mark.asyncio
230+
async def test_method_exists(self, client):
231+
"""get_batch_results must exist on AsyncValidKit (#2317)."""
232+
assert hasattr(client, "get_batch_results"), (
233+
"AsyncValidKit is missing get_batch_results(). See #2317."
234+
)
235+
236+
@pytest.mark.asyncio
237+
async def test_calls_correct_endpoint(self, client, mock_request):
238+
mock_request.return_value = {
239+
"success": True,
240+
"total": 2,
241+
"valid": 1,
242+
"invalid": 1,
243+
"results": {
244+
"a@b.com": {"v": True},
245+
"c@d.com": {"v": False, "r": "invalid"},
246+
},
247+
}
248+
result = await client.get_batch_results("batch-abc")
249+
args = mock_request.call_args
250+
assert args[0][0] == "GET"
251+
assert args[0][1] == "batch/batch-abc/results"
252+
253+
@pytest.mark.asyncio
254+
async def test_returns_batch_verification_result(self, client, mock_request):
255+
mock_request.return_value = {
256+
"success": True,
257+
"total": 1,
258+
"valid": 1,
259+
"invalid": 0,
260+
"results": {"a@b.com": {"v": True}},
261+
}
262+
result = await client.get_batch_results("batch-abc")
263+
assert isinstance(result, BatchVerificationResult)
264+
assert result.total == 1

tests/test_config.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Tests for ValidKitConfig validation."""
2+
3+
import pytest
4+
from validkit.config import ValidKitConfig
5+
6+
7+
class TestConfigValidation:
8+
def test_requires_api_key(self):
9+
with pytest.raises(ValueError, match="API key"):
10+
ValidKitConfig(api_key="")
11+
12+
def test_requires_valid_base_url(self):
13+
with pytest.raises(ValueError, match="Base URL"):
14+
ValidKitConfig(api_key="test", base_url="not-a-url")
15+
16+
def test_requires_positive_timeout(self):
17+
with pytest.raises(ValueError, match="Timeout"):
18+
ValidKitConfig(api_key="test", timeout=0)
19+
20+
def test_requires_non_negative_retries(self):
21+
with pytest.raises(ValueError, match="retries"):
22+
ValidKitConfig(api_key="test", max_retries=-1)
23+
24+
def test_requires_positive_rate_limit(self):
25+
with pytest.raises(ValueError, match="Rate limit"):
26+
ValidKitConfig(api_key="test", rate_limit=0)
27+
28+
def test_rate_limit_none_is_valid(self):
29+
config = ValidKitConfig(api_key="test", rate_limit=None)
30+
assert config.rate_limit is None
31+
32+
def test_defaults(self):
33+
config = ValidKitConfig(api_key="test_key")
34+
assert config.base_url == "https://api.validkit.com"
35+
assert config.api_version == "v1"
36+
assert config.timeout == 30
37+
assert config.max_retries == 3
38+
assert config.compact_format is True
39+
40+
def test_user_agent_contains_version(self):
41+
config = ValidKitConfig(api_key="test")
42+
assert "1.1.1" in config.user_agent

0 commit comments

Comments
 (0)