diff --git a/src/nocodb_simple_client/__init__.py b/src/nocodb_simple_client/__init__.py index 4324c13..592e3c7 100644 --- a/src/nocodb_simple_client/__init__.py +++ b/src/nocodb_simple_client/__init__.py @@ -26,7 +26,7 @@ # Async support (optional) from typing import TYPE_CHECKING -from .api_version import APIVersion, PathBuilder, QueryParamAdapter +from .api_version import APIVersion, PathBuilder, QueryParamAdapter, RequestAdapter, ResponseAdapter from .base_resolver import BaseIdResolver from .cache import CacheManager from .client import NocoDBClient @@ -94,6 +94,8 @@ def __init__(self, *args, **kwargs): # type: ignore[misc] "APIVersion", "PathBuilder", "QueryParamAdapter", + "ResponseAdapter", + "RequestAdapter", "BaseIdResolver", # Exceptions "NocoDBException", diff --git a/src/nocodb_simple_client/api_version.py b/src/nocodb_simple_client/api_version.py index 0cecf9b..83f8d69 100644 --- a/src/nocodb_simple_client/api_version.py +++ b/src/nocodb_simple_client/api_version.py @@ -37,6 +37,197 @@ def __str__(self) -> str: return self.value +class ResponseAdapter: + """Adapter for normalizing API responses between v2 and v3 formats. + + v2 returns flat records with 'Id' key and 'list' wrapper. + v3 returns records with 'id' key, 'fields' wrapper, and 'records' wrapper. + This adapter normalizes v3 responses to v2-compatible flat format. + """ + + @staticmethod + def normalize_record(record: dict[str, Any], api_version: "APIVersion") -> dict[str, Any]: + """Normalize a single record response to flat format. + + v2 format: {"Id": 1, "Name": "John", ...} + v3 format: {"id": 1, "fields": {"Name": "John", ...}} + + Returns flat format with "Id" key for consistency. + """ + if api_version == APIVersion.V2: + return record + + # v3: extract fields and normalize id + result: dict[str, Any] = {} + if "id" in record: + result["Id"] = record["id"] + if "fields" in record: + result.update(record["fields"]) + else: + # Fallback: if no fields wrapper, copy all except 'id' + for k, v in record.items(): + if k == "id": + result["Id"] = v + elif k != "deleted": + result[k] = v + return result + + @staticmethod + def normalize_records_list( + response: dict[str, Any], api_version: "APIVersion" + ) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Normalize a records list response. + + v2: {"list": [...], "pageInfo": {"isLastPage": true, ...}} + v3: {"records": [...], "next": "...", "prev": null} + + Returns (records_list, page_info) where page_info uses v2-compatible format. + """ + if api_version == APIVersion.V2: + records = response.get("list", []) + page_info = response.get("pageInfo", {}) + return records, page_info + + # v3: extract from "records" key + raw_records = response.get("records", []) + records = [ResponseAdapter.normalize_record(r, api_version) for r in raw_records] + + # Convert v3 pagination to v2-compatible pageInfo + has_next = response.get("next") is not None + page_info = { + "isLastPage": not has_next, + "next": response.get("next"), + "prev": response.get("prev"), + } + + return records, page_info + + @staticmethod + def extract_record_id(response: dict[str, Any], api_version: "APIVersion") -> Any: + """Extract record ID from a create/update/delete response. + + v2: {"Id": 123} + v3: {"id": 123, "fields": {...}} or {"records": [{"id": 123, ...}]} + """ + if api_version == APIVersion.V2: + return response.get("Id") + + # v3: try direct id first + if "id" in response: + return response["id"] + + # v3: try records wrapper (bulk responses) + records = response.get("records", []) + if records and isinstance(records, list) and len(records) > 0: + return records[0].get("id") + + # Fallback: try "Id" (in case of mixed format) + return response.get("Id") + + @staticmethod + def extract_record_ids( + response: dict[str, Any] | list[dict[str, Any]], api_version: "APIVersion" + ) -> list[Any]: + """Extract multiple record IDs from bulk operation responses. + + v2: [{"Id": 1}, {"Id": 2}] + v3: {"records": [{"id": 1, ...}, {"id": 2, ...}]} + """ + if api_version == APIVersion.V2: + if isinstance(response, list): + return [ + r.get("Id") for r in response if isinstance(r, dict) and r.get("Id") is not None + ] + elif isinstance(response, dict) and "Id" in response: + return [response["Id"]] + return [] + + # v3: records wrapper + if isinstance(response, dict): + records = response.get("records", []) + if records: + return [ + r.get("id") for r in records if isinstance(r, dict) and r.get("id") is not None + ] + # Fallback: direct id + if "id" in response: + return [response["id"]] + elif isinstance(response, list): + return [ + r.get("id") for r in response if isinstance(r, dict) and r.get("id") is not None + ] + + return [] + + +class RequestAdapter: + """Adapter for formatting API requests between v2 and v3 formats. + + v2 sends flat record data: {"Name": "John", "Email": "john@example.com"} + v3 wraps field data: {"fields": {"Name": "John", "Email": "john@example.com"}} + """ + + @staticmethod + def format_record(record: dict[str, Any], api_version: "APIVersion") -> dict[str, Any]: + """Format a record for create/update requests. + + v2: {"Name": "John", "Email": "john@example.com"} + v3: {"fields": {"Name": "John", "Email": "john@example.com"}} + """ + if api_version == APIVersion.V2: + return record + + # v3: wrap in fields, keeping Id/id separate + fields = {} + result: dict[str, Any] = {} + for k, v in record.items(): + if k in ("Id", "id"): + result["id"] = v + else: + fields[k] = v + result["fields"] = fields + return result + + @staticmethod + def format_records( + records: list[dict[str, Any]], api_version: "APIVersion" + ) -> list[dict[str, Any]] | dict[str, Any]: + """Format multiple records for bulk create/update requests. + + v2: [{"Name": "John"}, {"Name": "Jane"}] + v3: {"records": [{"fields": {"Name": "John"}}, {"fields": {"Name": "Jane"}}]} + """ + if api_version == APIVersion.V2: + return records + + # v3: wrap in records array with fields + return {"records": [RequestAdapter.format_record(r, api_version) for r in records]} + + @staticmethod + def format_delete(record_id: int | str, api_version: "APIVersion") -> dict[str, Any]: + """Format a delete request body. + + v2: {"Id": 123} + v3: {"id": 123} + """ + if api_version == APIVersion.V2: + return {"Id": record_id} + return {"id": record_id} + + @staticmethod + def format_bulk_delete( + record_ids: list[int | str], api_version: "APIVersion" + ) -> list[dict[str, Any]] | dict[str, Any]: + """Format bulk delete request body. + + v2: [{"Id": 1}, {"Id": 2}] + v3: {"records": [{"id": 1}, {"id": 2}]} + """ + if api_version == APIVersion.V2: + return [{"Id": rid} for rid in record_ids] + return {"records": [{"id": rid} for rid in record_ids]} + + class QueryParamAdapter: """Adapter for converting query parameters between API versions.""" diff --git a/src/nocodb_simple_client/client.py b/src/nocodb_simple_client/client.py index 11c412d..d1de01a 100644 --- a/src/nocodb_simple_client/client.py +++ b/src/nocodb_simple_client/client.py @@ -33,7 +33,7 @@ import requests from requests_toolbelt.multipart.encoder import MultipartEncoder -from .api_version import APIVersion, PathBuilder, QueryParamAdapter +from .api_version import APIVersion, PathBuilder, QueryParamAdapter, RequestAdapter, ResponseAdapter from .base_resolver import BaseIdResolver from .exceptions import NocoDBException, RecordNotFoundException, ValidationException @@ -156,6 +156,8 @@ def __init__( self.base_id = base_id self._path_builder = PathBuilder(self.api_version) self._param_adapter = QueryParamAdapter() + self._response_adapter = ResponseAdapter() + self._request_adapter = RequestAdapter() # Base ID resolver for v3 API (resolves table_id -> base_id) self._base_resolver = BaseIdResolver(self) if self.api_version == APIVersion.V3 else None @@ -352,10 +354,11 @@ def get_records( response = self._get(endpoint, params=params) - batch_records = response.get("list", []) + batch_records, page_info = self._response_adapter.normalize_records_list( + response, self.api_version + ) records.extend(batch_records) - page_info = response.get("pageInfo", {}) offset += len(batch_records) remaining_limit -= len(batch_records) @@ -398,7 +401,8 @@ def get_record( if fields: params["fields"] = ",".join(fields) - return self._get(endpoint, params=params) + response = self._get(endpoint, params=params) + return self._response_adapter.normalize_record(response, self.api_version) def insert_record( self, table_id: str, record: dict[str, Any], base_id: str | None = None @@ -424,10 +428,12 @@ def insert_record( # Build path using PathBuilder endpoint = self._path_builder.records_create(table_id, resolved_base_id) - response = self._post(endpoint, data=record) - # API v2 returns a single object: {"Id": 123} + # Format request for API version + formatted_record = self._request_adapter.format_record(record, self.api_version) + + response = self._post(endpoint, data=formatted_record) if isinstance(response, dict): - record_id = response.get("Id") + record_id = self._response_adapter.extract_record_id(response, self.api_version) else: raise NocoDBException( "INVALID_RESPONSE", @@ -473,9 +479,12 @@ def update_record( # Build path using PathBuilder endpoint = self._path_builder.records_update(table_id, resolved_base_id) - response = self._patch(endpoint, data=record) + # Format request for API version + formatted_record = self._request_adapter.format_record(record, self.api_version) + + response = self._patch(endpoint, data=formatted_record) if isinstance(response, dict): - record_id = response.get("Id") + record_id = self._response_adapter.extract_record_id(response, self.api_version) else: raise NocoDBException( "INVALID_RESPONSE", @@ -513,9 +522,12 @@ def delete_record( # Build path using PathBuilder endpoint = self._path_builder.records_delete(table_id, resolved_base_id) - response = self._delete(endpoint, data={"Id": record_id}) + # Format request for API version + delete_data = self._request_adapter.format_delete(record_id, self.api_version) + + response = self._delete(endpoint, data=delete_data) if isinstance(response, dict): - deleted_id = response.get("Id") + deleted_id = self._response_adapter.extract_record_id(response, self.api_version) else: raise NocoDBException( "INVALID_RESPONSE", @@ -591,24 +603,18 @@ def bulk_insert_records( # Build path using PathBuilder endpoint = self._path_builder.records_create(table_id, resolved_base_id) - # NocoDB v2 API supports bulk insert via array payload + # Format request for API version + formatted_data = self._request_adapter.format_records(records, self.api_version) + try: - response = self._post(endpoint, data=records) - - # Response should be list of record IDs - if isinstance(response, list): - record_ids = [] - for record in response: - if isinstance(record, dict) and record.get("Id") is not None: - record_ids.append(record["Id"]) + response = self._post(endpoint, data=formatted_data) + + # Extract record IDs using version-aware adapter + record_ids = self._response_adapter.extract_record_ids(response, self.api_version) + if record_ids: return record_ids - elif isinstance(response, dict) and "Id" in response: - # Single record response (fallback) - return [response["Id"]] - else: - raise NocoDBException( - "INVALID_RESPONSE", "Unexpected response format from bulk insert" - ) + + raise NocoDBException("INVALID_RESPONSE", "Unexpected response format from bulk insert") except Exception as e: if isinstance(e, NocoDBException): @@ -653,23 +659,18 @@ def bulk_update_records( # Build path using PathBuilder endpoint = self._path_builder.records_update(table_id, resolved_base_id) + # Format request for API version + formatted_data = self._request_adapter.format_records(records, self.api_version) + try: - response = self._patch(endpoint, data=records) - - # Response should be list of record IDs - if isinstance(response, list): - record_ids = [] - for record in response: - if isinstance(record, dict) and record.get("Id") is not None: - record_ids.append(record["Id"]) + response = self._patch(endpoint, data=formatted_data) + + # Extract record IDs using version-aware adapter + record_ids = self._response_adapter.extract_record_ids(response, self.api_version) + if record_ids: return record_ids - elif isinstance(response, dict) and "Id" in response: - # Single record response (fallback) - return [response["Id"]] - else: - raise NocoDBException( - "INVALID_RESPONSE", "Unexpected response format from bulk update" - ) + + raise NocoDBException("INVALID_RESPONSE", "Unexpected response format from bulk update") except Exception as e: if isinstance(e, NocoDBException): @@ -707,26 +708,18 @@ def bulk_delete_records( # Build path using PathBuilder endpoint = self._path_builder.records_delete(table_id, resolved_base_id) - # Convert to list of dictionaries with Id field - records_to_delete = [{"Id": record_id} for record_id in record_ids] + # Format request for API version + delete_data = self._request_adapter.format_bulk_delete(record_ids, self.api_version) try: - response = self._delete(endpoint, data=records_to_delete) - - # Response should be list of record IDs - if isinstance(response, list): - record_ids = [] - for record in response: - if isinstance(record, dict) and record.get("Id") is not None: - record_ids.append(record["Id"]) - return record_ids - elif isinstance(response, dict) and "Id" in response: - # Single record response (fallback) - return [response["Id"]] - else: - raise NocoDBException( - "INVALID_RESPONSE", "Unexpected response format from bulk delete" - ) + response = self._delete(endpoint, data=delete_data) + + # Extract record IDs using version-aware adapter + deleted_ids = self._response_adapter.extract_record_ids(response, self.api_version) + if deleted_ids: + return deleted_ids + + raise NocoDBException("INVALID_RESPONSE", "Unexpected response format from bulk delete") except Exception as e: if isinstance(e, NocoDBException): diff --git a/tests/test_api_version.py b/tests/test_api_version.py index 9b5aebc..8035a79 100644 --- a/tests/test_api_version.py +++ b/tests/test_api_version.py @@ -7,7 +7,13 @@ import pytest -from nocodb_simple_client.api_version import APIVersion, PathBuilder, QueryParamAdapter +from nocodb_simple_client.api_version import ( + APIVersion, + PathBuilder, + QueryParamAdapter, + RequestAdapter, + ResponseAdapter, +) class TestAPIVersion: @@ -405,3 +411,142 @@ def test_webhooks_list_v3(self): path = builder.webhooks_list("table_123", "base_abc") assert path == "api/v3/meta/bases/base_abc/tables/table_123/hooks" + + +class TestResponseAdapter: + """Test ResponseAdapter for normalizing API responses.""" + + def test_normalize_record_v2_passthrough(self): + """Test v2 records pass through unchanged.""" + record = {"Id": 1, "Name": "Test"} + result = ResponseAdapter.normalize_record(record, APIVersion.V2) + assert result == {"Id": 1, "Name": "Test"} + + def test_normalize_record_v3_with_fields(self): + """Test v3 record with fields wrapper is flattened.""" + record = {"id": 1, "fields": {"Name": "Test", "Email": "a@b.com"}} + result = ResponseAdapter.normalize_record(record, APIVersion.V3) + assert result == {"Id": 1, "Name": "Test", "Email": "a@b.com"} + + def test_normalize_record_v3_without_fields(self): + """Test v3 record without fields wrapper (fallback).""" + record = {"id": 1, "Name": "Test"} + result = ResponseAdapter.normalize_record(record, APIVersion.V3) + assert result == {"Id": 1, "Name": "Test"} + + def test_normalize_records_list_v2(self): + """Test v2 records list parsing.""" + response = { + "list": [{"Id": 1}, {"Id": 2}], + "pageInfo": {"isLastPage": True}, + } + records, page_info = ResponseAdapter.normalize_records_list(response, APIVersion.V2) + assert len(records) == 2 + assert records[0]["Id"] == 1 + assert page_info["isLastPage"] is True + + def test_normalize_records_list_v3(self): + """Test v3 records list parsing with normalization.""" + response = { + "records": [ + {"id": 1, "fields": {"Name": "A"}}, + {"id": 2, "fields": {"Name": "B"}}, + ], + "next": "https://example.com/page2", + } + records, page_info = ResponseAdapter.normalize_records_list(response, APIVersion.V3) + assert len(records) == 2 + assert records[0]["Id"] == 1 + assert records[0]["Name"] == "A" + assert page_info["isLastPage"] is False + + def test_normalize_records_list_v3_last_page(self): + """Test v3 records list with no next page.""" + response = {"records": [{"id": 1, "fields": {"Name": "A"}}], "next": None} + records, page_info = ResponseAdapter.normalize_records_list(response, APIVersion.V3) + assert page_info["isLastPage"] is True + + def test_extract_record_id_v2(self): + """Test extracting record ID from v2 response.""" + assert ResponseAdapter.extract_record_id({"Id": 42}, APIVersion.V2) == 42 + + def test_extract_record_id_v3_direct(self): + """Test extracting record ID from v3 direct response.""" + response = {"id": 42, "fields": {"Name": "Test"}} + assert ResponseAdapter.extract_record_id(response, APIVersion.V3) == 42 + + def test_extract_record_id_v3_records_wrapper(self): + """Test extracting record ID from v3 records wrapper.""" + response = {"records": [{"id": 99, "fields": {"Name": "Test"}}]} + assert ResponseAdapter.extract_record_id(response, APIVersion.V3) == 99 + + def test_extract_record_ids_v2_list(self): + """Test extracting IDs from v2 list response.""" + response = [{"Id": 1}, {"Id": 2}, {"Id": 3}] + result = ResponseAdapter.extract_record_ids(response, APIVersion.V2) + assert result == [1, 2, 3] + + def test_extract_record_ids_v3_records_wrapper(self): + """Test extracting IDs from v3 records wrapper.""" + response = {"records": [{"id": 10}, {"id": 20}]} + result = ResponseAdapter.extract_record_ids(response, APIVersion.V3) + assert result == [10, 20] + + +class TestRequestAdapter: + """Test RequestAdapter for formatting API requests.""" + + def test_format_record_v2_passthrough(self): + """Test v2 records pass through unchanged.""" + record = {"Name": "Test", "Email": "a@b.com"} + result = RequestAdapter.format_record(record, APIVersion.V2) + assert result == {"Name": "Test", "Email": "a@b.com"} + + def test_format_record_v3_wraps_fields(self): + """Test v3 wraps fields and uses lowercase id.""" + record = {"Name": "Test", "Email": "a@b.com"} + result = RequestAdapter.format_record(record, APIVersion.V3) + assert result == {"fields": {"Name": "Test", "Email": "a@b.com"}} + + def test_format_record_v3_with_id(self): + """Test v3 separates Id from fields.""" + record = {"Id": 42, "Name": "Test"} + result = RequestAdapter.format_record(record, APIVersion.V3) + assert result == {"id": 42, "fields": {"Name": "Test"}} + + def test_format_records_v2_passthrough(self): + """Test v2 bulk records pass through unchanged.""" + records = [{"Name": "A"}, {"Name": "B"}] + result = RequestAdapter.format_records(records, APIVersion.V2) + assert result == [{"Name": "A"}, {"Name": "B"}] + + def test_format_records_v3_wraps_in_records(self): + """Test v3 wraps in records array with fields.""" + records = [{"Name": "A"}, {"Name": "B"}] + result = RequestAdapter.format_records(records, APIVersion.V3) + assert result == { + "records": [ + {"fields": {"Name": "A"}}, + {"fields": {"Name": "B"}}, + ] + } + + def test_format_delete_v2(self): + """Test v2 delete format.""" + result = RequestAdapter.format_delete(42, APIVersion.V2) + assert result == {"Id": 42} + + def test_format_delete_v3(self): + """Test v3 delete format with lowercase id.""" + result = RequestAdapter.format_delete(42, APIVersion.V3) + assert result == {"id": 42} + + def test_format_bulk_delete_v2(self): + """Test v2 bulk delete format.""" + result = RequestAdapter.format_bulk_delete([1, 2, 3], APIVersion.V2) + assert result == [{"Id": 1}, {"Id": 2}, {"Id": 3}] + + def test_format_bulk_delete_v3(self): + """Test v3 bulk delete format.""" + result = RequestAdapter.format_bulk_delete([1, 2, 3], APIVersion.V3) + assert result == {"records": [{"id": 1}, {"id": 2}, {"id": 3}]} diff --git a/tests/test_version_switching.py b/tests/test_version_switching.py index 84f7293..e6f3c3d 100644 --- a/tests/test_version_switching.py +++ b/tests/test_version_switching.py @@ -79,7 +79,7 @@ def test_get_records_v2_endpoint(self, mock_session): assert "api/v2/tables/table_123/records" in call_args[0][0] def test_get_records_v3_endpoint(self, mock_session): - """Test get_records uses v3 endpoint.""" + """Test get_records uses v3 endpoint and parses v3 response format.""" client = NocoDBClient( base_url="https://test.com", db_auth_token="token", @@ -87,15 +87,27 @@ def test_get_records_v3_endpoint(self, mock_session): base_id="base_abc", ) - mock_session.get.return_value.json.return_value = {"list": [], "pageInfo": {}} + # v3 uses "records" key with nested "fields" and lowercase "id" + mock_session.get.return_value.json.return_value = { + "records": [ + {"id": 1, "fields": {"Name": "Record 1"}}, + {"id": 2, "fields": {"Name": "Record 2"}}, + ], + "next": None, + } mock_session.get.return_value.status_code = 200 - client.get_records("table_123", limit=10) + result = client.get_records("table_123", limit=10) # Check that v3 endpoint was called call_args = mock_session.get.call_args assert "api/v3/data/base_abc/table_123/records" in call_args[0][0] + # Check that v3 response was normalized to v2-compatible format + assert len(result) == 2 + assert result[0]["Id"] == 1 + assert result[0]["Name"] == "Record 1" + def test_v2_pagination_params(self, mock_session): """Test v2 uses offset/limit parameters.""" client = NocoDBClient( @@ -125,7 +137,7 @@ def test_v3_pagination_params(self, mock_session): base_id="base_abc", ) - mock_session.get.return_value.json.return_value = {"list": [], "pageInfo": {}} + mock_session.get.return_value.json.return_value = {"records": [], "next": None} mock_session.get.return_value.status_code = 200 client.get_records("table_123", limit=25) @@ -167,7 +179,7 @@ def test_v3_sort_json_format(self, mock_session): base_id="base_abc", ) - mock_session.get.return_value.json.return_value = {"list": [], "pageInfo": {}} + mock_session.get.return_value.json.return_value = {"records": [], "next": None} mock_session.get.return_value.status_code = 200 client.get_records("table_123", sort="name,-age") @@ -362,7 +374,137 @@ def test_both_data_and_meta_operations(self, mock_session): assert "api/v3/meta/bases/base_abc/tables" in meta_call # Data operation (inherited from NocoDBClient) - mock_session.get.return_value.json.return_value = {"list": [], "pageInfo": {}} + mock_session.get.return_value.json.return_value = {"records": [], "next": None} meta_client.get_records("table_123") data_call = mock_session.get.call_args[0][0] assert "api/v3/data/base_abc/table_123/records" in data_call + + +class TestV3ResponseFormatHandling: + """Test that v3 API response formats are correctly parsed.""" + + @pytest.fixture + def mock_session(self): + """Create a mock session.""" + with patch("nocodb_simple_client.client.requests.Session") as mock: + yield mock.return_value + + @pytest.fixture + def v3_client(self, mock_session): + """Create v3 client for testing.""" + return NocoDBClient( + base_url="https://test.com", + db_auth_token="token", + api_version="v3", + base_id="base_abc", + ) + + def test_get_record_v3_normalizes_fields(self, v3_client, mock_session): + """Test get_record normalizes v3 {id, fields} to flat format.""" + mock_session.get.return_value.json.return_value = { + "id": 42, + "fields": {"Name": "Test", "Email": "test@example.com"}, + } + mock_session.get.return_value.status_code = 200 + + result = v3_client.get_record("table_123", 42) + + assert result["Id"] == 42 + assert result["Name"] == "Test" + assert result["Email"] == "test@example.com" + + def test_insert_record_v3_formats_request_and_response(self, v3_client, mock_session): + """Test insert_record wraps data in fields and parses v3 response.""" + mock_session.post.return_value.json.return_value = { + "records": [{"id": 99, "fields": {"Name": "New"}}] + } + mock_session.post.return_value.status_code = 200 + + result = v3_client.insert_record("table_123", {"Name": "New"}) + + assert result == 99 + + # Verify request was formatted for v3 + call_args = mock_session.post.call_args + request_data = call_args[1]["json"] + assert "fields" in request_data + assert request_data["fields"]["Name"] == "New" + + def test_update_record_v3_formats_request_and_response(self, v3_client, mock_session): + """Test update_record wraps data in fields and parses v3 response.""" + mock_session.patch.return_value.json.return_value = { + "records": [{"id": 42, "fields": {"Name": "Updated"}}] + } + mock_session.patch.return_value.status_code = 200 + + result = v3_client.update_record("table_123", {"Name": "Updated"}, record_id=42) + + assert result == 42 + + # Verify request was formatted for v3 + call_args = mock_session.patch.call_args + request_data = call_args[1]["json"] + assert "fields" in request_data + assert request_data["id"] == 42 + + def test_delete_record_v3_formats_request_and_response(self, v3_client, mock_session): + """Test delete_record uses lowercase 'id' for v3.""" + mock_session.delete.return_value.json.return_value = { + "records": [{"id": 42, "deleted": True}] + } + mock_session.delete.return_value.status_code = 200 + + result = v3_client.delete_record("table_123", 42) + + assert result == 42 + + # Verify request was formatted for v3 + call_args = mock_session.delete.call_args + request_data = call_args[1]["json"] + assert "id" in request_data + assert request_data["id"] == 42 + + def test_get_records_v3_pagination_with_next(self, v3_client, mock_session): + """Test get_records handles v3 cursor-based pagination.""" + # First call returns records with next token + mock_session.get.return_value.json.return_value = { + "records": [ + {"id": 1, "fields": {"Name": "A"}}, + {"id": 2, "fields": {"Name": "B"}}, + ], + "next": "https://test.com/api/v3/data/base_abc/table_123/records?page=2", + } + mock_session.get.return_value.status_code = 200 + + result = v3_client.get_records("table_123", limit=2) + + assert len(result) == 2 + assert result[0]["Id"] == 1 + assert result[1]["Name"] == "B" + + def test_bulk_insert_v3_formats_records(self, v3_client, mock_session): + """Test bulk_insert formats records for v3 with records wrapper.""" + mock_session.post.return_value.json.return_value = { + "records": [ + {"id": 10, "fields": {"Name": "A"}}, + {"id": 11, "fields": {"Name": "B"}}, + ] + } + mock_session.post.return_value.status_code = 200 + + result = v3_client.bulk_insert_records( + "table_123", [{"Name": "A"}, {"Name": "B"}] + ) + + assert result == [10, 11] + + def test_bulk_delete_v3_formats_ids(self, v3_client, mock_session): + """Test bulk_delete formats IDs for v3.""" + mock_session.delete.return_value.json.return_value = { + "records": [{"id": 1, "deleted": True}, {"id": 2, "deleted": True}] + } + mock_session.delete.return_value.status_code = 200 + + result = v3_client.bulk_delete_records("table_123", [1, 2]) + + assert result == [1, 2]