|
| 1 | +import json |
| 2 | +import os |
1 | 3 | import socket |
2 | | -from urllib.parse import urljoin |
3 | | -from urllib3.util import Retry |
4 | | -import pytest |
5 | 4 | import subprocess |
6 | | -import os |
| 5 | +import threading |
| 6 | +import time |
7 | 7 | 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 | + |
9 | 15 | from seam import Seam |
10 | 16 |
|
| 17 | +SERVER_STARTUP_TIMEOUT = 30 |
| 18 | +SERVER_SHUTDOWN_TIMEOUT = 10 |
| 19 | +HEALTH_POLL_INTERVAL = 0.05 |
11 | 20 |
|
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" |
16 | 23 |
|
17 | | - with subprocess_popen(["npm", "run", "start"]): |
18 | | - endpoint = f"http://localhost:{port}" |
19 | | - seed = get_seed(endpoint) |
20 | | - yield endpoint, seed |
21 | 24 |
|
| 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.""" |
22 | 40 |
|
23 | | -@pytest.fixture(scope="function") |
24 | | -def seam(server): |
25 | 41 | endpoint, seed = server |
26 | | - seam = Seam(endpoint=endpoint, api_key=seed["seam_apikey1_token"]) |
27 | 42 |
|
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 | + ) |
29 | 82 |
|
| 83 | + status, payload = remaining.pop(0) if len(remaining) > 1 else remaining[0] |
30 | 84 |
|
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) |
35 | 111 |
|
36 | 112 |
|
37 | | -# Create a custom context manager to ensure the fake server subprocess is terminated correctly |
38 | 113 | @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 | + |
41 | 131 | try: |
42 | | - yield process |
| 132 | + wait_for_health(endpoint, process) |
| 133 | + yield endpoint, get_seed(endpoint) |
43 | 134 | 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 | + |
45 | 154 | 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 | + ) |
49 | 166 |
|
50 | 167 |
|
51 | 168 | 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