Skip to content

Commit 59851d2

Browse files
committed
chore: refine mock server fixtures
1 parent d77d3fa commit 59851d2

File tree

7 files changed

+275
-404
lines changed

7 files changed

+275
-404
lines changed

src/algokit_utils/accounts/kmd_account_manager.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,7 @@ def get_wallet_account(
8989
return None
9090

9191
wallet_id = wallet.id_
92-
wallet_handle = kmd_client.init_wallet_handle_token(
93-
InitWalletHandleTokenRequest(wallet_id, "")
94-
).wallet_handle_token
92+
wallet_handle = kmd_client.init_wallet_handle(InitWalletHandleTokenRequest(wallet_id, "")).wallet_handle_token
9593
addresses = kmd_client.list_keys_in_wallet(ListKeysRequest(wallet_handle)).addresses or []
9694

9795
matched_address = None
@@ -135,9 +133,7 @@ def get_or_create_wallet_account(self, name: str, fund_with: AlgoAmount | None =
135133
if not wallet:
136134
raise Exception(f"Error creating KMD wallet with name {name}")
137135
wallet_id = wallet.id_
138-
wallet_handle = kmd_client.init_wallet_handle_token(
139-
InitWalletHandleTokenRequest(wallet_id, "")
140-
).wallet_handle_token
136+
wallet_handle = kmd_client.init_wallet_handle(InitWalletHandleTokenRequest(wallet_id, "")).wallet_handle_token
141137
kmd_client.generate_key(GenerateKeyRequest(wallet_handle_token=wallet_handle))
142138

143139
account = self.get_wallet_account(name)

tests/modules/_mock_server.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Mock server infrastructure for algod/indexer/kmd client testing.
2+
3+
This module provides Docker-based mock servers that replay pre-recorded HAR files
4+
for deterministic API testing. Only used by algod_client, indexer_client, and
5+
kmd_client test modules.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import atexit
11+
import os
12+
import socket
13+
import subprocess
14+
import time
15+
from dataclasses import dataclass
16+
from pathlib import Path
17+
18+
# Port configuration
19+
MOCK_PORTS = {
20+
"algod": {"host": 18000, "container": 8000},
21+
"indexer": {"host": 18002, "container": 8002},
22+
"kmd": {"host": 18001, "container": 8001},
23+
}
24+
25+
DEFAULT_TOKEN = "a" * 64
26+
CONTAINER_PREFIX = "algokit_utils_py_mock"
27+
28+
# Track containers for cleanup
29+
_started_containers: set[str] = set()
30+
_is_xdist_worker = False
31+
32+
33+
def _cleanup_containers() -> None:
34+
"""Clean up containers on exit (skipped for xdist workers)."""
35+
if _is_xdist_worker:
36+
return
37+
for name in list(_started_containers):
38+
subprocess.run(["docker", "rm", "-f", name], capture_output=True, check=False)
39+
_started_containers.discard(name)
40+
41+
42+
atexit.register(_cleanup_containers)
43+
44+
45+
@dataclass
46+
class MockServer:
47+
"""A running mock server container."""
48+
49+
container_id: str
50+
name: str
51+
client_type: str
52+
port: int
53+
is_owner: bool
54+
55+
@property
56+
def base_url(self) -> str:
57+
return f"http://127.0.0.1:{self.port}"
58+
59+
def stop(self) -> None:
60+
"""Stop container if owned and not running under xdist."""
61+
if _is_xdist_worker or not self.is_owner:
62+
return
63+
if self.name in _started_containers:
64+
subprocess.run(["docker", "rm", "-f", self.container_id], capture_output=True, check=False)
65+
_started_containers.discard(self.name)
66+
67+
68+
def _wait_for_port(port: int, timeout: float = 30.0) -> bool:
69+
"""Wait for a port to become available."""
70+
start = time.time()
71+
while time.time() - start < timeout:
72+
try:
73+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
74+
s.settimeout(1)
75+
s.connect(("127.0.0.1", port))
76+
return True
77+
except OSError:
78+
time.sleep(0.1)
79+
return False
80+
81+
82+
def _get_container_id(name: str) -> str | None:
83+
"""Get container ID if running."""
84+
result = subprocess.run(
85+
["docker", "ps", "-q", "-f", f"name=^{name}$"],
86+
capture_output=True,
87+
text=True,
88+
check=False,
89+
)
90+
return result.stdout.strip() or None
91+
92+
93+
def _get_image() -> str:
94+
return os.environ.get("POLYTEST_MOCK_SERVER_IMAGE", "ghcr.io/aorumbayev/polytest-mock-server:latest")
95+
96+
97+
def _get_recordings_path() -> Path:
98+
if custom := os.environ.get("POLYTEST_RECORDINGS_PATH"):
99+
return Path(custom)
100+
return Path(__file__).parent.parent / "references" / "algokit-polytest" / "resources" / "mock-server" / "recordings"
101+
102+
103+
def start_mock_server(client_type: str) -> MockServer:
104+
"""Start or reuse a mock server for the given client type.
105+
106+
Thread-safe across pytest-xdist workers via file locking.
107+
"""
108+
global _is_xdist_worker # noqa: PLW0603
109+
110+
if os.environ.get("PYTEST_XDIST_WORKER"):
111+
_is_xdist_worker = True
112+
113+
ports = MOCK_PORTS[client_type]
114+
host_port, container_port = ports["host"], ports["container"]
115+
container_name = f"{CONTAINER_PREFIX}_{client_type}"
116+
117+
# Use file lock for xdist coordination
118+
from filelock import FileLock
119+
120+
with FileLock(f"/tmp/{container_name}.lock"):
121+
# Reuse existing container
122+
if existing_id := _get_container_id(container_name):
123+
if not _wait_for_port(host_port):
124+
raise TimeoutError(f"Existing {client_type} server not responding on port {host_port}")
125+
return MockServer(existing_id, container_name, client_type, host_port, is_owner=False)
126+
127+
# Remove any stopped container
128+
subprocess.run(["docker", "rm", "-f", container_name], capture_output=True, check=False)
129+
130+
# Build command
131+
cmd = [
132+
"docker",
133+
"run",
134+
"-d",
135+
"--name",
136+
container_name,
137+
"-p",
138+
f"{host_port}:{container_port}",
139+
"-e",
140+
f"{client_type.upper()}_PORT={container_port}",
141+
"-e",
142+
f"LOG_LEVEL={os.environ.get('MOCK_SERVER_LOG_LEVEL', 'warn')}",
143+
]
144+
145+
recordings = _get_recordings_path()
146+
image = _get_image()
147+
if recordings.exists():
148+
cmd.extend(["-v", f"{recordings}:/recordings:ro", image, client_type, "/recordings"])
149+
else:
150+
cmd.extend([image, client_type])
151+
152+
# Start container
153+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
154+
if result.returncode != 0:
155+
raise RuntimeError(f"Failed to start {client_type} mock server: {result.stderr}")
156+
157+
container_id = result.stdout.strip()
158+
_started_containers.add(container_name)
159+
160+
if not _wait_for_port(host_port):
161+
subprocess.run(["docker", "rm", "-f", container_id], capture_output=True, check=False)
162+
_started_containers.discard(container_name)
163+
raise TimeoutError(f"{client_type} mock server failed to start")
164+
165+
time.sleep(2) # Wait for HAR loading
166+
167+
return MockServer(container_id, container_name, client_type, host_port, is_owner=True)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Fixtures for algod client tests using mock server."""
2+
3+
from collections.abc import Generator
4+
5+
import pytest
6+
7+
from algokit_algod_client import AlgodClient, ClientConfig
8+
9+
from tests.modules._mock_server import DEFAULT_TOKEN, MockServer, start_mock_server
10+
11+
12+
@pytest.fixture(scope="session")
13+
def mock_algod_server() -> Generator[MockServer, None, None]:
14+
"""Session-scoped mock algod server for deterministic testing."""
15+
server = start_mock_server("algod")
16+
try:
17+
yield server
18+
finally:
19+
server.stop()
20+
21+
22+
@pytest.fixture
23+
def algod_client(mock_algod_server: MockServer) -> AlgodClient:
24+
"""Algod client connected to the mock server."""
25+
return AlgodClient(ClientConfig(base_url=mock_algod_server.base_url, token=DEFAULT_TOKEN))

tests/modules/algod_client/manual/test_suggested_params.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
import pytest
33

44
from algokit_algod_client import AlgodClient, ClientConfig
5+
from algokit_utils.algorand import AlgorandClient
56

67

7-
def test_get_suggested_params(algod_client: AlgodClient) -> None:
8+
@pytest.mark.localnet
9+
def test_get_suggested_params() -> None:
10+
"""Test suggested params using localnet."""
11+
algod_client = AlgorandClient.default_localnet().client.algod
812
params = algod_client.suggested_params()
913
assert isinstance(params.genesis_id, str)
1014
assert params.genesis_id
@@ -13,7 +17,7 @@ def test_get_suggested_params(algod_client: AlgodClient) -> None:
1317

1418

1519
def test_suggested_params_error_handling() -> None:
16-
# Invalid host should fail
20+
"""Test error handling for invalid host."""
1721
bad = AlgodClient(ClientConfig(base_url="http://invalid-host:4001", token="a" * 64))
1822
with pytest.raises(httpx.HTTPError):
1923
bad.suggested_params()

0 commit comments

Comments
 (0)