Skip to content

Commit 1dea7be

Browse files
authored
Merge pull request #598 from seamapi/claude/ruby-python-sdk-testing-2tqetp
test: align test suite with SDK baseline, and honor the retries option
2 parents 4f9cc47 + 59a4e06 commit 1dea7be

13 files changed

Lines changed: 558 additions & 151 deletions

seam/client.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,18 @@ def __init__(
4747
retries: Optional[Retry] = DEFAULT_RETRIES,
4848
**kwargs
4949
):
50-
super().__init__(**kwargs)
50+
# niquests.Session mounts its adapters while initializing, so retries
51+
# must be passed through here. Assigning self.retries afterwards leaves
52+
# the mounted adapters on their default and the option has no effect.
53+
super().__init__(
54+
retries=DEFAULT_RETRIES if retries is None else retries, **kwargs
55+
)
5156

5257
self.base_url = base_url
5358

5459
headers = {**auth_headers, **kwargs.get("headers", {}), **SDK_HEADERS}
5560
self.headers.update(headers)
5661

57-
if retries:
58-
self.retries = retries
59-
6062
def request(self, method, url, *args, **kwargs):
6163
url = urljoin(self.base_url, url)
6264
response = super().request(method, url, *args, **kwargs)

test/api_key_test.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,42 @@
11
import pytest
2+
23
from seam import Seam
34
from seam.auth import SeamInvalidTokenError
45

56

6-
def test_seam_client_from_api_key_returns_instance_authorized_with_api_key(
7-
server,
8-
):
7+
def test_seam_from_api_key_returns_instance_authorized_with_api_key(server):
98
endpoint, seed = server
109
seam = Seam.from_api_key(seed["seam_apikey1_token"], endpoint=endpoint)
11-
devices = seam.devices.list()
1210

13-
assert len(devices) > 0
11+
device = seam.devices.get(device_id=seed["august_device_1"])
12+
13+
assert device.workspace_id == seed["seed_workspace_1"]
14+
assert device.device_id == seed["august_device_1"]
1415

1516

16-
def test_seam_client_constructor_returns_instance_authorized_with_api_key(
17-
server,
18-
):
17+
def test_seam_constructor_returns_instance_authorized_with_api_key(server):
1918
endpoint, seed = server
2019
seam = Seam(api_key=seed["seam_apikey1_token"], endpoint=endpoint)
21-
devices = seam.devices.list()
2220

23-
assert len(devices) > 0
21+
device = seam.devices.get(device_id=seed["august_device_1"])
22+
23+
assert device.workspace_id == seed["seed_workspace_1"]
24+
assert device.device_id == seed["august_device_1"]
2425

2526

26-
def test_seam_client_constructor_interprets_single_string_argument_as_api_key(server):
27-
_, seed = server
28-
seam = Seam(seed["seam_apikey1_token"])
27+
def test_seam_constructor_interprets_single_string_argument_as_api_key(server):
28+
endpoint, seed = server
29+
seam = Seam(seed["seam_apikey1_token"], endpoint=endpoint)
30+
31+
device = seam.devices.get(device_id=seed["august_device_1"])
2932

30-
assert seam is not None
33+
assert device.device_id == seed["august_device_1"]
3134

3235
with pytest.raises(SeamInvalidTokenError, match=r"api_key"):
3336
Seam("some-invalid-key-format")
3437

3538

36-
def test_seam_client_checks_api_key_format():
39+
def test_seam_checks_api_key_format():
3740
with pytest.raises(SeamInvalidTokenError, match=r"Unknown"):
3841
Seam.from_api_key("some-invalid-key-format")
3942

@@ -45,3 +48,6 @@ def test_seam_client_checks_api_key_format():
4548

4649
with pytest.raises(SeamInvalidTokenError, match=r"Access Token"):
4750
Seam.from_api_key("seam_at")
51+
52+
with pytest.raises(SeamInvalidTokenError, match=r"Publishable Key"):
53+
Seam.from_api_key("seam_pk_token")

test/client_test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from seam import Seam
2+
3+
4+
def test_seam_exposes_a_client_that_can_make_requests(seam: Seam, server):
5+
_, seed = server
6+
7+
response = seam.client.post(
8+
"/devices/get", json={"device_id": seed["august_device_1"]}
9+
)
10+
11+
assert response["device"]["workspace_id"] == seed["seed_workspace_1"]
12+
assert response["device"]["device_id"] == seed["august_device_1"]
13+
14+
15+
def test_seam_client_resolves_paths_against_the_endpoint(seam: Seam, server):
16+
endpoint, _ = server
17+
18+
assert seam.client.base_url == endpoint
19+
20+
21+
def test_seam_client_sets_auth_headers(server):
22+
endpoint, seed = server
23+
seam = Seam.from_api_key(seed["seam_apikey1_token"], endpoint=endpoint)
24+
25+
assert (
26+
seam.client.headers["authorization"] == f"Bearer {seed['seam_apikey1_token']}"
27+
)
28+
29+
30+
def test_seam_defaults_to_waiting_for_action_attempts(server):
31+
endpoint, seed = server
32+
seam = Seam.from_api_key(seed["seam_apikey1_token"], endpoint=endpoint)
33+
34+
assert seam.defaults["wait_for_action_attempt"] is True
35+
36+
37+
def test_seam_wait_for_action_attempt_default_can_be_overridden(server):
38+
endpoint, seed = server
39+
seam = Seam.from_api_key(
40+
seed["seam_apikey1_token"], endpoint=endpoint, wait_for_action_attempt=False
41+
)
42+
43+
assert seam.defaults["wait_for_action_attempt"] is False

test/conftest.py

Lines changed: 161 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,183 @@
1+
import json
2+
import os
13
import socket
2-
from urllib.parse import urljoin
3-
from urllib3.util import Retry
4-
import pytest
54
import subprocess
6-
import os
5+
import threading
6+
import time
77
from contextlib import contextmanager
8-
from niquests import Session
8+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9+
from pathlib import Path
10+
from urllib.error import URLError
11+
from urllib.request import urlopen
12+
13+
import pytest
14+
915
from seam import Seam
1016

17+
SERVER_STARTUP_TIMEOUT = 30
18+
SERVER_SHUTDOWN_TIMEOUT = 10
19+
HEALTH_POLL_INTERVAL = 0.05
1120

12-
@pytest.fixture(scope="function")
13-
def server():
14-
port = get_port()
15-
os.environ["PORT"] = str(port)
21+
REPO_ROOT = Path(__file__).resolve().parent.parent
22+
FAKE_SEAM_CONNECT_BIN = REPO_ROOT / "node_modules" / ".bin" / "fake-seam-connect"
1623

17-
with subprocess_popen(["npm", "run", "start"]):
18-
endpoint = f"http://localhost:{port}"
19-
seed = get_seed(endpoint)
20-
yield endpoint, seed
2124

25+
@pytest.fixture(name="server")
26+
def server_fixture():
27+
"""Run a fake Seam Connect server for the duration of a single test.
28+
29+
Yields the endpoint of the running server along with its seed, which holds
30+
the ids and tokens of the seeded records.
31+
"""
32+
33+
with fake_seam_connect() as server:
34+
yield server
35+
36+
37+
@pytest.fixture(name="seam")
38+
def seam_fixture(server):
39+
"""Return a Seam client authorized against a fake Seam Connect server."""
2240

23-
@pytest.fixture(scope="function")
24-
def seam(server):
2541
endpoint, seed = server
26-
seam = Seam(endpoint=endpoint, api_key=seed["seam_apikey1_token"])
2742

28-
yield seam
43+
return Seam(api_key=seed["seam_apikey1_token"], endpoint=endpoint)
44+
45+
46+
@pytest.fixture(name="recording_server")
47+
def recording_server_fixture():
48+
"""Return a factory for a server that records requests and replays responses.
49+
50+
Use this only to assert on what the SDK puts on the wire, or to drive
51+
responses the fake cannot produce. Prefer the fake for everything else.
52+
"""
53+
54+
return recording_server
55+
56+
57+
@contextmanager
58+
def recording_server(responses):
59+
"""Serve the given (status, body) responses, repeating the last one.
60+
61+
Yields the endpoint along with the list of requests received so far.
62+
"""
63+
64+
requests = []
65+
remaining = list(responses)
66+
67+
class Handler(BaseHTTPRequestHandler):
68+
protocol_version = "HTTP/1.1"
69+
70+
# pylint: disable-next=invalid-name
71+
def do_POST(self): # BaseHTTPRequestHandler dispatches on this name.
72+
content_length = int(self.headers.get("content-length", 0))
73+
raw_body = self.rfile.read(content_length)
74+
75+
requests.append(
76+
{
77+
"path": self.path,
78+
"headers": {k.lower(): v for k, v in self.headers.items()},
79+
"body": json.loads(raw_body) if raw_body else None,
80+
}
81+
)
2982

83+
status, payload = remaining.pop(0) if len(remaining) > 1 else remaining[0]
3084

31-
def get_port():
32-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
33-
s.bind(("", 0))
34-
return s.getsockname()[1]
85+
if isinstance(payload, str):
86+
content_type = "text/plain"
87+
body = payload.encode()
88+
else:
89+
content_type = "application/json"
90+
body = json.dumps(payload).encode()
91+
92+
self.send_response(status)
93+
self.send_header("content-type", content_type)
94+
self.send_header("content-length", str(len(body)))
95+
self.end_headers()
96+
self.wfile.write(body)
97+
98+
def log_message(self, *args):
99+
pass
100+
101+
server = ThreadingHTTPServer(("localhost", 0), Handler)
102+
thread = threading.Thread(target=server.serve_forever, daemon=True)
103+
thread.start()
104+
105+
try:
106+
yield f"http://localhost:{server.server_port}", requests
107+
finally:
108+
server.shutdown()
109+
server.server_close()
110+
thread.join(timeout=5)
35111

36112

37-
# Create a custom context manager to ensure the fake server subprocess is terminated correctly
38113
@contextmanager
39-
def subprocess_popen(*args):
40-
process = subprocess.Popen(*args)
114+
def fake_seam_connect():
115+
if not FAKE_SEAM_CONNECT_BIN.exists():
116+
raise RuntimeError(
117+
f"Could not find {FAKE_SEAM_CONNECT_BIN}, run npm install before the tests."
118+
)
119+
120+
port = get_unused_port()
121+
endpoint = f"http://localhost:{port}"
122+
123+
process = subprocess.Popen(
124+
[str(FAKE_SEAM_CONNECT_BIN), "--seed"],
125+
cwd=REPO_ROOT,
126+
env={**os.environ, "PORT": str(port)},
127+
stdout=subprocess.DEVNULL,
128+
stderr=subprocess.DEVNULL,
129+
)
130+
41131
try:
42-
yield process
132+
wait_for_health(endpoint, process)
133+
yield endpoint, get_seed(endpoint)
43134
finally:
44-
process.terminate()
135+
stop_process(process)
136+
137+
138+
def get_unused_port():
139+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
140+
sock.bind(("", 0))
141+
return sock.getsockname()[1]
142+
143+
144+
def wait_for_health(endpoint, process):
145+
deadline = time.monotonic() + SERVER_STARTUP_TIMEOUT
146+
147+
while time.monotonic() < deadline:
148+
if process.poll() is not None:
149+
raise RuntimeError(
150+
f"Fake Seam Connect exited with code {process.returncode} "
151+
"before becoming healthy."
152+
)
153+
45154
try:
46-
process.wait(timeout=10)
47-
except subprocess.TimeoutExpired:
48-
process.kill()
155+
with urlopen(f"{endpoint}/health") as response:
156+
if response.status == 200:
157+
return
158+
except (URLError, OSError):
159+
pass
160+
161+
time.sleep(HEALTH_POLL_INTERVAL)
162+
163+
raise RuntimeError(
164+
f"Fake Seam Connect did not become healthy within {SERVER_STARTUP_TIMEOUT}s."
165+
)
49166

50167

51168
def get_seed(endpoint):
52-
retries = Retry(connect=5, total=None, backoff_factor=0.1)
53-
session = Session(retries=retries)
54-
seed_url = urljoin(endpoint, "/_fake/default_seed")
55-
return session.get(seed_url).json()
169+
with urlopen(f"{endpoint}/_fake/default_seed") as response:
170+
return json.load(response)
171+
172+
173+
def stop_process(process):
174+
if process.poll() is not None:
175+
return
176+
177+
process.terminate()
178+
179+
try:
180+
process.wait(timeout=SERVER_SHUTDOWN_TIMEOUT)
181+
except subprocess.TimeoutExpired:
182+
process.kill()
183+
process.wait()

0 commit comments

Comments
 (0)