Skip to content

Commit b762597

Browse files
authored
Add live E2E coverage for policies, rules, and lists (#471)
1 parent b23ec1e commit b762597

7 files changed

Lines changed: 347 additions & 2 deletions

File tree

nylas/models/response.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,22 @@ def from_dict(cls, resp: dict, generic_type, headers: Optional[CaseInsensitiveDi
110110
headers: The headers returned from the API.
111111
"""
112112

113+
raw_data = resp.get("data", [])
114+
if isinstance(raw_data, dict):
115+
next_cursor = resp.get("next_cursor", raw_data.get("next_cursor"))
116+
data = raw_data.get("items", [])
117+
else:
118+
next_cursor = resp.get("next_cursor")
119+
data = raw_data
120+
113121
converted_data = []
114-
for item in resp["data"]:
122+
for item in data:
115123
converted_data.append(generic_type.from_dict(item, infer_missing=True))
116124

117125
return cls(
118126
data=converted_data,
119127
request_id=resp["request_id"],
120-
next_cursor=resp.get("next_cursor", None),
128+
next_cursor=next_cursor,
121129
headers=headers,
122130
)
123131

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,9 @@ version = {attr = "nylas._client_sdk_version.__VERSION__"}
4949
[tool.setuptools.packages.find]
5050
where = ["."]
5151
include = ["nylas*"]
52+
53+
[tool.pytest.ini_options]
54+
addopts = "-m 'not e2e'"
55+
markers = [
56+
"e2e: marks tests that call live Nylas APIs",
57+
]

tests/e2e/conftest.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import os
2+
from typing import Dict, List
3+
from uuid import uuid4
4+
5+
import pytest
6+
7+
from nylas import Client
8+
9+
10+
_E2E_API_KEY_ENV = "NYLAS_E2E_API_KEY"
11+
_E2E_API_URI_ENV = "NYLAS_E2E_API_URI"
12+
_E2E_RUN_ENV = "NYLAS_E2E_RUN"
13+
14+
15+
def _is_truthy(value: str) -> bool:
16+
return value.lower() in {"1", "true", "yes", "on"}
17+
18+
19+
def pytest_addoption(parser):
20+
parser.addoption(
21+
"--run-e2e",
22+
action="store_true",
23+
default=False,
24+
help="Run live E2E tests that call Nylas APIs.",
25+
)
26+
27+
28+
def pytest_collection_modifyitems(config, items):
29+
run_e2e = config.getoption("--run-e2e") or _is_truthy(os.getenv(_E2E_RUN_ENV, ""))
30+
if run_e2e:
31+
return
32+
33+
skip_e2e = pytest.mark.skip(
34+
reason=(
35+
"E2E tests are opt-in. Set NYLAS_E2E_RUN=1 or pass --run-e2e to execute."
36+
)
37+
)
38+
for item in items:
39+
if "e2e" in item.keywords:
40+
item.add_marker(skip_e2e)
41+
42+
43+
@pytest.fixture
44+
def paginated_list_contains_id():
45+
def _contains_id(list_method, resource_id: str, limit: int = 100, max_pages: int = 20) -> bool:
46+
next_cursor = None
47+
seen_cursors = set()
48+
49+
for _ in range(max_pages):
50+
query_params = {"limit": limit}
51+
if next_cursor:
52+
query_params["page_token"] = next_cursor
53+
54+
response = list_method(query_params=query_params)
55+
if any(item.id == resource_id for item in response.data if item and item.id):
56+
return True
57+
58+
if not response.next_cursor or response.next_cursor in seen_cursors:
59+
return False
60+
61+
seen_cursors.add(response.next_cursor)
62+
next_cursor = response.next_cursor
63+
64+
return False
65+
66+
return _contains_id
67+
68+
69+
@pytest.fixture(scope="session")
70+
def e2e_client() -> Client:
71+
api_key = os.getenv(_E2E_API_KEY_ENV, "")
72+
if not api_key:
73+
pytest.skip(
74+
"E2E tests require NYLAS_E2E_API_KEY to be set."
75+
)
76+
77+
api_uri = os.getenv(_E2E_API_URI_ENV, "")
78+
timeout = int(os.getenv("NYLAS_E2E_TIMEOUT", "90"))
79+
if api_uri:
80+
return Client(api_key=api_key, api_uri=api_uri, timeout=timeout)
81+
return Client(api_key=api_key, timeout=timeout)
82+
83+
84+
@pytest.fixture
85+
def unique_name():
86+
def _build(prefix: str) -> str:
87+
return f"{prefix}-{uuid4().hex[:10]}"
88+
89+
return _build
90+
91+
92+
@pytest.fixture
93+
def e2e_resource_registry(e2e_client):
94+
registry: Dict[str, List[str]] = {
95+
"policies": [],
96+
"rules": [],
97+
"lists": [],
98+
}
99+
yield registry
100+
101+
for policy_id in reversed(registry["policies"]):
102+
try:
103+
e2e_client.policies.destroy(policy_id)
104+
except Exception:
105+
pass
106+
107+
for rule_id in reversed(registry["rules"]):
108+
try:
109+
e2e_client.rules.destroy(rule_id)
110+
except Exception:
111+
pass
112+
113+
for list_id in reversed(registry["lists"]):
114+
try:
115+
e2e_client.lists.destroy(list_id)
116+
except Exception:
117+
pass
118+

tests/e2e/test_lists_e2e.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import pytest
2+
3+
4+
@pytest.mark.e2e
5+
def test_lists_lifecycle_e2e(e2e_client, e2e_resource_registry, unique_name):
6+
create_response = e2e_client.lists.create(
7+
{
8+
"name": unique_name("e2e-list"),
9+
"type": "domain",
10+
"description": "Created by SDK e2e test",
11+
}
12+
)
13+
created_list = create_response.data
14+
assert created_list.id
15+
assert created_list.type == "domain"
16+
e2e_resource_registry["lists"].append(created_list.id)
17+
18+
found_response = e2e_client.lists.find(created_list.id)
19+
assert found_response.data.id == created_list.id
20+
21+
updated_name = unique_name("e2e-list-updated")
22+
update_response = e2e_client.lists.update(
23+
created_list.id,
24+
{"name": updated_name, "description": "Updated by SDK e2e test"},
25+
)
26+
assert update_response.data.id == created_list.id
27+
assert update_response.data.name == updated_name
28+
29+
first_domain = f"{unique_name('allowed')}.example"
30+
second_domain = f"{unique_name('blocked')}.example"
31+
add_items_response = e2e_client.lists.add_items(
32+
created_list.id, {"items": [first_domain, second_domain]}
33+
)
34+
assert add_items_response.data.id == created_list.id
35+
36+
list_items_response = e2e_client.lists.list_items(
37+
created_list.id, query_params={"limit": 200}
38+
)
39+
item_values = {item.value for item in list_items_response.data if item.value}
40+
assert first_domain in item_values
41+
assert second_domain in item_values
42+
43+
remove_items_response = e2e_client.lists.remove_items(
44+
created_list.id, {"items": [first_domain]}
45+
)
46+
assert remove_items_response.data.id == created_list.id
47+
48+
after_remove_response = e2e_client.lists.list_items(
49+
created_list.id, query_params={"limit": 200}
50+
)
51+
item_values_after_remove = {
52+
item.value for item in after_remove_response.data if item.value
53+
}
54+
assert first_domain not in item_values_after_remove
55+
assert second_domain in item_values_after_remove
56+
57+
destroy_response = e2e_client.lists.destroy(created_list.id)
58+
assert destroy_response.request_id
59+
e2e_resource_registry["lists"].remove(created_list.id)
60+

tests/e2e/test_policies_e2e.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import pytest
2+
3+
4+
@pytest.mark.e2e
5+
def test_policies_lifecycle_with_rule_association_e2e(
6+
e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id
7+
):
8+
rule_response = e2e_client.rules.create(
9+
{
10+
"name": unique_name("e2e-policy-rule"),
11+
"trigger": "inbound",
12+
"match": {
13+
"operator": "any",
14+
"conditions": [
15+
{
16+
"field": "from.domain",
17+
"operator": "is",
18+
"value": "example.com",
19+
}
20+
],
21+
},
22+
"actions": [{"type": "archive"}],
23+
}
24+
)
25+
created_rule = rule_response.data
26+
assert created_rule.id
27+
e2e_resource_registry["rules"].append(created_rule.id)
28+
29+
policy_response = e2e_client.policies.create(
30+
{"name": unique_name("e2e-policy"), "rules": [created_rule.id]}
31+
)
32+
created_policy = policy_response.data
33+
assert created_policy.id
34+
e2e_resource_registry["policies"].append(created_policy.id)
35+
36+
find_response = e2e_client.policies.find(created_policy.id)
37+
assert find_response.data.id == created_policy.id
38+
39+
updated_name = unique_name("e2e-policy-updated")
40+
update_response = e2e_client.policies.update(
41+
created_policy.id,
42+
{
43+
"name": updated_name,
44+
"rules": [created_rule.id],
45+
"spam_detection": {
46+
"use_list_dnsbl": True,
47+
"use_header_anomaly_detection": True,
48+
},
49+
},
50+
)
51+
# Some policy update responses may omit id; verify canonical state by refetching.
52+
assert update_response.data.name == updated_name
53+
54+
refetch_response = e2e_client.policies.find(created_policy.id)
55+
assert refetch_response.data.id == created_policy.id
56+
assert refetch_response.data.name == updated_name
57+
assert refetch_response.data.rules is not None
58+
assert created_rule.id in refetch_response.data.rules
59+
60+
assert paginated_list_contains_id(e2e_client.policies.list, created_policy.id)
61+
62+
destroy_policy_response = e2e_client.policies.destroy(created_policy.id)
63+
assert destroy_policy_response.request_id
64+
e2e_resource_registry["policies"].remove(created_policy.id)
65+
66+
destroy_rule_response = e2e_client.rules.destroy(created_rule.id)
67+
assert destroy_rule_response.request_id
68+
e2e_resource_registry["rules"].remove(created_rule.id)
69+

tests/e2e/test_rules_e2e.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import pytest
2+
3+
4+
@pytest.mark.e2e
5+
def test_rules_lifecycle_e2e(
6+
e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id
7+
):
8+
create_response = e2e_client.rules.create(
9+
{
10+
"name": unique_name("e2e-rule"),
11+
"description": "Created by SDK e2e test",
12+
"trigger": "inbound",
13+
"match": {
14+
"operator": "any",
15+
"conditions": [
16+
{
17+
"field": "from.domain",
18+
"operator": "is",
19+
"value": "example.com",
20+
}
21+
],
22+
},
23+
"actions": [{"type": "archive"}],
24+
}
25+
)
26+
created_rule = create_response.data
27+
assert created_rule.id
28+
e2e_resource_registry["rules"].append(created_rule.id)
29+
30+
find_response = e2e_client.rules.find(created_rule.id)
31+
assert find_response.data.id == created_rule.id
32+
33+
updated_name = unique_name("e2e-rule-updated")
34+
update_response = e2e_client.rules.update(
35+
created_rule.id,
36+
{
37+
"name": updated_name,
38+
"enabled": False,
39+
"actions": [{"type": "mark_as_spam"}],
40+
},
41+
)
42+
assert update_response.data.id == created_rule.id
43+
assert update_response.data.name == updated_name
44+
45+
assert paginated_list_contains_id(e2e_client.rules.list, created_rule.id)
46+
47+
destroy_response = e2e_client.rules.destroy(created_rule.id)
48+
assert destroy_response.request_id
49+
e2e_resource_registry["rules"].remove(created_rule.id)
50+

tests/test_response.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from nylas.models.response import ListResponse
2+
from nylas.models.rules import Rule
3+
4+
5+
class TestListResponse:
6+
def test_from_dict_with_list_data(self):
7+
response = {
8+
"request_id": "req-123",
9+
"data": [{"id": "rule-1", "name": "Rule One"}],
10+
"next_cursor": "cursor-1",
11+
}
12+
13+
parsed = ListResponse.from_dict(response, Rule)
14+
15+
assert parsed.request_id == "req-123"
16+
assert parsed.next_cursor == "cursor-1"
17+
assert len(parsed.data) == 1
18+
assert parsed.data[0].id == "rule-1"
19+
20+
def test_from_dict_with_items_wrapper(self):
21+
response = {
22+
"request_id": "req-456",
23+
"data": {
24+
"items": [{"id": "rule-2", "name": "Rule Two"}],
25+
"next_cursor": "cursor-2",
26+
},
27+
}
28+
29+
parsed = ListResponse.from_dict(response, Rule)
30+
31+
assert parsed.request_id == "req-456"
32+
assert parsed.next_cursor == "cursor-2"
33+
assert len(parsed.data) == 1
34+
assert parsed.data[0].id == "rule-2"

0 commit comments

Comments
 (0)