Skip to content

Commit b2337f5

Browse files
TexasCodingclaude
andauthored
test(config): cover trailing-slash stripping and extra_headers plumbing (#131)
Adds tests/test_config.py with 10 tests closing the Wave 5 audit gaps: - F-Q-05: __post_init__ rstrips trailing slashes on base_url and ws_base_url, multiple-slash defense, validation still passes after stripping, and an end-to-end signed GET against a trailing-slash base that asserts no // in the wire URL (regression for the auth-signing failure mode the post-init prevents). - F-Q-06: extra_headers reach the wire via respx, coexist with per-request KALSHI-ACCESS-* auth headers (no overwrite either direction), persist across multiple requests, and the default_factory dict is per-instance. Closes #99 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e534eb4 commit b2337f5

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

tests/test_config.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Tests for KalshiConfig normalization and request-layer plumbing.
2+
3+
Covers two gaps flagged by Wave 5 audit (issue #99):
4+
5+
* F-Q-05 — trailing-slash stripping on ``base_url`` / ``ws_base_url`` plus
6+
end-to-end verification that signed requests don't end up with ``//`` paths.
7+
* F-Q-06 — ``extra_headers`` is forwarded to the httpx client and coexists
8+
with per-request auth headers (no overwrite either direction).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import httpx
14+
import respx
15+
16+
from kalshi._base_client import SyncTransport
17+
from kalshi.auth import KalshiAuth
18+
from kalshi.config import (
19+
DEMO_BASE_URL,
20+
DEMO_WS_URL,
21+
PRODUCTION_BASE_URL,
22+
PRODUCTION_WS_URL,
23+
KalshiConfig,
24+
)
25+
26+
27+
class TestTrailingSlashStripping:
28+
"""F-Q-05: __post_init__ rstrips trailing slashes on base_url and ws_base_url."""
29+
30+
def test_trailing_slash_stripped_on_base_url(self) -> None:
31+
config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/")
32+
assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2"
33+
34+
def test_trailing_slash_stripped_on_ws_base_url(self) -> None:
35+
config = KalshiConfig(ws_base_url="wss://demo-api.kalshi.co/trade-api/ws/v2/")
36+
assert config.ws_base_url == "wss://demo-api.kalshi.co/trade-api/ws/v2"
37+
38+
def test_multiple_trailing_slashes_all_stripped(self) -> None:
39+
# Defensive: rstrip("/") removes all of them, not just one.
40+
config = KalshiConfig(
41+
base_url="https://demo-api.kalshi.co/trade-api/v2///",
42+
ws_base_url="wss://demo-api.kalshi.co/trade-api/ws/v2///",
43+
)
44+
assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2"
45+
assert config.ws_base_url == "wss://demo-api.kalshi.co/trade-api/ws/v2"
46+
47+
def test_no_trailing_slash_left_unchanged(self) -> None:
48+
config = KalshiConfig(
49+
base_url=PRODUCTION_BASE_URL,
50+
ws_base_url=PRODUCTION_WS_URL,
51+
)
52+
assert config.base_url == PRODUCTION_BASE_URL
53+
assert config.ws_base_url == PRODUCTION_WS_URL
54+
55+
def test_demo_classmethod_url_has_no_trailing_slash(self) -> None:
56+
config = KalshiConfig.demo()
57+
assert not config.base_url.endswith("/")
58+
assert not config.ws_base_url.endswith("/")
59+
assert config.base_url == DEMO_BASE_URL
60+
assert config.ws_base_url == DEMO_WS_URL
61+
62+
def test_trailing_slash_base_url_still_passes_validation(self) -> None:
63+
# Regression: stripping happens before _validate_url, so a known-host URL
64+
# with a trailing slash must not trip the validator.
65+
config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/")
66+
assert config.base_url == DEMO_BASE_URL
67+
68+
69+
class TestTrailingSlashSignedRequest:
70+
"""F-Q-05: signed GET against a trailing-slash base must hit /v2/markets, not //markets."""
71+
72+
@respx.mock
73+
def test_signed_get_with_trailing_slash_base_no_double_slash(
74+
self, test_auth: KalshiAuth
75+
) -> None:
76+
config = KalshiConfig(
77+
base_url="https://demo-api.kalshi.co/trade-api/v2/",
78+
timeout=5.0,
79+
max_retries=0,
80+
)
81+
# Route asserts the URL has no // before "markets". If the trailing slash
82+
# had leaked through, httpx would emit /trade-api/v2//markets and miss this route.
83+
route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock(
84+
return_value=httpx.Response(200, json={"markets": []})
85+
)
86+
transport = SyncTransport(test_auth, config)
87+
try:
88+
resp = transport.request("GET", "/markets")
89+
assert resp.status_code == 200
90+
assert route.call_count == 1
91+
request = route.calls[0].request
92+
assert str(request.url) == "https://demo-api.kalshi.co/trade-api/v2/markets"
93+
assert "//markets" not in str(request.url)
94+
finally:
95+
transport.close()
96+
97+
98+
class TestExtraHeadersForwarding:
99+
"""F-Q-06: extra_headers reach the wire and coexist with auth headers."""
100+
101+
@respx.mock
102+
def test_extra_headers_forwarded_to_request(self, test_auth: KalshiAuth) -> None:
103+
config = KalshiConfig(
104+
base_url="https://demo-api.kalshi.co/trade-api/v2",
105+
timeout=5.0,
106+
max_retries=0,
107+
extra_headers={
108+
"X-Trace-Id": "trace-1",
109+
"User-Agent": "kalshi-sdk/test",
110+
},
111+
)
112+
route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock(
113+
return_value=httpx.Response(200, json={"markets": []})
114+
)
115+
transport = SyncTransport(test_auth, config)
116+
try:
117+
transport.request("GET", "/markets")
118+
request = route.calls[0].request
119+
# User extras land on the wire as-is.
120+
assert request.headers["X-Trace-Id"] == "trace-1"
121+
assert request.headers["User-Agent"] == "kalshi-sdk/test"
122+
# And the per-request auth headers still ride alongside them
123+
# (no overwrite either direction).
124+
assert "KALSHI-ACCESS-KEY" in request.headers
125+
assert "KALSHI-ACCESS-SIGNATURE" in request.headers
126+
assert "KALSHI-ACCESS-TIMESTAMP" in request.headers
127+
finally:
128+
transport.close()
129+
130+
@respx.mock
131+
def test_extra_headers_persist_across_multiple_requests(
132+
self, test_auth: KalshiAuth
133+
) -> None:
134+
# Regression: extras are set on httpx.Client(headers=...), so every request
135+
# carries them — not just the first one.
136+
config = KalshiConfig(
137+
base_url="https://demo-api.kalshi.co/trade-api/v2",
138+
timeout=5.0,
139+
max_retries=0,
140+
extra_headers={"X-Trace-Id": "trace-1"},
141+
)
142+
route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock(
143+
return_value=httpx.Response(200, json={"markets": []})
144+
)
145+
transport = SyncTransport(test_auth, config)
146+
try:
147+
transport.request("GET", "/markets")
148+
transport.request("GET", "/markets")
149+
assert route.call_count == 2
150+
for call in route.calls:
151+
assert call.request.headers["X-Trace-Id"] == "trace-1"
152+
finally:
153+
transport.close()
154+
155+
def test_extra_headers_defaults_to_empty_dict(self) -> None:
156+
# Each instance gets its own dict (default_factory), so mutating one
157+
# config's extras must not bleed into another's.
158+
config_a = KalshiConfig()
159+
config_b = KalshiConfig()
160+
assert config_a.extra_headers == {}
161+
assert config_b.extra_headers == {}
162+
config_a.extra_headers["X-Trace-Id"] = "a"
163+
assert config_b.extra_headers == {}

0 commit comments

Comments
 (0)