Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Tests for KalshiConfig normalization and request-layer plumbing.

Covers two gaps flagged by Wave 5 audit (issue #99):

* F-Q-05 — trailing-slash stripping on ``base_url`` / ``ws_base_url`` plus
end-to-end verification that signed requests don't end up with ``//`` paths.
* F-Q-06 — ``extra_headers`` is forwarded to the httpx client and coexists
with per-request auth headers (no overwrite either direction).
"""

from __future__ import annotations

import httpx
import respx

from kalshi._base_client import SyncTransport
from kalshi.auth import KalshiAuth
from kalshi.config import (
DEMO_BASE_URL,
DEMO_WS_URL,
PRODUCTION_BASE_URL,
PRODUCTION_WS_URL,
KalshiConfig,
)


class TestTrailingSlashStripping:
"""F-Q-05: __post_init__ rstrips trailing slashes on base_url and ws_base_url."""

def test_trailing_slash_stripped_on_base_url(self) -> None:
config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/")
assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2"

def test_trailing_slash_stripped_on_ws_base_url(self) -> None:
config = KalshiConfig(ws_base_url="wss://demo-api.kalshi.co/trade-api/ws/v2/")
assert config.ws_base_url == "wss://demo-api.kalshi.co/trade-api/ws/v2"

def test_multiple_trailing_slashes_all_stripped(self) -> None:
# Defensive: rstrip("/") removes all of them, not just one.
config = KalshiConfig(
base_url="https://demo-api.kalshi.co/trade-api/v2///",
ws_base_url="wss://demo-api.kalshi.co/trade-api/ws/v2///",
)
assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2"
assert config.ws_base_url == "wss://demo-api.kalshi.co/trade-api/ws/v2"

def test_no_trailing_slash_left_unchanged(self) -> None:
config = KalshiConfig(
base_url=PRODUCTION_BASE_URL,
ws_base_url=PRODUCTION_WS_URL,
)
assert config.base_url == PRODUCTION_BASE_URL
assert config.ws_base_url == PRODUCTION_WS_URL

def test_demo_classmethod_url_has_no_trailing_slash(self) -> None:
config = KalshiConfig.demo()
assert not config.base_url.endswith("/")
assert not config.ws_base_url.endswith("/")
assert config.base_url == DEMO_BASE_URL
assert config.ws_base_url == DEMO_WS_URL

def test_trailing_slash_base_url_still_passes_validation(self) -> None:
# Regression: stripping happens before _validate_url, so a known-host URL
# with a trailing slash must not trip the validator.
config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/")
assert config.base_url == DEMO_BASE_URL


class TestTrailingSlashSignedRequest:
"""F-Q-05: signed GET against a trailing-slash base must hit /v2/markets, not //markets."""

@respx.mock
def test_signed_get_with_trailing_slash_base_no_double_slash(
self, test_auth: KalshiAuth
) -> None:
config = KalshiConfig(
base_url="https://demo-api.kalshi.co/trade-api/v2/",
timeout=5.0,
max_retries=0,
)
# Route asserts the URL has no // before "markets". If the trailing slash
# had leaked through, httpx would emit /trade-api/v2//markets and miss this route.
route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock(
return_value=httpx.Response(200, json={"markets": []})
)
transport = SyncTransport(test_auth, config)
try:
resp = transport.request("GET", "/markets")
assert resp.status_code == 200
assert route.call_count == 1
request = route.calls[0].request
assert str(request.url) == "https://demo-api.kalshi.co/trade-api/v2/markets"
assert "//markets" not in str(request.url)
finally:
transport.close()


class TestExtraHeadersForwarding:
"""F-Q-06: extra_headers reach the wire and coexist with auth headers."""

@respx.mock
def test_extra_headers_forwarded_to_request(self, test_auth: KalshiAuth) -> None:
config = KalshiConfig(
base_url="https://demo-api.kalshi.co/trade-api/v2",
timeout=5.0,
max_retries=0,
extra_headers={
"X-Trace-Id": "trace-1",
"User-Agent": "kalshi-sdk/test",
},
)
route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock(
return_value=httpx.Response(200, json={"markets": []})
)
transport = SyncTransport(test_auth, config)
try:
transport.request("GET", "/markets")
request = route.calls[0].request
# User extras land on the wire as-is.
assert request.headers["X-Trace-Id"] == "trace-1"
assert request.headers["User-Agent"] == "kalshi-sdk/test"
# And the per-request auth headers still ride alongside them
# (no overwrite either direction).
assert "KALSHI-ACCESS-KEY" in request.headers
assert "KALSHI-ACCESS-SIGNATURE" in request.headers
assert "KALSHI-ACCESS-TIMESTAMP" in request.headers
finally:
transport.close()

@respx.mock
def test_extra_headers_persist_across_multiple_requests(
self, test_auth: KalshiAuth
) -> None:
# Regression: extras are set on httpx.Client(headers=...), so every request
# carries them — not just the first one.
config = KalshiConfig(
base_url="https://demo-api.kalshi.co/trade-api/v2",
timeout=5.0,
max_retries=0,
extra_headers={"X-Trace-Id": "trace-1"},
)
route = respx.get("https://demo-api.kalshi.co/trade-api/v2/markets").mock(
return_value=httpx.Response(200, json={"markets": []})
)
transport = SyncTransport(test_auth, config)
try:
transport.request("GET", "/markets")
transport.request("GET", "/markets")
assert route.call_count == 2
for call in route.calls:
assert call.request.headers["X-Trace-Id"] == "trace-1"
finally:
transport.close()

def test_extra_headers_defaults_to_empty_dict(self) -> None:
# Each instance gets its own dict (default_factory), so mutating one
# config's extras must not bleed into another's.
config_a = KalshiConfig()
config_b = KalshiConfig()
assert config_a.extra_headers == {}
assert config_b.extra_headers == {}
config_a.extra_headers["X-Trace-Id"] = "a"
assert config_b.extra_headers == {}
Loading