From a422ca3a5b816e68bea41664e56a42451eb935fa Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 16:46:42 +0100 Subject: [PATCH 01/12] test(core): port general behavior tests from TypeScript SDK --- mise.toml | 1 + pyproject.toml | 4 + tests/__init__.py | 1 + tests/conftest.py | 177 ++++++++++++++ tests/test_basic_behavior.py | 157 +++++++++++++ tests/test_error_handling.py | 440 +++++++++++++++++++++++++++++++++++ tests/test_job_board.py | 35 +++ uv.lock | 254 ++++++++++++++++++++ 8 files changed, 1069 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_basic_behavior.py create mode 100644 tests/test_error_handling.py create mode 100644 tests/test_job_board.py diff --git a/mise.toml b/mise.toml index 0474256..2bead92 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,6 @@ [tools] "aqua:speakeasy-api/speakeasy" = "1" +uv = "0.9.5" [tasks."dev"] description = "Build the SDK without publishing" diff --git a/pyproject.toml b/pyproject.toml index 348fd38..595dda7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,10 @@ dev = [ "mypy ==1.15.0", "pylint ==3.2.3", "pyright ==1.1.398", + "pytest >=8.0.0", + "pytest-asyncio >=0.24.0", + "respx >=0.21.0", + "inline-snapshot >=0.13.0", ] [tool.setuptools.packages.find] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..bedcb58 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for Kombo Python SDK.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6f94004 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,177 @@ +"""Test fixtures and helpers for Kombo SDK tests.""" + +from typing import Optional, Dict, Any, List +import json +import respx +import httpx +from kombo import Kombo + + +class CapturedRequest: + """Represents a captured HTTP request.""" + + def __init__(self, method: str, path: str, headers: Dict[str, str], body: Optional[Any] = None): + self.method = method + self.path = path + self.headers = headers + self.body = body + + +class TestContext: # noqa: D101 + """Test context for mocking HTTP requests and capturing request details.""" + + def __init__(self, api_key: Optional[str] = None, integration_id: Optional[str] = None): + """ + Initialize test context. + + :param api_key: API key to use (defaults to "test-api-key") + :param integration_id: Integration ID to use (defaults to "test-integration-id" if not explicitly None) + """ + self._api_key = api_key or "test-api-key" + # If integration_id is explicitly None, don't pass it; otherwise use default + self._integration_id = integration_id if integration_id is not None else "test-integration-id" + self._captured_requests: List[CapturedRequest] = [] + + # Initialize SDK + if integration_id is None: + self.kombo = Kombo(api_key=self._api_key) + else: + self.kombo = Kombo(api_key=self._api_key, integration_id=self._integration_id) + + def mock_endpoint( + self, + method: str, + path: str, + response: Dict[str, Any], + delay_response_ms: Optional[int] = None, + ) -> None: + """ + Mock an HTTP 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 delay_response_ms: Optional delay in milliseconds before responding + """ + status_code = response.get("statusCode", 200) + body = response.get("body") + response_headers = response.get("headers", {}) + + # Set up Content-Type header for JSON responses if not provided + if isinstance(body, dict) and "Content-Type" not in response_headers: + response_headers = {**response_headers, "Content-Type": "application/json"} + + # Prepare response content + if isinstance(body, (dict, list)): + content = json.dumps(body).encode() + elif isinstance(body, str): + content = body.encode() + else: + content = b"" + + # Create response function that captures request + def create_response(request: httpx.Request) -> httpx.Response: + # Capture request details + query_string = request.url.query.decode() if request.url.query else "" + full_path = request.url.path + (f"?{query_string}" if query_string else "") + + # 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 + + captured = CapturedRequest( + method=request.method, + path=full_path, + headers=dict(request.headers), + body=request_body, + ) + self._captured_requests.append(captured) + + return httpx.Response( + status_code=status_code, + headers=response_headers, + content=content, + ) + + # Create the mock route + # For GET requests, match any query parameters using regex + # For other methods, match exact path + base_url = f"https://api.kombo.dev{path}" + if method == "GET": + # Match the base path, allowing any query parameters + import re + route = respx.request( + method=method, + url__regex=re.compile(f"^{re.escape(base_url)}(\\?.*)?$"), + ) + else: + route = respx.request( + method=method, + url=base_url, + ) + + # Handle delay if specified + if delay_response_ms is not None: + def delayed_response(request: httpx.Request) -> httpx.Response: + import time + # Sleep to simulate delay - this will cause timeout if timeout_ms < delay_response_ms + time.sleep(delay_response_ms / 1000.0) + return create_response(request) + route.mock(side_effect=delayed_response) + else: + route.mock(side_effect=create_response) + + def get_requests(self) -> List[CapturedRequest]: + """Get all captured requests.""" + return list(self._captured_requests) + + def get_last_request(self) -> CapturedRequest: + """Get the last captured request.""" + if not self._captured_requests: + raise RuntimeError("No requests captured!") + return self._captured_requests[-1] + + def clear(self) -> None: + """Clear captured requests and reset mocks.""" + self._captured_requests.clear() + respx.stop() + respx.clear() + respx.start() # Restart respx so new mocks can be registered + + +# Pytest fixtures +import pytest + + +@pytest.fixture(autouse=True) +def reset_respx(): + """Reset respx mocks before and after each test.""" + respx.start() + yield + respx.stop() + respx.clear() + diff --git a/tests/test_basic_behavior.py b/tests/test_basic_behavior.py new file mode 100644 index 0000000..2929b31 --- /dev/null +++ b/tests/test_basic_behavior.py @@ -0,0 +1,157 @@ +"""Tests for basic SDK behavior.""" + +import pytest +from inline_snapshot import snapshot +from tests.conftest import TestContext + + +class TestBasicSDKBehavior: + """Test basic SDK behavior.""" + + def test_should_include_api_key_in_authorization_header(self): + """Test that API key is included in Authorization header.""" + ctx = TestContext(api_key="my-custom-api-key") + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "body": { + "status": "success", + "data": {"results": [], "next": None}, + }, + }, + ) + + jobs = ctx.kombo.ats.get_jobs() + if jobs is not None: + _ = jobs.next() # Consume first page + + request = ctx.get_last_request() + assert request.headers.get("authorization") == snapshot("Bearer my-custom-api-key") + + def test_should_include_integration_id_in_x_integration_id_header_when_specified(self): + """Test that X-Integration-Id header is included when specified.""" + ctx = TestContext( + api_key="test-key", + integration_id="my-integration-123", + ) + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "body": { + "status": "success", + "data": {"results": [], "next": None}, + }, + }, + ) + + jobs = ctx.kombo.ats.get_jobs() + if jobs is not None: + _ = jobs.next() # Consume first page + + request = ctx.get_last_request() + assert request.headers.get("x-integration-id") == snapshot("my-integration-123") + + def test_should_not_include_x_integration_id_header_when_not_provided(self): + """Test that X-Integration-Id header is not included when not provided.""" + ctx = TestContext( + api_key="test-key", + integration_id=None, + ) + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "body": { + "status": "success", + "data": {"results": [], "next": None}, + }, + }, + ) + + jobs = ctx.kombo.ats.get_jobs() + if jobs is not None: + _ = jobs.next() # Consume first page + + request = ctx.get_last_request() + # When integration ID is None, the header should not be set + assert request.headers.get("x-integration-id") is None + + def test_should_correctly_encode_comma_separated_query_parameters(self): + """Test that comma-separated query parameters are correctly encoded.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "body": { + "status": "success", + "data": { + "results": [], + "next": None, + }, + }, + }, + ) + + # Make the API call + _jobs = ctx.kombo.ats.get_jobs( + statuses=["OPEN", "CLOSED"], + ids=["CPDifhHr7izJhKHmGPkXqknC", "J7znt8TJRiwPVA7paC2iCh8u"], + ) + + # Verify and snapshot the request details + request = ctx.get_last_request() + assert request.path == snapshot( + "/v1/ats/jobs?ids=CPDifhHr7izJhKHmGPkXqknC%2CJ7znt8TJRiwPVA7paC2iCh8u&statuses=OPEN%2CCLOSED&include_deleted=false&page_size=100" + ) + + def test_should_correctly_encode_boolean_query_parameters(self): + """Test that boolean query parameters are correctly encoded.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "body": { + "status": "success", + "data": {"results": [], "next": None}, + }, + }, + ) + + # Test with boolean true + jobs_with_deleted = ctx.kombo.ats.get_jobs(include_deleted=True) + if jobs_with_deleted is not None: + _ = jobs_with_deleted.next() # Consume first page + + request_with_deleted = ctx.get_last_request() + assert "include_deleted=true" in request_with_deleted.path + + ctx.clear() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "body": { + "status": "success", + "data": {"results": [], "next": None}, + }, + }, + ) + + # Test with boolean false + jobs_without_deleted = ctx.kombo.ats.get_jobs(include_deleted=False) + if jobs_without_deleted is not None: + _ = jobs_without_deleted.next() # Consume first page + + request_without_deleted = ctx.get_last_request() + assert "include_deleted=false" in request_without_deleted.path + diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..ed3b558 --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,440 @@ +"""Tests for error handling.""" + +import pytest +from inline_snapshot import snapshot +from tests.conftest import TestContext +from kombo.errors import ( + KomboAtsError, + KomboHrisError, + KomboGeneralError, + SDKDefaultError, + ResponseValidationError, +) + + +class TestErrorHandling: + """Test error handling behavior.""" + + class TestATSEndpoints: + """Test ATS endpoint error handling.""" + + def test_returns_kombo_ats_error_for_platform_rate_limit_errors(self): + """Test that KomboAtsError is returned for platform rate limit errors.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "statusCode": 429, + "body": { + "status": "error", + "error": { + "code": "PLATFORM.RATE_LIMIT_EXCEEDED", + "title": "Rate limit exceeded", + "message": "You have exceeded the rate limit. Please try again later.", + "log_url": "https://app.kombo.dev/logs/abc123", + }, + }, + }, + ) + + with pytest.raises(KomboAtsError) as exc_info: + jobs = ctx.kombo.ats.get_jobs() + if jobs is not None: + _ = jobs.next() # Consume first page + + 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.") + assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/abc123") + assert error.data.status == snapshot("error") + + def test_returns_kombo_ats_error_for_ats_specific_job_closed_errors(self): + """Test that KomboAtsError is returned for ATS-specific job closed errors.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="POST", + path="/v1/ats/jobs/test-job-id/applications", + response={ + "statusCode": 400, + "body": { + "status": "error", + "error": { + "code": "ATS.JOB_CLOSED", + "title": "Job is closed", + "message": "Cannot create application for a closed job. The job must be in an open state.", + "log_url": "https://app.kombo.dev/logs/ghi789", + }, + }, + }, + ) + + with pytest.raises(KomboAtsError) as exc_info: + ctx.kombo.ats.create_application( + job_id="test-job-id", + candidate={ + "first_name": "John", + "last_name": "Doe", + "email_address": "john.doe@example.com", + }, + ) + + error = exc_info.value + 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( + "Cannot create application for a closed job. The job must be in an open state." + ) + assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/ghi789") + assert error.data.status == snapshot("error") + + class TestHRISEndpoints: + """Test HRIS endpoint error handling.""" + + def test_returns_kombo_hris_error_for_integration_permission_errors(self): + """Test that KomboHrisError is returned for integration permission errors.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/hris/employees", + response={ + "statusCode": 403, + "body": { + "status": "error", + "error": { + "code": "INTEGRATION.PERMISSION_MISSING", + "title": "Permission missing", + "message": "The integration is missing required permissions to access this resource.", + "log_url": "https://app.kombo.dev/logs/hris-def456", + }, + }, + }, + ) + + with pytest.raises(KomboHrisError) as exc_info: + employees = ctx.kombo.hris.get_employees() + if employees is not None: + _ = employees.next() # Consume first page + + error = exc_info.value + 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( + "The integration is missing required permissions to access this resource." + ) + assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/hris-def456") + assert error.data.status == snapshot("error") + + class TestAssessmentEndpoints: + """Test Assessment endpoint error handling.""" + + def test_returns_kombo_ats_error_for_platform_input_validation_errors(self): + """Test that KomboAtsError is returned for platform input validation errors.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/assessment/orders/open", + response={ + "statusCode": 400, + "body": { + "status": "error", + "error": { + "code": "PLATFORM.INPUT_INVALID", + "title": "Input invalid", + "message": "The provided input is invalid or malformed.", + "log_url": "https://app.kombo.dev/logs/assessment-xyz", + }, + }, + }, + ) + + with pytest.raises(KomboAtsError) as exc_info: + orders = ctx.kombo.assessment.get_open_orders() + if orders is not None: + _ = orders.next() # Consume first page + + 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.") + assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/assessment-xyz") + assert error.data.status == snapshot("error") + + class TestGeneralEndpoints: + """Test General endpoint error handling.""" + + def test_returns_kombo_general_error_for_authentication_errors(self): + """Test that KomboGeneralError is returned for authentication errors.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/check-api-key", + response={ + "statusCode": 401, + "body": { + "status": "error", + "error": { + "code": "PLATFORM.AUTHENTICATION_INVALID", + "title": "Authentication invalid", + "message": "The provided API key is invalid or expired.", + "log_url": "https://app.kombo.dev/logs/general-auth-123", + }, + }, + }, + ) + + with pytest.raises(KomboGeneralError) as exc_info: + ctx.kombo.general.check_api_key() + + 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.") + assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/general-auth-123") + assert error.data.status == snapshot("error") + + class TestUnexpectedResponseFormats: + """Test handling of unexpected response formats.""" + + class TestSDKDefaultErrorForNonJSONResponses: + """Test SDKDefaultError thrown for non-JSON responses.""" + + def test_handles_plain_text_500_error_from_load_balancer(self): + """Test handling of plain text 500 error from load balancer.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "statusCode": 500, + "body": "500 Internal Server Error", + }, + ) + + with pytest.raises(SDKDefaultError) as exc_info: + jobs = ctx.kombo.ats.get_jobs() + if jobs is not None: + _ = jobs.next() # Consume first page + + error = exc_info.value + assert isinstance(error, SDKDefaultError) + assert str(error) == snapshot( + 'Unexpected Status or Content-Type: Status 500 Content-Type "". Body: 500 Internal Server Error' + ) + + def test_handles_plain_text_502_bad_gateway_error(self): + """Test handling of plain text 502 bad gateway error.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/hris/employees", + response={ + "statusCode": 502, + "body": "502 Bad Gateway", + "headers": { + "Content-Type": "text/plain", + }, + }, + ) + + with pytest.raises(SDKDefaultError) as exc_info: + employees = ctx.kombo.hris.get_employees() + if employees is not None: + _ = employees.next() # Consume first page + + error = exc_info.value + assert isinstance(error, SDKDefaultError) + assert str(error) == snapshot( + 'Unexpected Status or Content-Type: Status 502 Content-Type text/plain. Body: 502 Bad Gateway' + ) + + def test_handles_html_error_page_from_nginx(self): + """Test handling of HTML error page from nginx.""" + ctx = TestContext() + + html_error_page = """ + +503 Service Temporarily Unavailable + +

503 Service Temporarily Unavailable

+
nginx/1.18.0
+ +""" + + ctx.mock_endpoint( + method="POST", + path="/v1/ats/jobs/test-job-id/applications", + response={ + "statusCode": 503, + "body": html_error_page, + }, + ) + + with pytest.raises(SDKDefaultError) as exc_info: + ctx.kombo.ats.create_application( + job_id="test-job-id", + candidate={ + "first_name": "John", + "last_name": "Doe", + "email_address": "john.doe@example.com", + }, + ) + + error = exc_info.value + assert isinstance(error, SDKDefaultError) + assert str(error) == snapshot( + 'Unexpected Status or Content-Type: Status 503 Content-Type ""\n' + "Body: \n" + "\n" + "503 Service Temporarily Unavailable\n" + "\n" + "

503 Service Temporarily Unavailable

\n" + "
nginx/1.18.0
\n" + "\n" + "" + ) + + def test_handles_empty_response_body_with_error_status_code(self): + """Test handling of empty response body with error status code.""" + ctx = TestContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/check-api-key", + response={ + "statusCode": 500, + "body": "", + }, + ) + + with pytest.raises(SDKDefaultError) as exc_info: + ctx.kombo.general.check_api_key() + + error = exc_info.value + assert isinstance(error, SDKDefaultError) + assert str(error) == snapshot('Unexpected Status or Content-Type: Status 500 Content-Type "". Body: ""') + + def test_handles_unexpected_content_type_header(self): + """Test handling of unexpected Content-Type header.""" + ctx = TestContext() + + # Response with unexpected Content-Type + ctx.mock_endpoint( + method="GET", + path="/v1/ats/applications", + response={ + "statusCode": 500, + "body": "Server error occurred", + "headers": { + "Content-Type": "text/xml", + }, + }, + ) + + with pytest.raises(SDKDefaultError) as exc_info: + applications = ctx.kombo.ats.get_applications() + if applications is not None: + _ = applications.next() # Consume first page + + error = exc_info.value + assert isinstance(error, SDKDefaultError) + assert str(error) == snapshot( + 'Unexpected Status or Content-Type: Status 500 Content-Type text/xml. Body: Server error occurred' + ) + + def test_handles_unexpected_json_structure_in_error_response(self): + """Test handling of unexpected JSON structure in error response (ResponseValidationError).""" + ctx = TestContext() + + # Valid JSON but unexpected structure (not matching Kombo error format) + unexpected_json = { + "errorCode": "500", + "errorMessage": "Internal server error", + "timestamp": "2024-01-01T00:00:00Z", + } + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "statusCode": 500, + "body": unexpected_json, + }, + ) + + with pytest.raises(ResponseValidationError) as exc_info: + jobs = ctx.kombo.ats.get_jobs() + if jobs is not None: + _ = jobs.next() # Consume first page + + error = exc_info.value + # Valid JSON but unexpected structure triggers ResponseValidationError + assert isinstance(error, ResponseValidationError) + assert "Response validation failed" in str(error) + + class TestHTTPClientErrors: + """Test HTTP client error handling.""" + + @pytest.mark.skip( + reason="respx doesn't properly simulate HTTP timeouts - delay happens but httpx timeout doesn't trigger with mocked responses" + ) + def test_handles_request_timeout(self): + """Test handling of request timeout.""" + ctx = TestContext() + + # Mock endpoint but delay the connection to exceed SDK timeout + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "statusCode": 200, + "body": { + "status": "success", + "data": {"results": [], "next": None}, + }, + }, + delay_response_ms=500, + ) + + # Use a short timeout for this test + with pytest.raises(Exception) as exc_info: + jobs = ctx.kombo.ats.get_jobs(timeout_ms=100) + if jobs is not None: + _ = jobs.next() # Consume first page + + # The exact error type may vary (could be httpx.TimeoutException or similar) + # but it should be some kind of timeout error + # In Python, httpx raises TimeoutException, but the SDK might wrap it + error = exc_info.value + error_str = str(error).lower() + assert "timeout" in error_str or "timed out" in error_str + diff --git a/tests/test_job_board.py b/tests/test_job_board.py new file mode 100644 index 0000000..4e9fcc1 --- /dev/null +++ b/tests/test_job_board.py @@ -0,0 +1,35 @@ +"""Tests for Kombo ATS Jobs API.""" + +from inline_snapshot import snapshot +from tests.conftest import TestContext + + +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.""" + ctx = TestContext() + + # Mock the API endpoint + ctx.mock_endpoint( + method="GET", + path="/v1/ats/jobs", + response={ + "body": { + "status": "success", + "data": { + "results": [], + "next": None, + }, + }, + }, + ) + + # Make the API call + _jobs = ctx.kombo.ats.get_jobs() + + # Verify and snapshot the request details + request = ctx.get_last_request() + assert request.path == snapshot("/v1/ats/jobs?include_deleted=false&page_size=100") + diff --git a/uv.lock b/uv.lock index adc6c2b..ac2341f 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" }, ] +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -83,6 +101,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -129,6 +156,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "inline-snapshot" +version = "0.31.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b1/52b5ee59f73ed31d5fe21b10881bf2d121d07d54b23c0b6b74186792e620/inline_snapshot-0.31.1.tar.gz", hash = "sha256:4ea5ed70aa1d652713bbfd750606b94bd8a42483f7d3680433b3e92994495f64", size = 2606338, upload-time = "2025-11-07T07:36:18.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/52/945db420380efbda8c69a7a4a16c53df9d7ac50d8217286b9d41e5d825ff/inline_snapshot-0.31.1-py3-none-any.whl", hash = "sha256:7875a73c986a03388c7e758fb5cb8a43d2c3a20328aa1d851bfb4ed536c4496f", size = 71965, upload-time = "2025-11-07T07:36:16.836Z" }, +] + [[package]] name = "isort" version = "5.13.2" @@ -160,9 +230,15 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "inline-snapshot" }, { name = "mypy" }, { name = "pylint" }, { name = "pyright" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "respx" }, ] [package.metadata] @@ -175,9 +251,45 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "inline-snapshot", specifier = ">=0.13.0" }, { name = "mypy", specifier = "==1.15.0" }, { name = "pylint", specifier = "==3.2.3" }, { name = "pyright", specifier = "==1.1.398" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "respx", specifier = ">=0.21.0" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] @@ -189,6 +301,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "1.15.0" @@ -251,6 +372,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + [[package]] name = "platformdirs" version = "4.4.0" @@ -277,6 +407,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.12.4" @@ -423,6 +562,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pylint" version = "3.2.3" @@ -457,6 +605,112 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/e0/5283593f61b3c525d6d7e94cfb6b3ded20b3df66e953acaf7bb4f23b3f6e/pyright-1.1.398-py3-none-any.whl", hash = "sha256:0a70bfd007d9ea7de1cf9740e1ad1a40a122592cfe22a3f6791b06162ad08753", size = 5780235, upload-time = "2025-03-26T10:06:03.994Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" From 1d92975a8843abdb54914eefef676c61a4fba34a Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 16:51:39 +0100 Subject: [PATCH 02/12] Run tests in CI --- .github/workflows/test.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..592a611 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + push: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.9.5" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install dependencies + run: uv sync --group dev + + - name: Run tests + run: uv run pytest tests/ -v + From 23abbafa1a882b4fd343ad34b699dd45e2ddb88b Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 17:08:08 +0100 Subject: [PATCH 03/12] Fix snapshots --- tests/test_basic_behavior.py | 2 +- tests/test_error_handling.py | 27 ++++++++++++++------------- tests/test_job_board.py | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/test_basic_behavior.py b/tests/test_basic_behavior.py index 2929b31..e81b2ad 100644 --- a/tests/test_basic_behavior.py +++ b/tests/test_basic_behavior.py @@ -108,7 +108,7 @@ def test_should_correctly_encode_comma_separated_query_parameters(self): # Verify and snapshot the request details request = ctx.get_last_request() assert request.path == snapshot( - "/v1/ats/jobs?ids=CPDifhHr7izJhKHmGPkXqknC%2CJ7znt8TJRiwPVA7paC2iCh8u&statuses=OPEN%2CCLOSED&include_deleted=false&page_size=100" + '/v1/ats/jobs?page_size=100&include_deleted=false&ids=CPDifhHr7izJhKHmGPkXqknC%2CJ7znt8TJRiwPVA7paC2iCh8u&statuses=OPEN%2CCLOSED' ) def test_should_correctly_encode_boolean_query_parameters(self): diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index ed3b558..088f1ff 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -247,7 +247,7 @@ def test_handles_plain_text_500_error_from_load_balancer(self): error = exc_info.value assert isinstance(error, SDKDefaultError) assert str(error) == snapshot( - 'Unexpected Status or Content-Type: Status 500 Content-Type "". Body: 500 Internal Server Error' + 'Unexpected response received: Status 500 Content-Type "". Body: 500 Internal Server Error' ) def test_handles_plain_text_502_bad_gateway_error(self): @@ -274,7 +274,7 @@ def test_handles_plain_text_502_bad_gateway_error(self): error = exc_info.value assert isinstance(error, SDKDefaultError) assert str(error) == snapshot( - 'Unexpected Status or Content-Type: Status 502 Content-Type text/plain. Body: 502 Bad Gateway' + 'Unexpected response received: Status 502 Content-Type text/plain. Body: 502 Bad Gateway' ) def test_handles_html_error_page_from_nginx(self): @@ -312,15 +312,16 @@ def test_handles_html_error_page_from_nginx(self): error = exc_info.value assert isinstance(error, SDKDefaultError) assert str(error) == snapshot( - 'Unexpected Status or Content-Type: Status 503 Content-Type ""\n' - "Body: \n" - "\n" - "503 Service Temporarily Unavailable\n" - "\n" - "

503 Service Temporarily Unavailable

\n" - "
nginx/1.18.0
\n" - "\n" - "" + """\ +Unexpected response received: Status 503 Content-Type "". Body: + +503 Service Temporarily Unavailable + +

503 Service Temporarily Unavailable

+
nginx/1.18.0
+ +\ +""" ) def test_handles_empty_response_body_with_error_status_code(self): @@ -341,7 +342,7 @@ def test_handles_empty_response_body_with_error_status_code(self): error = exc_info.value assert isinstance(error, SDKDefaultError) - assert str(error) == snapshot('Unexpected Status or Content-Type: Status 500 Content-Type "". Body: ""') + assert str(error) == snapshot('Unexpected response received: Status 500 Content-Type "". Body: ""') def test_handles_unexpected_content_type_header(self): """Test handling of unexpected Content-Type header.""" @@ -368,7 +369,7 @@ def test_handles_unexpected_content_type_header(self): error = exc_info.value assert isinstance(error, SDKDefaultError) assert str(error) == snapshot( - 'Unexpected Status or Content-Type: Status 500 Content-Type text/xml. Body: Server error occurred' + 'Unexpected response received: Status 500 Content-Type text/xml. Body: Server error occurred' ) def test_handles_unexpected_json_structure_in_error_response(self): diff --git a/tests/test_job_board.py b/tests/test_job_board.py index 4e9fcc1..be284b8 100644 --- a/tests/test_job_board.py +++ b/tests/test_job_board.py @@ -31,5 +31,5 @@ def test_should_make_correct_http_request_for_get_jobs(self): # Verify and snapshot the request details request = ctx.get_last_request() - assert request.path == snapshot("/v1/ats/jobs?include_deleted=false&page_size=100") + assert request.path == snapshot('/v1/ats/jobs?page_size=100&include_deleted=false') From 0b81e4a617eff554037907bbed294c3875df1a52 Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 17:08:19 +0100 Subject: [PATCH 04/12] Add Mise tasks --- mise.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mise.toml b/mise.toml index 2bead92..6302b59 100644 --- a/mise.toml +++ b/mise.toml @@ -5,3 +5,11 @@ uv = "0.9.5" [tasks."dev"] description = "Build the SDK without publishing" run = 'OPENAPI_DOC_AUTH_TOKEN="$(doppler secrets get SDK_SPEC_ENDPOINT_TOKEN --project kombo-engine --config dev --plain)" speakeasy run --minimal --skip-upload-spec --skip-versioning --skip-compile --target kombo-python' + +[tasks."test"] +description = "Run tests" +run = "uv run pytest tests/ -v" + +[tasks."test:update"] +description = "Run tests and update inline snapshots" +run = "uv run pytest tests/ --inline-snapshot=fix -v" From 05f63319bbe8bfe7b1570fd4e7dfb3dd773d6411 Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 17:09:27 +0100 Subject: [PATCH 05/12] Add Python to Mise config --- mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/mise.toml b/mise.toml index 6302b59..4913f94 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,5 @@ [tools] +python = "3.12" "aqua:speakeasy-api/speakeasy" = "1" uv = "0.9.5" From ac71f83442a242bd7c2e202a0814a5aad213cb45 Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 17:11:39 +0100 Subject: [PATCH 06/12] Unify Python versions --- mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 4913f94..2db8c12 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -python = "3.12" +python = "3.9" "aqua:speakeasy-api/speakeasy" = "1" uv = "0.9.5" From 072e8f7d20819c0b42cc03b1521bc93a3b272bbc Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 17:13:49 +0100 Subject: [PATCH 07/12] Add `AGENTS.md` to explain testing --- AGENTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0d0def9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Testing Setup + +Tests use pytest with `respx` for HTTP mocking and `inline-snapshot` for assertions. + +**TestContext helper** (`tests/conftest.py`): +- `ctx = TestContext(api_key="...", integration_id="...")` - creates SDK instance +- `ctx.mock_endpoint(method, path, response)` - mocks HTTP endpoints +- `ctx.get_last_request()` - captures request details (path, headers, body) +- `ctx.clear()` - resets mocks between calls + +**Running tests**: +- `mise run test` or `uv run pytest tests/ -v` +- `mise run test:update` - update snapshots +- Use `snapshot(...)` from `inline-snapshot` for assertions + From 959399eb5d7a4949cf142d6bd87eebb781420332 Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 17:44:21 +0100 Subject: [PATCH 08/12] Deslop --- AGENTS.md | 4 +-- pyproject.toml | 1 - tests/conftest.py | 47 ++++++++++++++-------------- tests/test_basic_behavior.py | 16 +++++----- tests/test_error_handling.py | 60 ++++++++---------------------------- tests/test_job_board.py | 8 +++-- uv.lock | 48 ----------------------------- 7 files changed, 51 insertions(+), 133 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d0def9..ec15ab9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ Tests use pytest with `respx` for HTTP mocking and `inline-snapshot` for assertions. -**TestContext helper** (`tests/conftest.py`): -- `ctx = TestContext(api_key="...", integration_id="...")` - creates SDK instance +**MockContext helper** (`tests/conftest.py`): +- `ctx = MockContext(api_key="...", integration_id="...")` - creates SDK instance - `ctx.mock_endpoint(method, path, response)` - mocks HTTP endpoints - `ctx.get_last_request()` - captures request details (path, headers, body) - `ctx.clear()` - resets mocks between calls diff --git a/pyproject.toml b/pyproject.toml index 595dda7..c15a633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ dev = [ "pylint ==3.2.3", "pyright ==1.1.398", "pytest >=8.0.0", - "pytest-asyncio >=0.24.0", "respx >=0.21.0", "inline-snapshot >=0.13.0", ] diff --git a/tests/conftest.py b/tests/conftest.py index 6f94004..5f59e49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,11 +2,17 @@ from typing import Optional, Dict, Any, List import json +import re +import pytest import respx import httpx from kombo import Kombo +# Sentinel value to distinguish between "not provided" and "explicitly None" +_UNSET = object() + + class CapturedRequest: """Represents a captured HTTP request.""" @@ -17,33 +23,41 @@ def __init__(self, method: str, path: str, headers: Dict[str, str], body: Option self.body = body -class TestContext: # noqa: D101 +class MockContext: """Test context for mocking HTTP requests and capturing request details.""" - def __init__(self, api_key: Optional[str] = None, integration_id: Optional[str] = None): + def __init__(self, api_key: Optional[str] = None, integration_id: Any = _UNSET): """ Initialize test context. :param api_key: API key to use (defaults to "test-api-key") - :param integration_id: Integration ID to use (defaults to "test-integration-id" if not explicitly None) + :param integration_id: Integration ID to use. Defaults to "test-integration-id" if not provided. + Pass None explicitly to omit the integration_id from SDK initialization. """ self._api_key = api_key or "test-api-key" - # If integration_id is explicitly None, don't pass it; otherwise use default - self._integration_id = integration_id if integration_id is not None else "test-integration-id" + + # Determine integration_id value + if integration_id is _UNSET: + # Not provided, use default + id_to_use = "test-integration-id" + else: + # 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 - if integration_id is None: + if id_to_use is None: self.kombo = Kombo(api_key=self._api_key) else: - self.kombo = Kombo(api_key=self._api_key, integration_id=self._integration_id) + self.kombo = Kombo(api_key=self._api_key, integration_id=id_to_use) def mock_endpoint( self, method: str, path: str, response: Dict[str, Any], - delay_response_ms: Optional[int] = None, ) -> None: """ Mock an HTTP endpoint. @@ -51,7 +65,6 @@ 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 delay_response_ms: Optional delay in milliseconds before responding """ status_code = response.get("statusCode", 200) body = response.get("body") @@ -123,7 +136,6 @@ def create_response(request: httpx.Request) -> httpx.Response: base_url = f"https://api.kombo.dev{path}" if method == "GET": # Match the base path, allowing any query parameters - import re route = respx.request( method=method, url__regex=re.compile(f"^{re.escape(base_url)}(\\?.*)?$"), @@ -134,16 +146,7 @@ def create_response(request: httpx.Request) -> httpx.Response: url=base_url, ) - # Handle delay if specified - if delay_response_ms is not None: - def delayed_response(request: httpx.Request) -> httpx.Response: - import time - # Sleep to simulate delay - this will cause timeout if timeout_ms < delay_response_ms - time.sleep(delay_response_ms / 1000.0) - return create_response(request) - route.mock(side_effect=delayed_response) - else: - route.mock(side_effect=create_response) + route.mock(side_effect=create_response) def get_requests(self) -> List[CapturedRequest]: """Get all captured requests.""" @@ -163,10 +166,6 @@ def clear(self) -> None: respx.start() # Restart respx so new mocks can be registered -# Pytest fixtures -import pytest - - @pytest.fixture(autouse=True) def reset_respx(): """Reset respx mocks before and after each test.""" diff --git a/tests/test_basic_behavior.py b/tests/test_basic_behavior.py index e81b2ad..f4d45e8 100644 --- a/tests/test_basic_behavior.py +++ b/tests/test_basic_behavior.py @@ -2,7 +2,7 @@ import pytest from inline_snapshot import snapshot -from tests.conftest import TestContext +from tests.conftest import MockContext class TestBasicSDKBehavior: @@ -10,7 +10,7 @@ class TestBasicSDKBehavior: def test_should_include_api_key_in_authorization_header(self): """Test that API key is included in Authorization header.""" - ctx = TestContext(api_key="my-custom-api-key") + ctx = MockContext(api_key="my-custom-api-key") ctx.mock_endpoint( method="GET", @@ -32,7 +32,7 @@ def test_should_include_api_key_in_authorization_header(self): def test_should_include_integration_id_in_x_integration_id_header_when_specified(self): """Test that X-Integration-Id header is included when specified.""" - ctx = TestContext( + ctx = MockContext( api_key="test-key", integration_id="my-integration-123", ) @@ -57,7 +57,7 @@ def test_should_include_integration_id_in_x_integration_id_header_when_specified def test_should_not_include_x_integration_id_header_when_not_provided(self): """Test that X-Integration-Id header is not included when not provided.""" - ctx = TestContext( + ctx = MockContext( api_key="test-key", integration_id=None, ) @@ -83,7 +83,7 @@ def test_should_not_include_x_integration_id_header_when_not_provided(self): def test_should_correctly_encode_comma_separated_query_parameters(self): """Test that comma-separated query parameters are correctly encoded.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", @@ -100,10 +100,12 @@ def test_should_correctly_encode_comma_separated_query_parameters(self): ) # Make the API call - _jobs = ctx.kombo.ats.get_jobs( + jobs = ctx.kombo.ats.get_jobs( statuses=["OPEN", "CLOSED"], ids=["CPDifhHr7izJhKHmGPkXqknC", "J7znt8TJRiwPVA7paC2iCh8u"], ) + if jobs is not None: + _ = jobs.next() # Consume first page # Verify and snapshot the request details request = ctx.get_last_request() @@ -113,7 +115,7 @@ def test_should_correctly_encode_comma_separated_query_parameters(self): def test_should_correctly_encode_boolean_query_parameters(self): """Test that boolean query parameters are correctly encoded.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 088f1ff..55121ce 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -2,7 +2,7 @@ import pytest from inline_snapshot import snapshot -from tests.conftest import TestContext +from tests.conftest import MockContext from kombo.errors import ( KomboAtsError, KomboHrisError, @@ -20,7 +20,7 @@ class TestATSEndpoints: def test_returns_kombo_ats_error_for_platform_rate_limit_errors(self): """Test that KomboAtsError is returned for platform rate limit errors.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", @@ -56,7 +56,7 @@ def test_returns_kombo_ats_error_for_platform_rate_limit_errors(self): def test_returns_kombo_ats_error_for_ats_specific_job_closed_errors(self): """Test that KomboAtsError is returned for ATS-specific job closed errors.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="POST", @@ -104,7 +104,7 @@ class TestHRISEndpoints: def test_returns_kombo_hris_error_for_integration_permission_errors(self): """Test that KomboHrisError is returned for integration permission errors.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", @@ -147,7 +147,7 @@ class TestAssessmentEndpoints: def test_returns_kombo_ats_error_for_platform_input_validation_errors(self): """Test that KomboAtsError is returned for platform input validation errors.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", @@ -187,7 +187,7 @@ class TestGeneralEndpoints: def test_returns_kombo_general_error_for_authentication_errors(self): """Test that KomboGeneralError is returned for authentication errors.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", @@ -228,7 +228,7 @@ class TestSDKDefaultErrorForNonJSONResponses: def test_handles_plain_text_500_error_from_load_balancer(self): """Test handling of plain text 500 error from load balancer.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", @@ -252,7 +252,7 @@ def test_handles_plain_text_500_error_from_load_balancer(self): def test_handles_plain_text_502_bad_gateway_error(self): """Test handling of plain text 502 bad gateway error.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", @@ -279,7 +279,7 @@ def test_handles_plain_text_502_bad_gateway_error(self): def test_handles_html_error_page_from_nginx(self): """Test handling of HTML error page from nginx.""" - ctx = TestContext() + ctx = MockContext() html_error_page = """ @@ -326,7 +326,7 @@ def test_handles_html_error_page_from_nginx(self): def test_handles_empty_response_body_with_error_status_code(self): """Test handling of empty response body with error status code.""" - ctx = TestContext() + ctx = MockContext() ctx.mock_endpoint( method="GET", @@ -346,7 +346,7 @@ def test_handles_empty_response_body_with_error_status_code(self): def test_handles_unexpected_content_type_header(self): """Test handling of unexpected Content-Type header.""" - ctx = TestContext() + ctx = MockContext() # Response with unexpected Content-Type ctx.mock_endpoint( @@ -374,7 +374,7 @@ def test_handles_unexpected_content_type_header(self): def test_handles_unexpected_json_structure_in_error_response(self): """Test handling of unexpected JSON structure in error response (ResponseValidationError).""" - ctx = TestContext() + ctx = MockContext() # Valid JSON but unexpected structure (not matching Kombo error format) unexpected_json = { @@ -402,40 +402,4 @@ def test_handles_unexpected_json_structure_in_error_response(self): assert isinstance(error, ResponseValidationError) assert "Response validation failed" in str(error) - class TestHTTPClientErrors: - """Test HTTP client error handling.""" - - @pytest.mark.skip( - reason="respx doesn't properly simulate HTTP timeouts - delay happens but httpx timeout doesn't trigger with mocked responses" - ) - def test_handles_request_timeout(self): - """Test handling of request timeout.""" - ctx = TestContext() - - # Mock endpoint but delay the connection to exceed SDK timeout - ctx.mock_endpoint( - method="GET", - path="/v1/ats/jobs", - response={ - "statusCode": 200, - "body": { - "status": "success", - "data": {"results": [], "next": None}, - }, - }, - delay_response_ms=500, - ) - - # Use a short timeout for this test - with pytest.raises(Exception) as exc_info: - jobs = ctx.kombo.ats.get_jobs(timeout_ms=100) - if jobs is not None: - _ = jobs.next() # Consume first page - - # The exact error type may vary (could be httpx.TimeoutException or similar) - # but it should be some kind of timeout error - # In Python, httpx raises TimeoutException, but the SDK might wrap it - error = exc_info.value - error_str = str(error).lower() - assert "timeout" in error_str or "timed out" in error_str diff --git a/tests/test_job_board.py b/tests/test_job_board.py index be284b8..670d685 100644 --- a/tests/test_job_board.py +++ b/tests/test_job_board.py @@ -1,7 +1,7 @@ """Tests for Kombo ATS Jobs API.""" from inline_snapshot import snapshot -from tests.conftest import TestContext +from tests.conftest import MockContext class TestKomboATSJobsAPI: @@ -9,7 +9,7 @@ class TestKomboATSJobsAPI: def test_should_make_correct_http_request_for_get_jobs(self): """Test that getJobs makes correct HTTP request.""" - ctx = TestContext() + ctx = MockContext() # Mock the API endpoint ctx.mock_endpoint( @@ -27,7 +27,9 @@ def test_should_make_correct_http_request_for_get_jobs(self): ) # Make the API call - _jobs = ctx.kombo.ats.get_jobs() + jobs = ctx.kombo.ats.get_jobs() + if jobs is not None: + _ = jobs.next() # Consume first page # Verify and snapshot the request details request = ctx.get_last_request() diff --git a/uv.lock b/uv.lock index ac2341f..8d0c4ab 100644 --- a/uv.lock +++ b/uv.lock @@ -53,15 +53,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "certifi" version = "2025.11.12" @@ -236,8 +227,6 @@ dev = [ { name = "pyright" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "respx" }, ] @@ -256,7 +245,6 @@ dev = [ { name = "pylint", specifier = "==3.2.3" }, { name = "pyright", specifier = "==1.1.398" }, { name = "pytest", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "respx", specifier = ">=0.21.0" }, ] @@ -649,42 +637,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] -[[package]] -name = "pytest-asyncio" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - [[package]] name = "respx" version = "0.22.0" From 45a903b9cf5e666b271d9d07a265c834bca07ae9 Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Tue, 16 Dec 2025 17:49:12 +0100 Subject: [PATCH 09/12] Add additional dev deps to Speakeasy config --- .speakeasy/gen.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index 030f40c..9791cea 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -30,7 +30,10 @@ generation: python: version: 0.2.0 additionalDependencies: - dev: {} + dev: + pytest: ">=8.0.0" + respx: ">=0.21.0" + inline-snapshot: ">=0.13.0" main: {} allowedRedefinedBuiltins: - id From 3f1b6eac363bf1f1c76bfd763281a31817e278b5 Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Thu, 18 Dec 2025 10:12:29 +0100 Subject: [PATCH 10/12] Clean up #1 --- tests/conftest.py | 39 +++++++++++---------------------------- tests/test_job_board.py | 2 +- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5f59e49..c1130fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -90,31 +89,13 @@ def create_response(request: httpx.Request) -> httpx.Response: # Read body for non-GET requests request_body = None - if request.method != "GET": + if request.method != "GET" and request.content: 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 + # Try to parse as JSON first + request_body = json.loads(request.content.decode()) + except (json.JSONDecodeError, UnicodeDecodeError): + # Fall back to raw string if not valid JSON + request_body = request.content.decode() captured = CapturedRequest( method=request.method, @@ -159,11 +140,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) diff --git a/tests/test_job_board.py b/tests/test_job_board.py index 670d685..040980d 100644 --- a/tests/test_job_board.py +++ b/tests/test_job_board.py @@ -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 From cf19d9978dea77280e7d3f90fb2212e22db1b91e Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Thu, 18 Dec 2025 10:27:18 +0100 Subject: [PATCH 11/12] Cleanup #2 --- tests/conftest.py | 4 +-- tests/test_basic_behavior.py | 58 +++++++++++++++++++++++++++++++++++- tests/test_error_handling.py | 38 +++++++---------------- 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c1130fa..7547d2b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,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", {}) diff --git a/tests/test_basic_behavior.py b/tests/test_basic_behavior.py index f4d45e8..4e14391 100644 --- a/tests/test_basic_behavior.py +++ b/tests/test_basic_behavior.py @@ -1,6 +1,5 @@ """Tests for basic SDK behavior.""" -import pytest from inline_snapshot import snapshot from tests.conftest import MockContext @@ -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": "jane.smith@example.com", + }, + ) + + # 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": "jane.smith@example.com", + } + } + ) + diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 55121ce..48bb9ab 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -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": { @@ -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.") @@ -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": { @@ -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( @@ -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": { @@ -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( @@ -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": { @@ -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.") @@ -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": { @@ -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.") @@ -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", }, ) @@ -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' ) @@ -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", @@ -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' ) @@ -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, }, ) @@ -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: @@ -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": "", }, ) @@ -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): @@ -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", @@ -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' ) @@ -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, }, ) @@ -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) From f6bee8a9be510c84552f087be7db0126349eacc6 Mon Sep 17 00:00:00 2001 From: Niklas Higi Date: Thu, 18 Dec 2025 10:47:54 +0100 Subject: [PATCH 12/12] test(core): add regression tests for incorrect pagination cursor extraction --- tests/test_pagination.py | 355 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 tests/test_pagination.py diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..b6ad485 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,355 @@ +"""Tests for pagination behavior.""" + +from datetime import datetime +from tests.conftest import MockContext + + +class TestPaginationBehavior: + """Test pagination behavior.""" + + def test_should_iterate_through_multiple_pages(self): + """Test that SDK can iterate through multiple pages of results.""" + ctx = MockContext() + + # Mock 3 pages of results + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag1", + "remote_id": None, + "name": "Tag 1", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + { + "id": "tag2", + "remote_id": None, + "name": "Tag 2", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": "cursor_page2", + }, + }, + }, + ) + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag3", + "remote_id": None, + "name": "Tag 3", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + { + "id": "tag4", + "remote_id": None, + "name": "Tag 4", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": "cursor_page3", + }, + }, + }, + ) + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag5", + "remote_id": None, + "name": "Tag 5", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": None, + }, + }, + }, + ) + + page = ctx.kombo.ats.get_tags() + all_results = [] + + # Iterate through all pages + while page is not None: + all_results.extend(page.result.data.results) + page = page.next() + + # Verify all 5 tags were collected + assert len(all_results) == 5 + assert [tag.id for tag in all_results] == ["tag1", "tag2", "tag3", "tag4", "tag5"] + + # Verify 3 HTTP requests were made + requests = ctx.get_requests() + assert len(requests) == 3 + + def test_should_pass_cursor_parameter_to_subsequent_requests(self): + """Test that cursor parameter is passed to subsequent paginated requests.""" + ctx = MockContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag1", + "remote_id": None, + "name": "Tag 1", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": "test_cursor_abc123", + }, + }, + }, + ) + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag2", + "remote_id": None, + "name": "Tag 2", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": None, + }, + }, + }, + ) + + page = ctx.kombo.ats.get_tags() + # Iterate through all pages + while page is not None: + page = page.next() + + requests = ctx.get_requests() + assert len(requests) == 2 + + # First request should NOT include cursor + assert "cursor=" not in requests[0].path + + # Second request SHOULD include cursor + assert "cursor=test_cursor_abc123" in requests[1].path + + def test_should_stop_pagination_when_next_is_null(self): + """Test that pagination stops when next cursor is null.""" + ctx = MockContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag1", + "remote_id": None, + "name": "Tag 1", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + { + "id": "tag2", + "remote_id": None, + "name": "Tag 2", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": None, + }, + }, + }, + ) + + page = ctx.kombo.ats.get_tags() + page_count = [] + + while page is not None: + page_count.append(1) + page = page.next() + + # Verify only 1 page was returned + assert len(page_count) == 1 + + # Verify only 1 HTTP request was made + requests = ctx.get_requests() + assert len(requests) == 1 + + def test_should_preserve_query_parameters_across_paginated_requests(self): + """Test that original query parameters are preserved across paginated requests.""" + ctx = MockContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag1", + "remote_id": None, + "name": "Tag 1", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": "cursor_for_page2", + }, + }, + }, + ) + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag2", + "remote_id": None, + "name": "Tag 2", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": None, + }, + }, + }, + ) + + page = ctx.kombo.ats.get_tags( + updated_after=datetime(2024, 1, 1, 0, 0, 0) + ) + + # Iterate through all pages + while page is not None: + page = page.next() + + requests = ctx.get_requests() + assert len(requests) == 2 + + # Both requests should include the original query parameters + # Check that updated_after parameter is present (URL encoded) + assert "updated_after=2024-01-01T00%3A00%3A00" in requests[0].path + assert "cursor=" not in requests[0].path + + assert "updated_after=2024-01-01T00%3A00%3A00" in requests[1].path + assert "cursor=cursor_for_page2" in requests[1].path + + def test_should_support_manual_pagination_with_next(self): + """Test that manual pagination works by calling next() method.""" + ctx = MockContext() + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag1", + "remote_id": None, + "name": "Tag 1", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": "manual_cursor_xyz", + }, + }, + }, + ) + + ctx.mock_endpoint( + method="GET", + path="/v1/ats/tags", + response={ + "body": { + "status": "success", + "data": { + "results": [ + { + "id": "tag2", + "remote_id": None, + "name": "Tag 2", + "changed_at": "2024-01-01T00:00:00.000Z", + "remote_deleted_at": None, + }, + ], + "next": None, + }, + }, + }, + ) + + page1 = ctx.kombo.ats.get_tags() + + # Verify first page was fetched + assert page1.result.data.results is not None + assert len(page1.result.data.results) == 1 + + # Manually call next() + page2_result = page1.next() + + # Verify second page was fetched (should not be null if cursor was read correctly) + # This will fail if cursor extraction bug exists + assert page2_result is not None + if page2_result: + assert len(page2_result.result.data.results) == 1 + assert page2_result.result.data.results[0].id == "tag2" + + # Verify 2 HTTP requests were made + requests = ctx.get_requests() + assert len(requests) == 2 + assert "cursor=manual_cursor_xyz" in requests[1].path +