diff --git a/nylas/models/response.py b/nylas/models/response.py index bcb0fba..64b03d0 100644 --- a/nylas/models/response.py +++ b/nylas/models/response.py @@ -110,14 +110,22 @@ def from_dict(cls, resp: dict, generic_type, headers: Optional[CaseInsensitiveDi headers: The headers returned from the API. """ + raw_data = resp.get("data", []) + if isinstance(raw_data, dict): + next_cursor = resp.get("next_cursor", raw_data.get("next_cursor")) + data = raw_data.get("items", []) + else: + next_cursor = resp.get("next_cursor") + data = raw_data + converted_data = [] - for item in resp["data"]: + for item in data: converted_data.append(generic_type.from_dict(item, infer_missing=True)) return cls( data=converted_data, request_id=resp["request_id"], - next_cursor=resp.get("next_cursor", None), + next_cursor=next_cursor, headers=headers, ) diff --git a/pyproject.toml b/pyproject.toml index 42b4f9f..46395ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,3 +49,9 @@ version = {attr = "nylas._client_sdk_version.__VERSION__"} [tool.setuptools.packages.find] where = ["."] include = ["nylas*"] + +[tool.pytest.ini_options] +addopts = "-m 'not e2e'" +markers = [ + "e2e: marks tests that call live Nylas APIs", +] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..89193c8 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,118 @@ +import os +from typing import Dict, List +from uuid import uuid4 + +import pytest + +from nylas import Client + + +_E2E_API_KEY_ENV = "NYLAS_E2E_API_KEY" +_E2E_API_URI_ENV = "NYLAS_E2E_API_URI" +_E2E_RUN_ENV = "NYLAS_E2E_RUN" + + +def _is_truthy(value: str) -> bool: + return value.lower() in {"1", "true", "yes", "on"} + + +def pytest_addoption(parser): + parser.addoption( + "--run-e2e", + action="store_true", + default=False, + help="Run live E2E tests that call Nylas APIs.", + ) + + +def pytest_collection_modifyitems(config, items): + run_e2e = config.getoption("--run-e2e") or _is_truthy(os.getenv(_E2E_RUN_ENV, "")) + if run_e2e: + return + + skip_e2e = pytest.mark.skip( + reason=( + "E2E tests are opt-in. Set NYLAS_E2E_RUN=1 or pass --run-e2e to execute." + ) + ) + for item in items: + if "e2e" in item.keywords: + item.add_marker(skip_e2e) + + +@pytest.fixture +def paginated_list_contains_id(): + def _contains_id(list_method, resource_id: str, limit: int = 100, max_pages: int = 20) -> bool: + next_cursor = None + seen_cursors = set() + + for _ in range(max_pages): + query_params = {"limit": limit} + if next_cursor: + query_params["page_token"] = next_cursor + + response = list_method(query_params=query_params) + if any(item.id == resource_id for item in response.data if item and item.id): + return True + + if not response.next_cursor or response.next_cursor in seen_cursors: + return False + + seen_cursors.add(response.next_cursor) + next_cursor = response.next_cursor + + return False + + return _contains_id + + +@pytest.fixture(scope="session") +def e2e_client() -> Client: + api_key = os.getenv(_E2E_API_KEY_ENV, "") + if not api_key: + pytest.skip( + "E2E tests require NYLAS_E2E_API_KEY to be set." + ) + + api_uri = os.getenv(_E2E_API_URI_ENV, "") + timeout = int(os.getenv("NYLAS_E2E_TIMEOUT", "90")) + if api_uri: + return Client(api_key=api_key, api_uri=api_uri, timeout=timeout) + return Client(api_key=api_key, timeout=timeout) + + +@pytest.fixture +def unique_name(): + def _build(prefix: str) -> str: + return f"{prefix}-{uuid4().hex[:10]}" + + return _build + + +@pytest.fixture +def e2e_resource_registry(e2e_client): + registry: Dict[str, List[str]] = { + "policies": [], + "rules": [], + "lists": [], + } + yield registry + + for policy_id in reversed(registry["policies"]): + try: + e2e_client.policies.destroy(policy_id) + except Exception: + pass + + for rule_id in reversed(registry["rules"]): + try: + e2e_client.rules.destroy(rule_id) + except Exception: + pass + + for list_id in reversed(registry["lists"]): + try: + e2e_client.lists.destroy(list_id) + except Exception: + pass + diff --git a/tests/e2e/test_lists_e2e.py b/tests/e2e/test_lists_e2e.py new file mode 100644 index 0000000..a9778d1 --- /dev/null +++ b/tests/e2e/test_lists_e2e.py @@ -0,0 +1,60 @@ +import pytest + + +@pytest.mark.e2e +def test_lists_lifecycle_e2e(e2e_client, e2e_resource_registry, unique_name): + create_response = e2e_client.lists.create( + { + "name": unique_name("e2e-list"), + "type": "domain", + "description": "Created by SDK e2e test", + } + ) + created_list = create_response.data + assert created_list.id + assert created_list.type == "domain" + e2e_resource_registry["lists"].append(created_list.id) + + found_response = e2e_client.lists.find(created_list.id) + assert found_response.data.id == created_list.id + + updated_name = unique_name("e2e-list-updated") + update_response = e2e_client.lists.update( + created_list.id, + {"name": updated_name, "description": "Updated by SDK e2e test"}, + ) + assert update_response.data.id == created_list.id + assert update_response.data.name == updated_name + + first_domain = f"{unique_name('allowed')}.example" + second_domain = f"{unique_name('blocked')}.example" + add_items_response = e2e_client.lists.add_items( + created_list.id, {"items": [first_domain, second_domain]} + ) + assert add_items_response.data.id == created_list.id + + list_items_response = e2e_client.lists.list_items( + created_list.id, query_params={"limit": 200} + ) + item_values = {item.value for item in list_items_response.data if item.value} + assert first_domain in item_values + assert second_domain in item_values + + remove_items_response = e2e_client.lists.remove_items( + created_list.id, {"items": [first_domain]} + ) + assert remove_items_response.data.id == created_list.id + + after_remove_response = e2e_client.lists.list_items( + created_list.id, query_params={"limit": 200} + ) + item_values_after_remove = { + item.value for item in after_remove_response.data if item.value + } + assert first_domain not in item_values_after_remove + assert second_domain in item_values_after_remove + + destroy_response = e2e_client.lists.destroy(created_list.id) + assert destroy_response.request_id + e2e_resource_registry["lists"].remove(created_list.id) + diff --git a/tests/e2e/test_policies_e2e.py b/tests/e2e/test_policies_e2e.py new file mode 100644 index 0000000..6f84209 --- /dev/null +++ b/tests/e2e/test_policies_e2e.py @@ -0,0 +1,69 @@ +import pytest + + +@pytest.mark.e2e +def test_policies_lifecycle_with_rule_association_e2e( + e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id +): + rule_response = e2e_client.rules.create( + { + "name": unique_name("e2e-policy-rule"), + "trigger": "inbound", + "match": { + "operator": "any", + "conditions": [ + { + "field": "from.domain", + "operator": "is", + "value": "example.com", + } + ], + }, + "actions": [{"type": "archive"}], + } + ) + created_rule = rule_response.data + assert created_rule.id + e2e_resource_registry["rules"].append(created_rule.id) + + policy_response = e2e_client.policies.create( + {"name": unique_name("e2e-policy"), "rules": [created_rule.id]} + ) + created_policy = policy_response.data + assert created_policy.id + e2e_resource_registry["policies"].append(created_policy.id) + + find_response = e2e_client.policies.find(created_policy.id) + assert find_response.data.id == created_policy.id + + updated_name = unique_name("e2e-policy-updated") + update_response = e2e_client.policies.update( + created_policy.id, + { + "name": updated_name, + "rules": [created_rule.id], + "spam_detection": { + "use_list_dnsbl": True, + "use_header_anomaly_detection": True, + }, + }, + ) + # Some policy update responses may omit id; verify canonical state by refetching. + assert update_response.data.name == updated_name + + refetch_response = e2e_client.policies.find(created_policy.id) + assert refetch_response.data.id == created_policy.id + assert refetch_response.data.name == updated_name + assert refetch_response.data.rules is not None + assert created_rule.id in refetch_response.data.rules + + assert paginated_list_contains_id(e2e_client.policies.list, created_policy.id) + + destroy_policy_response = e2e_client.policies.destroy(created_policy.id) + assert destroy_policy_response.request_id + e2e_resource_registry["policies"].remove(created_policy.id) + + destroy_rule_response = e2e_client.rules.destroy(created_rule.id) + assert destroy_rule_response.request_id + e2e_resource_registry["rules"].remove(created_rule.id) + diff --git a/tests/e2e/test_rules_e2e.py b/tests/e2e/test_rules_e2e.py new file mode 100644 index 0000000..677aa17 --- /dev/null +++ b/tests/e2e/test_rules_e2e.py @@ -0,0 +1,50 @@ +import pytest + + +@pytest.mark.e2e +def test_rules_lifecycle_e2e( + e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id +): + create_response = e2e_client.rules.create( + { + "name": unique_name("e2e-rule"), + "description": "Created by SDK e2e test", + "trigger": "inbound", + "match": { + "operator": "any", + "conditions": [ + { + "field": "from.domain", + "operator": "is", + "value": "example.com", + } + ], + }, + "actions": [{"type": "archive"}], + } + ) + created_rule = create_response.data + assert created_rule.id + e2e_resource_registry["rules"].append(created_rule.id) + + find_response = e2e_client.rules.find(created_rule.id) + assert find_response.data.id == created_rule.id + + updated_name = unique_name("e2e-rule-updated") + update_response = e2e_client.rules.update( + created_rule.id, + { + "name": updated_name, + "enabled": False, + "actions": [{"type": "mark_as_spam"}], + }, + ) + assert update_response.data.id == created_rule.id + assert update_response.data.name == updated_name + + assert paginated_list_contains_id(e2e_client.rules.list, created_rule.id) + + destroy_response = e2e_client.rules.destroy(created_rule.id) + assert destroy_response.request_id + e2e_resource_registry["rules"].remove(created_rule.id) + diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..1ae87bb --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,34 @@ +from nylas.models.response import ListResponse +from nylas.models.rules import Rule + + +class TestListResponse: + def test_from_dict_with_list_data(self): + response = { + "request_id": "req-123", + "data": [{"id": "rule-1", "name": "Rule One"}], + "next_cursor": "cursor-1", + } + + parsed = ListResponse.from_dict(response, Rule) + + assert parsed.request_id == "req-123" + assert parsed.next_cursor == "cursor-1" + assert len(parsed.data) == 1 + assert parsed.data[0].id == "rule-1" + + def test_from_dict_with_items_wrapper(self): + response = { + "request_id": "req-456", + "data": { + "items": [{"id": "rule-2", "name": "Rule Two"}], + "next_cursor": "cursor-2", + }, + } + + parsed = ListResponse.from_dict(response, Rule) + + assert parsed.request_id == "req-456" + assert parsed.next_cursor == "cursor-2" + assert len(parsed.data) == 1 + assert parsed.data[0].id == "rule-2"