Skip to content
40 changes: 9 additions & 31 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ def __init__(self, api_key: Optional[str] = None, integration_id: Any = _UNSET):
# Use provided value (could be None, a string, etc.)
id_to_use = integration_id

self._integration_id = id_to_use
self._captured_requests: List[CapturedRequest] = []

# Initialize SDK
Expand All @@ -64,9 +63,9 @@ def mock_endpoint(

:param method: HTTP method (GET, POST, PUT, DELETE, PATCH)
:param path: URL path (e.g., "/v1/ats/jobs")
:param response: Response dict with 'body', optional 'statusCode', and optional 'headers'
:param response: Response dict with 'body', optional 'status_code', and optional 'headers'
"""
status_code = response.get("statusCode", 200)
status_code = response.get("status_code", 200)
body = response.get("body")
response_headers = response.get("headers", {})

Expand All @@ -90,31 +89,8 @@ def create_response(request: httpx.Request) -> httpx.Response:

# Read body for non-GET requests
request_body = None
if request.method != "GET":
try:
# Try to get content from request
if hasattr(request, "_content"):
body_bytes = request._content
elif hasattr(request, "content"):
body_bytes = request.content
else:
# Try reading from stream
body_bytes = request.read()

if body_bytes:
try:
if isinstance(body_bytes, bytes):
request_body = json.loads(body_bytes.decode())
else:
request_body = json.loads(body_bytes)
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
if isinstance(body_bytes, bytes):
request_body = body_bytes.decode()
else:
request_body = body_bytes
except Exception:
# If we can't read the body, that's okay
pass
if request.method != "GET" and request.content:
request_body = json.loads(request.content.decode())

captured = CapturedRequest(
method=request.method,
Expand Down Expand Up @@ -159,11 +135,13 @@ def get_last_request(self) -> CapturedRequest:
return self._captured_requests[-1]

def clear(self) -> None:
"""Clear captured requests and reset mocks."""
"""Clear captured requests and reset mocks.

Note: respx is managed by the reset_respx fixture, but we need to
clear registered routes between calls within the same test.
"""
self._captured_requests.clear()
respx.stop()
respx.clear()
respx.start() # Restart respx so new mocks can be registered


@pytest.fixture(autouse=True)
Expand Down
58 changes: 57 additions & 1 deletion tests/test_basic_behavior.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Tests for basic SDK behavior."""

import pytest
from inline_snapshot import snapshot
from tests.conftest import MockContext

Expand Down Expand Up @@ -157,3 +156,60 @@ def test_should_correctly_encode_boolean_query_parameters(self):
request_without_deleted = ctx.get_last_request()
assert "include_deleted=false" in request_without_deleted.path

def test_should_correctly_serialize_post_request_body(self):
"""Test that POST request bodies are correctly serialized."""
ctx = MockContext()

ctx.mock_endpoint(
method="POST",
path="/v1/ats/jobs/test-job-id/applications",
response={
"body": {
"status": "success",
"data": {
"id": "app-123",
"remote_id": "remote-app-123",
"outcome": "PENDING",
"rejection_reason_name": None,
"rejected_at": None,
"current_stage_id": "stage-1",
"job_id": "test-job-id",
"candidate_id": "candidate-456",
"custom_fields": {},
"remote_url": "https://example.com/application/123",
"changed_at": "2024-01-01T00:00:00Z",
"remote_deleted_at": None,
"remote_created_at": "2024-01-01T00:00:00Z",
"remote_updated_at": "2024-01-01T00:00:00Z",
"current_stage": None,
"job": None,
"candidate": None,
},
"warnings": [],
},
},
)

# Make the API call
ctx.kombo.ats.create_application(
job_id="test-job-id",
candidate={
"first_name": "Jane",
"last_name": "Smith",
"email_address": "[email protected]",
},
)

# Verify request body is correctly serialized
request = ctx.get_last_request()
assert request.method == "POST"
assert request.body == snapshot(
{
"candidate": {
"first_name": "Jane",
"last_name": "Smith",
"email_address": "[email protected]",
}
}
)

38 changes: 11 additions & 27 deletions tests/test_error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_returns_kombo_ats_error_for_platform_rate_limit_errors(self):
method="GET",
path="/v1/ats/jobs",
response={
"statusCode": 429,
"status_code": 429,
"body": {
"status": "error",
"error": {
Expand All @@ -46,8 +46,6 @@ def test_returns_kombo_ats_error_for_platform_rate_limit_errors(self):

error = exc_info.value
assert str(error) == snapshot("You have exceeded the rate limit. Please try again later.")
assert isinstance(error, KomboAtsError)

assert error.data.error.code == snapshot("PLATFORM.RATE_LIMIT_EXCEEDED")
assert error.data.error.title == snapshot("Rate limit exceeded")
assert error.data.error.message == snapshot("You have exceeded the rate limit. Please try again later.")
Expand All @@ -62,7 +60,7 @@ def test_returns_kombo_ats_error_for_ats_specific_job_closed_errors(self):
method="POST",
path="/v1/ats/jobs/test-job-id/applications",
response={
"statusCode": 400,
"status_code": 400,
"body": {
"status": "error",
"error": {
Expand All @@ -89,8 +87,6 @@ def test_returns_kombo_ats_error_for_ats_specific_job_closed_errors(self):
assert str(error) == snapshot(
"Cannot create application for a closed job. The job must be in an open state."
)
assert isinstance(error, KomboAtsError)

assert error.data.error.code == snapshot("ATS.JOB_CLOSED")
assert error.data.error.title == snapshot("Job is closed")
assert error.data.error.message == snapshot(
Expand All @@ -110,7 +106,7 @@ def test_returns_kombo_hris_error_for_integration_permission_errors(self):
method="GET",
path="/v1/hris/employees",
response={
"statusCode": 403,
"status_code": 403,
"body": {
"status": "error",
"error": {
Expand All @@ -132,8 +128,6 @@ def test_returns_kombo_hris_error_for_integration_permission_errors(self):
assert str(error) == snapshot(
"The integration is missing required permissions to access this resource."
)
assert isinstance(error, KomboHrisError)

assert error.data.error.code == snapshot("INTEGRATION.PERMISSION_MISSING")
assert error.data.error.title == snapshot("Permission missing")
assert error.data.error.message == snapshot(
Expand All @@ -153,7 +147,7 @@ def test_returns_kombo_ats_error_for_platform_input_validation_errors(self):
method="GET",
path="/v1/assessment/orders/open",
response={
"statusCode": 400,
"status_code": 400,
"body": {
"status": "error",
"error": {
Expand All @@ -174,8 +168,6 @@ def test_returns_kombo_ats_error_for_platform_input_validation_errors(self):
error = exc_info.value
# Assessment uses KomboAtsError for errors
assert str(error) == snapshot("The provided input is invalid or malformed.")
assert isinstance(error, KomboAtsError)

assert error.data.error.code == snapshot("PLATFORM.INPUT_INVALID")
assert error.data.error.title == snapshot("Input invalid")
assert error.data.error.message == snapshot("The provided input is invalid or malformed.")
Expand All @@ -193,7 +185,7 @@ def test_returns_kombo_general_error_for_authentication_errors(self):
method="GET",
path="/v1/check-api-key",
response={
"statusCode": 401,
"status_code": 401,
"body": {
"status": "error",
"error": {
Expand All @@ -212,8 +204,6 @@ def test_returns_kombo_general_error_for_authentication_errors(self):
error = exc_info.value
# General endpoints use KomboGeneralError for errors
assert str(error) == snapshot("The provided API key is invalid or expired.")
assert isinstance(error, KomboGeneralError)

assert error.data.error.code == snapshot("PLATFORM.AUTHENTICATION_INVALID")
assert error.data.error.title == snapshot("Authentication invalid")
assert error.data.error.message == snapshot("The provided API key is invalid or expired.")
Expand All @@ -234,7 +224,7 @@ def test_handles_plain_text_500_error_from_load_balancer(self):
method="GET",
path="/v1/ats/jobs",
response={
"statusCode": 500,
"status_code": 500,
"body": "500 Internal Server Error",
},
)
Expand All @@ -245,7 +235,6 @@ def test_handles_plain_text_500_error_from_load_balancer(self):
_ = jobs.next() # Consume first page

error = exc_info.value
assert isinstance(error, SDKDefaultError)
assert str(error) == snapshot(
'Unexpected response received: Status 500 Content-Type "". Body: 500 Internal Server Error'
)
Expand All @@ -258,7 +247,7 @@ def test_handles_plain_text_502_bad_gateway_error(self):
method="GET",
path="/v1/hris/employees",
response={
"statusCode": 502,
"status_code": 502,
"body": "502 Bad Gateway",
"headers": {
"Content-Type": "text/plain",
Expand All @@ -272,7 +261,6 @@ def test_handles_plain_text_502_bad_gateway_error(self):
_ = employees.next() # Consume first page

error = exc_info.value
assert isinstance(error, SDKDefaultError)
assert str(error) == snapshot(
'Unexpected response received: Status 502 Content-Type text/plain. Body: 502 Bad Gateway'
)
Expand All @@ -294,7 +282,7 @@ def test_handles_html_error_page_from_nginx(self):
method="POST",
path="/v1/ats/jobs/test-job-id/applications",
response={
"statusCode": 503,
"status_code": 503,
"body": html_error_page,
},
)
Expand All @@ -310,7 +298,6 @@ def test_handles_html_error_page_from_nginx(self):
)

error = exc_info.value
assert isinstance(error, SDKDefaultError)
assert str(error) == snapshot(
"""\
Unexpected response received: Status 503 Content-Type "". Body: <!DOCTYPE html>
Expand All @@ -332,7 +319,7 @@ def test_handles_empty_response_body_with_error_status_code(self):
method="GET",
path="/v1/check-api-key",
response={
"statusCode": 500,
"status_code": 500,
"body": "",
},
)
Expand All @@ -341,7 +328,6 @@ def test_handles_empty_response_body_with_error_status_code(self):
ctx.kombo.general.check_api_key()

error = exc_info.value
assert isinstance(error, SDKDefaultError)
assert str(error) == snapshot('Unexpected response received: Status 500 Content-Type "". Body: ""')

def test_handles_unexpected_content_type_header(self):
Expand All @@ -353,7 +339,7 @@ def test_handles_unexpected_content_type_header(self):
method="GET",
path="/v1/ats/applications",
response={
"statusCode": 500,
"status_code": 500,
"body": "Server error occurred",
"headers": {
"Content-Type": "text/xml",
Expand All @@ -367,7 +353,6 @@ def test_handles_unexpected_content_type_header(self):
_ = applications.next() # Consume first page

error = exc_info.value
assert isinstance(error, SDKDefaultError)
assert str(error) == snapshot(
'Unexpected response received: Status 500 Content-Type text/xml. Body: Server error occurred'
)
Expand All @@ -387,7 +372,7 @@ def test_handles_unexpected_json_structure_in_error_response(self):
method="GET",
path="/v1/ats/jobs",
response={
"statusCode": 500,
"status_code": 500,
"body": unexpected_json,
},
)
Expand All @@ -399,7 +384,6 @@ def test_handles_unexpected_json_structure_in_error_response(self):

error = exc_info.value
# Valid JSON but unexpected structure triggers ResponseValidationError
assert isinstance(error, ResponseValidationError)
assert "Response validation failed" in str(error)


2 changes: 1 addition & 1 deletion tests/test_job_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class TestKomboATSJobsAPI:
"""Test Kombo ATS Jobs API."""

def test_should_make_correct_http_request_for_get_jobs(self):
"""Test that getJobs makes correct HTTP request."""
"""Test that get_jobs makes correct HTTP request."""
ctx = MockContext()

# Mock the API endpoint
Expand Down