From 5b07e8b0d8f62d3b8e08ef4cf53aa34e94880c57 Mon Sep 17 00:00:00 2001 From: pengfeiye Date: Fri, 24 Apr 2026 15:24:03 -0400 Subject: [PATCH 1/3] Add live E2E coverage for policies, rules, and lists --- tests/e2e/conftest.py | 104 +++++++++++++++++++++++++++++++++ tests/e2e/test_lists_e2e.py | 60 +++++++++++++++++++ tests/e2e/test_policies_e2e.py | 70 ++++++++++++++++++++++ tests/e2e/test_rules_e2e.py | 51 ++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_lists_e2e.py create mode 100644 tests/e2e/test_policies_e2e.py create mode 100644 tests/e2e/test_rules_e2e.py diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..d8a5e2c --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,104 @@ +import os +from typing import Dict, List +from uuid import uuid4 + +import pytest + +from nylas import Client + + +_API_KEY_ENV_VARS = ("NYLAS_E2E_API_KEY", "NYLAS_API_KEY") +_API_URI_ENV_VARS = ("NYLAS_E2E_API_URI", "NYLAS_API_URI") + + +def _first_env_value(keys: tuple) -> str: + for key in keys: + value = os.getenv(key) + if value: + return value + return "" + + +def extract_list_items(response_data): + """ + Normalize list endpoint response payloads across API shapes. + """ + if isinstance(response_data, list): + return response_data + + if isinstance(response_data, dict): + items = response_data.get("items") + if isinstance(items, list): + return items + + return [] + + +def raw_list_ids(client: Client, path: str, id_key: str = "id", query_params=None): + json_response, _headers = client.http_client._execute( + "GET", + path, + None, + query_params or {"limit": 200}, + None, + ) + response_data = json_response.get("data") + items = extract_list_items(response_data) + return {item.get(id_key) for item in items if isinstance(item, dict) and item.get(id_key)} + + +@pytest.fixture +def raw_list_ids_helper(): + return raw_list_ids + + +@pytest.fixture(scope="session") +def e2e_client() -> Client: + api_key = _first_env_value(_API_KEY_ENV_VARS) + if not api_key: + pytest.skip( + "E2E tests require NYLAS_E2E_API_KEY (or NYLAS_API_KEY) to be set." + ) + + api_uri = _first_env_value(_API_URI_ENV_VARS) + 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..0122608 --- /dev/null +++ b/tests/e2e/test_policies_e2e.py @@ -0,0 +1,70 @@ +import pytest + + +@pytest.mark.e2e +def test_policies_lifecycle_with_rule_association_e2e( + e2e_client, e2e_resource_registry, unique_name, raw_list_ids_helper +): + 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 + + returned_policy_ids = raw_list_ids_helper(e2e_client, "/v3/policies") + assert created_policy.id in returned_policy_ids + + 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..4b7d81d --- /dev/null +++ b/tests/e2e/test_rules_e2e.py @@ -0,0 +1,51 @@ +import pytest + + +@pytest.mark.e2e +def test_rules_lifecycle_e2e( + e2e_client, e2e_resource_registry, unique_name, raw_list_ids_helper +): + 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 + + returned_rule_ids = raw_list_ids_helper(e2e_client, "/v3/rules") + assert created_rule.id in returned_rule_ids + + destroy_response = e2e_client.rules.destroy(created_rule.id) + assert destroy_response.request_id + e2e_resource_registry["rules"].remove(created_rule.id) + From 29bc3b5a79422870b05e3e548eb8d57c4df373a0 Mon Sep 17 00:00:00 2001 From: pengfeiye Date: Mon, 27 Apr 2026 13:50:37 -0400 Subject: [PATCH 2/3] address comment --- nylas/models/response.py | 12 ++++++-- pyproject.toml | 5 ++++ tests/e2e/conftest.py | 51 +++++++++++++++------------------- tests/e2e/test_policies_e2e.py | 5 ++-- tests/e2e/test_rules_e2e.py | 5 ++-- tests/test_response.py | 34 +++++++++++++++++++++++ 6 files changed, 75 insertions(+), 37 deletions(-) create mode 100644 tests/test_response.py 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..e6b0356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,3 +49,8 @@ version = {attr = "nylas._client_sdk_version.__VERSION__"} [tool.setuptools.packages.find] where = ["."] include = ["nylas*"] + +[tool.pytest.ini_options] +markers = [ + "e2e: marks tests that call live Nylas APIs", +] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index d8a5e2c..3060ecb 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -19,37 +19,30 @@ def _first_env_value(keys: tuple) -> str: return "" -def extract_list_items(response_data): - """ - Normalize list endpoint response payloads across API shapes. - """ - if isinstance(response_data, list): - return response_data - - if isinstance(response_data, dict): - items = response_data.get("items") - if isinstance(items, list): - return items - - return [] - - -def raw_list_ids(client: Client, path: str, id_key: str = "id", query_params=None): - json_response, _headers = client.http_client._execute( - "GET", - path, - None, - query_params or {"limit": 200}, - None, - ) - response_data = json_response.get("data") - items = extract_list_items(response_data) - return {item.get(id_key) for item in items if isinstance(item, dict) and item.get(id_key)} +@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 -@pytest.fixture -def raw_list_ids_helper(): - return raw_list_ids + 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") diff --git a/tests/e2e/test_policies_e2e.py b/tests/e2e/test_policies_e2e.py index 0122608..6f84209 100644 --- a/tests/e2e/test_policies_e2e.py +++ b/tests/e2e/test_policies_e2e.py @@ -3,7 +3,7 @@ @pytest.mark.e2e def test_policies_lifecycle_with_rule_association_e2e( - e2e_client, e2e_resource_registry, unique_name, raw_list_ids_helper + e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id ): rule_response = e2e_client.rules.create( { @@ -57,8 +57,7 @@ def test_policies_lifecycle_with_rule_association_e2e( assert refetch_response.data.rules is not None assert created_rule.id in refetch_response.data.rules - returned_policy_ids = raw_list_ids_helper(e2e_client, "/v3/policies") - assert created_policy.id in returned_policy_ids + 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 diff --git a/tests/e2e/test_rules_e2e.py b/tests/e2e/test_rules_e2e.py index 4b7d81d..677aa17 100644 --- a/tests/e2e/test_rules_e2e.py +++ b/tests/e2e/test_rules_e2e.py @@ -3,7 +3,7 @@ @pytest.mark.e2e def test_rules_lifecycle_e2e( - e2e_client, e2e_resource_registry, unique_name, raw_list_ids_helper + e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id ): create_response = e2e_client.rules.create( { @@ -42,8 +42,7 @@ def test_rules_lifecycle_e2e( assert update_response.data.id == created_rule.id assert update_response.data.name == updated_name - returned_rule_ids = raw_list_ids_helper(e2e_client, "/v3/rules") - assert created_rule.id in returned_rule_ids + 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 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" From 4c2524bf7c3322a07a8109d4a40d8a32ef9b14ef Mon Sep 17 00:00:00 2001 From: pengfeiye Date: Mon, 27 Apr 2026 13:54:33 -0400 Subject: [PATCH 3/3] address comment --- pyproject.toml | 1 + tests/e2e/conftest.py | 43 ++++++++++++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e6b0356..46395ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ 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 index 3060ecb..89193c8 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -7,16 +7,37 @@ from nylas import Client -_API_KEY_ENV_VARS = ("NYLAS_E2E_API_KEY", "NYLAS_API_KEY") -_API_URI_ENV_VARS = ("NYLAS_E2E_API_URI", "NYLAS_API_URI") +_E2E_API_KEY_ENV = "NYLAS_E2E_API_KEY" +_E2E_API_URI_ENV = "NYLAS_E2E_API_URI" +_E2E_RUN_ENV = "NYLAS_E2E_RUN" -def _first_env_value(keys: tuple) -> str: - for key in keys: - value = os.getenv(key) - if value: - return value - return "" +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 @@ -47,13 +68,13 @@ def _contains_id(list_method, resource_id: str, limit: int = 100, max_pages: int @pytest.fixture(scope="session") def e2e_client() -> Client: - api_key = _first_env_value(_API_KEY_ENV_VARS) + api_key = os.getenv(_E2E_API_KEY_ENV, "") if not api_key: pytest.skip( - "E2E tests require NYLAS_E2E_API_KEY (or NYLAS_API_KEY) to be set." + "E2E tests require NYLAS_E2E_API_KEY to be set." ) - api_uri = _first_env_value(_API_URI_ENV_VARS) + 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)