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 + 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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ec15ab9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Testing Setup + +Tests use pytest with `respx` for HTTP mocking and `inline-snapshot` for assertions. + +**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 + +**Running tests**: +- `mise run test` or `uv run pytest tests/ -v` +- `mise run test:update` - update snapshots +- Use `snapshot(...)` from `inline-snapshot` for assertions + diff --git a/mise.toml b/mise.toml index 0474256..2db8c12 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,16 @@ [tools] +python = "3.9" "aqua:speakeasy-api/speakeasy" = "1" +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" diff --git a/pyproject.toml b/pyproject.toml index 348fd38..c15a633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ dev = [ "mypy ==1.15.0", "pylint ==3.2.3", "pyright ==1.1.398", + "pytest >=8.0.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..5f59e49 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,176 @@ +"""Test fixtures and helpers for Kombo SDK tests.""" + +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.""" + + 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 MockContext: + """Test context for mocking HTTP requests and capturing request details.""" + + 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 provided. + Pass None explicitly to omit the integration_id from SDK initialization. + """ + self._api_key = api_key or "test-api-key" + + # 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 id_to_use is None: + self.kombo = Kombo(api_key=self._api_key) + else: + 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], + ) -> 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' + """ + 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 + route = respx.request( + method=method, + url__regex=re.compile(f"^{re.escape(base_url)}(\\?.*)?$"), + ) + else: + route = respx.request( + method=method, + url=base_url, + ) + + 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.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..f4d45e8 --- /dev/null +++ b/tests/test_basic_behavior.py @@ -0,0 +1,159 @@ +"""Tests for basic SDK behavior.""" + +import pytest +from inline_snapshot import snapshot +from tests.conftest import MockContext + + +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 = MockContext(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 = MockContext( + 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 = MockContext( + 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 = MockContext() + + 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"], + ) + if jobs is not None: + _ = jobs.next() # Consume first page + + # Verify and snapshot the request details + request = ctx.get_last_request() + assert request.path == snapshot( + '/v1/ats/jobs?page_size=100&include_deleted=false&ids=CPDifhHr7izJhKHmGPkXqknC%2CJ7znt8TJRiwPVA7paC2iCh8u&statuses=OPEN%2CCLOSED' + ) + + def test_should_correctly_encode_boolean_query_parameters(self): + """Test that boolean query parameters are correctly encoded.""" + ctx = MockContext() + + 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..55121ce --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,405 @@ +"""Tests for error handling.""" + +import pytest +from inline_snapshot import snapshot +from tests.conftest import MockContext +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 = MockContext() + + 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 = MockContext() + + 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 = MockContext() + + 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 = MockContext() + + 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 = MockContext() + + 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 = MockContext() + + 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 response received: 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 = MockContext() + + 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 response received: 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 = MockContext() + + 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 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): + """Test handling of empty response body with error status code.""" + ctx = MockContext() + + 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 response received: Status 500 Content-Type "". Body: ""') + + def test_handles_unexpected_content_type_header(self): + """Test handling of unexpected Content-Type header.""" + ctx = MockContext() + + # 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 response received: 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 = MockContext() + + # 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) + + diff --git a/tests/test_job_board.py b/tests/test_job_board.py new file mode 100644 index 0000000..670d685 --- /dev/null +++ b/tests/test_job_board.py @@ -0,0 +1,37 @@ +"""Tests for Kombo ATS Jobs API.""" + +from inline_snapshot import snapshot +from tests.conftest import MockContext + + +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 = MockContext() + + # 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() + if jobs is not None: + _ = jobs.next() # Consume first page + + # Verify and snapshot the request details + request = ctx.get_last_request() + assert request.path == snapshot('/v1/ats/jobs?page_size=100&include_deleted=false') + diff --git a/uv.lock b/uv.lock index adc6c2b..8d0c4ab 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,15 @@ 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 = "certifi" version = "2025.11.12" @@ -83,6 +92,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 +147,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 +221,13 @@ 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 = "respx" }, ] [package.metadata] @@ -175,9 +240,44 @@ 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 = "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 +289,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 +360,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 +395,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 +550,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 +593,76 @@ 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 = "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"