Skip to content

Commit 3d9d4c9

Browse files
TexasCodingclaude
andauthored
test(coverage): backfill WS edge cases and small auth/type/config branches (#134)
* test(coverage): backfill WS edge cases and small auth/type/config branches Closes the test-coverage gaps from the Wave 5 audit: - F-Q-09: pin KalshiAuth.from_key_path PermissionError and passphrase-protected key branches (both surface helpful KalshiAuthError messages). - F-Q-10: pin DollarDecimal / FixedPointCount TypeError fallback for unexpected input types (list, dict). Raw TypeError surfaces with the offending type name. - F-Q-12: pin KalshiClient.is_authenticated / AsyncKalshiClient.is_authenticated true/false branches with and without auth. - F-Q-13: replace flaky `await asyncio.sleep(0.2-0.3); assert` patterns in ws/test_client.py and ws/test_integration.py with deterministic `asyncio.Event` + `asyncio.wait_for(timeout=2.0)` waits. Callbacks signal completion; tests no longer race the event loop. - F-Q-15: cover two subs to the same channel (orderbook_delta) with different tickers - distinct server_sids, distinct iterators, message-to-sid routing. - F-Q-16: pin current `_join_tickers` behavior for non-string list elements (bool, int) - existing crash with `argument of type 'X' is not iterable` is captured so any future validation upgrade trips a test. - F-Q-18: cover ws/client.py:197-203 sentinel-broadcast on permanent reconnect failure. Force ws_max_retries=1 + reject_auth=True after first disconnect; assert the iterator exits via StopAsyncIteration instead of hanging. All changes are test-only. Net: +10 tests (1612 -> 1622). Wave 3's WS recv-loop, dispatcher, and orderbook are unchanged - this only adds tests around them. Closes #102 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review(#134): trim multi-paragraph test docstrings per CLAUDE.md §3 Per bot review on #134: - test_non_string_bool_element_raises_unhelpful_type_error: 6-line docstring -> 1-line inline comment. The WHY (pin crash path so a future fix trips this test) survives without the prose. - test_types.py module docstring: 9 lines -> 1 line. The Pydantic- BeforeValidator-doesn't-wrap note was redundant with the test names. Skipped: - test_non_string_int_element docstring is already one line; no change. - session._sub_mgr access + insertion-order assumption are flagged as informational by bot, not requested changes. uv run pytest tests/test_base_helpers.py tests/test_types.py -q: 22 passed. ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 47817d7 commit 3d9d4c9

7 files changed

Lines changed: 230 additions & 8 deletions

File tree

tests/test_async_client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,14 @@ def test_no_auth_constructs_unauthenticated(self) -> None:
371371
client = AsyncKalshiClient()
372372
assert client._auth is None
373373

374+
def test_is_authenticated_true_with_auth(self, test_auth: KalshiAuth) -> None:
375+
client = AsyncKalshiClient(auth=test_auth)
376+
assert client.is_authenticated is True
377+
378+
def test_is_authenticated_false_without_auth(self) -> None:
379+
client = AsyncKalshiClient()
380+
assert client.is_authenticated is False
381+
374382
def test_demo_flag(self, test_auth: KalshiAuth) -> None:
375383
client = AsyncKalshiClient(auth=test_auth, demo=True)
376384
assert client._config.base_url == DEMO_BASE_URL

tests/test_auth.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,47 @@ def test_invalid_pem_content(self) -> None:
251251
KalshiAuth.from_key_path("my-key", f.name)
252252
os.unlink(f.name)
253253

254+
@pytest.mark.skipif(
255+
os.geteuid() == 0, reason="root bypasses file permission checks"
256+
)
257+
def test_permission_denied_wraps_with_helpful_message(
258+
self, pem_bytes: bytes
259+
) -> None:
260+
"""A key file the user can't read raises KalshiAuthError, not PermissionError."""
261+
with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f:
262+
f.write(pem_bytes)
263+
f.flush()
264+
path = f.name
265+
try:
266+
os.chmod(path, 0o000)
267+
with pytest.raises(KalshiAuthError, match="Permission denied"):
268+
KalshiAuth.from_key_path("my-key", path)
269+
finally:
270+
os.chmod(path, 0o600)
271+
os.unlink(path)
272+
273+
def test_passphrase_protected_key_raises_helpful_error(
274+
self, rsa_private_key: rsa.RSAPrivateKey
275+
) -> None:
276+
"""Encrypted keys produce a KalshiAuthError pointing at the openssl fix."""
277+
encrypted_pem = rsa_private_key.private_bytes(
278+
encoding=serialization.Encoding.PEM,
279+
format=serialization.PrivateFormat.PKCS8,
280+
encryption_algorithm=serialization.BestAvailableEncryption(b"pw"),
281+
)
282+
with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f:
283+
f.write(encrypted_pem)
284+
f.flush()
285+
path = f.name
286+
try:
287+
with pytest.raises(KalshiAuthError) as exc_info:
288+
KalshiAuth.from_key_path("my-key", path)
289+
msg = str(exc_info.value)
290+
assert "Passphrase-protected" in msg
291+
assert "openssl pkey" in msg
292+
finally:
293+
os.unlink(path)
294+
254295

255296
class TestFromPem:
256297
def test_accepts_bytes(self, pem_bytes: bytes) -> None:

tests/test_base_helpers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ def test_happy_path_still_works(self) -> None:
4848
assert _join_tickers(()) is None
4949
assert _join_tickers("") is None
5050

51+
def test_non_string_bool_element_raises_unhelpful_type_error(self) -> None:
52+
# Pins crash path: bool fails `"," in elem` check; update if validation is added.
53+
with pytest.raises(TypeError, match=r"argument of type 'bool' is not iterable"):
54+
_join_tickers([True, "A"]) # type: ignore[list-item]
55+
56+
def test_non_string_int_element_raises_unhelpful_type_error(self) -> None:
57+
"""Mirror of the bool case: int element crashes the same way."""
58+
with pytest.raises(TypeError, match=r"argument of type 'int' is not iterable"):
59+
_join_tickers((1, "A")) # type: ignore[arg-type]
60+
5161

5262
class TestSyncListNullItemsCoercion:
5363
@respx.mock

tests/test_client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,16 @@ def test_no_auth_constructs_unauthenticated(self) -> None:
388388
assert client._auth is None
389389
client.close()
390390

391+
def test_is_authenticated_true_with_auth(self, test_auth: KalshiAuth) -> None:
392+
client = KalshiClient(auth=test_auth)
393+
assert client.is_authenticated is True
394+
client.close()
395+
396+
def test_is_authenticated_false_without_auth(self) -> None:
397+
client = KalshiClient()
398+
assert client.is_authenticated is False
399+
client.close()
400+
391401
def test_demo_flag(self, test_auth: KalshiAuth) -> None:
392402
client = KalshiClient(auth=test_auth, demo=True)
393403
assert client._config.base_url == DEMO_BASE_URL

tests/test_types.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Tests for DollarDecimal / FixedPointCount type-fallback branches in kalshi.types."""
2+
from __future__ import annotations
3+
4+
import pytest
5+
from pydantic import BaseModel
6+
7+
from kalshi.types import DollarDecimal, FixedPointCount
8+
9+
10+
class _DollarModel(BaseModel):
11+
x: DollarDecimal
12+
13+
14+
class _CountModel(BaseModel):
15+
x: FixedPointCount
16+
17+
18+
class TestDollarDecimalTypeFallback:
19+
def test_list_input_raises_type_error_with_named_type(self) -> None:
20+
with pytest.raises(TypeError, match="Cannot convert list to Decimal"):
21+
_DollarModel.model_validate({"x": [1, 2]})
22+
23+
def test_dict_input_raises_type_error_with_named_type(self) -> None:
24+
with pytest.raises(TypeError, match="Cannot convert dict to Decimal"):
25+
_DollarModel.model_validate({"x": {"nested": "value"}})
26+
27+
28+
class TestFixedPointCountTypeFallback:
29+
def test_list_input_raises_type_error_with_named_type(self) -> None:
30+
with pytest.raises(TypeError, match="Cannot convert list to Decimal"):
31+
_CountModel.model_validate({"x": [1, 2]})
32+
33+
def test_dict_input_raises_type_error_with_named_type(self) -> None:
34+
with pytest.raises(TypeError, match="Cannot convert dict to Decimal"):
35+
_CountModel.model_validate({"x": {"nested": "value"}})

tests/ws/test_client.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,19 +197,21 @@ async def test_on_decorator_registers(self, fake_ws, test_auth) -> None: # type
197197
ws = KalshiWebSocket(auth=test_auth, config=config)
198198
async with ws.connect() as session:
199199
received: list[object] = []
200+
got_one = asyncio.Event()
200201

201202
@session.on("fill")
202203
async def on_fill(msg: object) -> None:
203204
received.append(msg)
205+
got_one.set()
204206

205207
await session.subscribe_fill()
206208
await fake_ws.send_to_all({
207209
"type": "fill", "sid": 1,
208210
"msg": {"trade_id": "t1", "order_id": "o1"},
209211
})
210212

211-
# Give recv_loop time to dispatch
212-
await asyncio.sleep(0.2)
213+
# Deterministic wait: the callback signals us; we don't sleep blindly.
214+
await asyncio.wait_for(got_one.wait(), timeout=2.0)
213215
assert len(received) == 1
214216

215217

@@ -241,6 +243,52 @@ async def test_two_channels_on_same_connection(self, fake_ws, test_auth) -> None
241243
assert ticker_msg.msg.market_ticker == "T1"
242244
assert fill_msg.msg.trade_id == "t1"
243245

246+
async def test_two_subs_same_channel_distinct_params(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def]
247+
"""Two subs to the same channel (orderbook_delta) with different tickers.
248+
249+
Pins that the SDK treats each ``subscribe`` call as an independent
250+
subscription with its own ``server_sid`` and its own iterator queue —
251+
even when channel name collides. Messages with sid=A go only to the
252+
first iterator; sid=B only to the second.
253+
"""
254+
config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0)
255+
ws = KalshiWebSocket(auth=test_auth, config=config)
256+
async with ws.connect() as session:
257+
stream_a = await session.subscribe_orderbook_delta(tickers=["T1"])
258+
stream_b = await session.subscribe_orderbook_delta(tickers=["T2"])
259+
260+
# Two distinct subscribes -> two distinct server sids
261+
assert session._sub_mgr is not None
262+
sids = [
263+
sub.server_sid
264+
for sub in session._sub_mgr.active_subscriptions.values()
265+
]
266+
assert len(sids) == 2
267+
assert sids[0] != sids[1]
268+
sid_a, sid_b = sids[0], sids[1]
269+
assert sid_a is not None and sid_b is not None
270+
271+
# Push one message to each sid
272+
await fake_ws.send_to_all({
273+
"type": "orderbook_snapshot", "sid": sid_a, "seq": 1,
274+
"msg": {
275+
"market_ticker": "T1", "market_id": "x",
276+
"yes": [["0.50", "100"]], "no": [],
277+
},
278+
})
279+
await fake_ws.send_to_all({
280+
"type": "orderbook_snapshot", "sid": sid_b, "seq": 1,
281+
"msg": {
282+
"market_ticker": "T2", "market_id": "y",
283+
"yes": [["0.60", "200"]], "no": [],
284+
},
285+
})
286+
287+
msg_a = await asyncio.wait_for(stream_a.__anext__(), timeout=2.0)
288+
msg_b = await asyncio.wait_for(stream_b.__anext__(), timeout=2.0)
289+
assert msg_a.msg.market_ticker == "T1"
290+
assert msg_b.msg.market_ticker == "T2"
291+
244292

245293
# ---------------------------------------------------------------------------
246294
# run_forever
@@ -279,9 +327,11 @@ async def test_run_forever_returns_immediately_without_subscribe(
279327
class TestErrorCallback:
280328
async def test_on_error_called(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def]
281329
errors: list[object] = []
330+
got_one = asyncio.Event()
282331

283332
async def on_err(err: object) -> None:
284333
errors.append(err)
334+
got_one.set()
285335

286336
config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0)
287337
ws = KalshiWebSocket(auth=test_auth, config=config, on_error=on_err)
@@ -294,5 +344,6 @@ async def on_err(err: object) -> None:
294344
"type": "error",
295345
"msg": {"code": 400, "msg": "bad request"},
296346
})
297-
await asyncio.sleep(0.2)
347+
# Deterministic wait: the callback signals us; we don't sleep blindly.
348+
await asyncio.wait_for(got_one.wait(), timeout=2.0)
298349
assert len(errors) == 1

tests/ws/test_integration.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,66 @@ async def test_reconnect_after_server_disconnect(
333333
msg2 = await asyncio.wait_for(stream.__anext__(), timeout=3.0)
334334
assert msg2.msg.yes_bid == 75
335335

336+
async def test_reconnect_failure_broadcasts_sentinel_to_iterators(
337+
self,
338+
fake_ws: FakeKalshiWS,
339+
test_auth: KalshiAuth,
340+
) -> None:
341+
"""Recv loop must broadcast a sentinel when reconnect permanently fails.
342+
343+
Covers ``ws/client.py:197-203``: when ``_connection.reconnect()`` raises,
344+
iterators must receive a sentinel so ``async for`` exits cleanly via
345+
``StopAsyncIteration`` instead of hanging forever.
346+
347+
Flow: connect -> subscribe -> server forces disconnect after one
348+
message -> fake server rejects all reconnect attempts -> recv loop
349+
catches the reconnect exception -> sentinel broadcast -> iterator
350+
exits cleanly.
351+
"""
352+
# Fast retries, single attempt so the test doesn't drag.
353+
config = KalshiConfig(
354+
ws_base_url=fake_ws.url,
355+
timeout=2.0,
356+
retry_base_delay=0.01,
357+
retry_max_delay=0.05,
358+
ws_max_retries=1,
359+
)
360+
# Disconnect after the first broadcast; then make all reconnects fail.
361+
fake_ws.disconnect_after = 1
362+
363+
ws = KalshiWebSocket(auth=test_auth, config=config)
364+
async with ws.connect() as session:
365+
stream = await session.subscribe_ticker(tickers=["T1"])
366+
367+
# Reject any subsequent handshake -> reconnect() will exhaust
368+
# its single retry and raise KalshiConnectionError.
369+
fake_ws.reject_auth = True
370+
371+
# First message arrives, then the server closes the connection.
372+
await fake_ws.send_to_all({
373+
"type": "ticker", "sid": 1,
374+
"msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 42},
375+
})
376+
377+
# We should get the first message via the iterator,
378+
# then the sentinel — async-for exits with no further items.
379+
received: list[int] = []
380+
try:
381+
async with asyncio.timeout(5.0):
382+
async for msg in stream:
383+
received.append(msg.msg.yes_bid)
384+
except TimeoutError:
385+
pytest.fail(
386+
"Iterator hung after reconnect failure; sentinel was "
387+
"not broadcast — iterators would never exit."
388+
)
389+
390+
# The pre-disconnect message may or may not have been queued
391+
# depending on dispatch timing relative to the disconnect.
392+
# The contract under test is that iteration *terminates*, not
393+
# what's in the buffer first.
394+
assert received == [42] or received == []
395+
336396

337397
# ---------------------------------------------------------------------------
338398
# 5. Sequence gap detection
@@ -348,9 +408,11 @@ async def test_gap_detection_fires_callback(
348408
) -> None:
349409
"""Sequence gap: server sends seq 1, 2, 5 (skipping 3, 4)."""
350410
gaps: list[SequenceGap] = []
411+
got_gap = asyncio.Event()
351412

352413
async def on_gap(gap: SequenceGap) -> None:
353414
gaps.append(gap)
415+
got_gap.set()
354416

355417
ws = KalshiWebSocket(auth=test_auth, config=ws_config)
356418
async with ws.connect() as session:
@@ -404,8 +466,8 @@ async def on_gap(gap: SequenceGap) -> None:
404466
})
405467

406468
# Gap message is NOT dispatched (skipped by recv loop).
407-
# Give recv loop time to invoke the gap callback.
408-
await asyncio.sleep(0.3)
469+
# Deterministic wait: gap handler signals us; no blind sleep.
470+
await asyncio.wait_for(got_gap.wait(), timeout=2.0)
409471

410472
assert len(gaps) == 1
411473
assert gaps[0].sid == 1
@@ -428,12 +490,14 @@ async def test_callback_and_iterator_coexist(
428490
"""Iterator for ticker, callback for fill -- both receive messages."""
429491
ws = KalshiWebSocket(auth=test_auth, config=ws_config)
430492
callback_received: list[object] = []
493+
got_fill = asyncio.Event()
431494

432495
async with ws.connect() as session:
433496
# Register callback for fill BEFORE subscribing
434497
@session.on("fill")
435498
async def on_fill(msg: object) -> None:
436499
callback_received.append(msg)
500+
got_fill.set()
437501

438502
# Subscribe to ticker via iterator
439503
ticker_stream = await session.subscribe_ticker(tickers=["T1"])
@@ -460,8 +524,8 @@ async def on_fill(msg: object) -> None:
460524
assert ticker_msg.msg.market_ticker == "T1"
461525
assert ticker_msg.msg.yes_bid == 55
462526

463-
# Wait for callback to fire
464-
await asyncio.sleep(0.3)
527+
# Wait for callback to fire (signaled by the callback itself).
528+
await asyncio.wait_for(got_fill.wait(), timeout=2.0)
465529
assert len(callback_received) == 1
466530

467531

@@ -533,9 +597,11 @@ async def test_error_message_triggers_callback(
533597
) -> None:
534598
"""Server error message fires the on_error callback."""
535599
errors: list[ErrorMessage] = []
600+
got_error = asyncio.Event()
536601

537602
async def on_error(err: ErrorMessage) -> None:
538603
errors.append(err)
604+
got_error.set()
539605

540606
ws = KalshiWebSocket(
541607
auth=test_auth, config=ws_config, on_error=on_error,
@@ -550,7 +616,8 @@ async def on_error(err: ErrorMessage) -> None:
550616
"msg": {"code": 5, "msg": "something went wrong"},
551617
})
552618

553-
await asyncio.sleep(0.3)
619+
# Deterministic wait: callback signals; no blind sleep.
620+
await asyncio.wait_for(got_error.wait(), timeout=2.0)
554621
assert len(errors) == 1
555622
assert errors[0].msg.code == 5
556623
assert errors[0].msg.msg == "something went wrong"

0 commit comments

Comments
 (0)