| 4 |
4 |
import pytest |
| 5 |
5 |
from starlette.websockets import WebSocketDisconnect |
| 6 |
6 |
|
| 7 |
|
−KEY = "hfmd_live_" + "a" * 32 |
| 8 |
|
−KEY2 = "hfmd_live_" + "b" * 32 |
|
7 |
+ |
|
8 |
+ |
|
9 |
+@pytest.fixture |
|
10 |
+def keys(make_user): |
|
11 |
+ """Two real high_usage API keys (the rate-limit middleware validates keys before the stream handler).""" |
|
12 |
+ _, k1, _, _ = make_user(tier="high_usage") |
|
13 |
+ _, k2, _, _ = make_user(tier="high_usage") |
|
14 |
+ return k1, k2 |
| 9 |
15 |
|
| 10 |
16 |
|
| 11 |
17 |
@pytest.fixture(autouse=True) |
| 25 |
31 |
|
| 26 |
32 |
|
| 27 |
33 |
def test_bad_key_is_refused(client): |
| 28 |
|
− with client.websocket_connect("/v1/stream", headers={"Authorization": "Bearer not-a-key"}) as ws: |
| 29 |
|
− assert ws.receive_json()["code"] == "AUTH_REQUIRED" |
|
34 |
+ try: |
|
35 |
+ with client.websocket_connect("/v1/stream", headers={"Authorization": "Bearer not-a-key"}) as ws: |
|
36 |
+ msg = ws.receive_json() |
|
37 |
+ assert msg["code"] in ("AUTH_REQUIRED", "INVALID_API_KEY") |
|
38 |
+ except WebSocketDisconnect as e: # refused by the rate-limit middleware before the handler |
|
39 |
+ assert e.code in (4401, 4001, 1008) |
| 30 |
40 |
|
| 31 |
41 |
|
| 32 |
|
−def test_subscribe_receive_and_accounting(client, fundamentals_data): |
|
42 |
+def test_subscribe_receive_and_accounting(keys, client, fundamentals_data): |
| 33 |
43 |
from stream import accounting, broker |
| 34 |
44 |
from stream.auth import authenticate |
| 35 |
|
− principal = authenticate(KEY) |
|
45 |
+ principal = authenticate(keys[0]) |
| 36 |
46 |
before = accounting.rows_charged(principal) |
| 37 |
|
− with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: |
|
47 |
+ with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws: |
| 38 |
48 |
hello = ws.receive_json() |
| 39 |
49 |
assert hello["type"] == "hello" and hello["heartbeat_seconds"] == 20 |
| 40 |
50 |
ws.send_json({"action": "subscribe", "channel": "filings", "tickers": ["AAPL"], "forms": ["10-Q", "10-K"]}) |
| 53 |
63 |
assert accounting.rows_charged(principal) == before + 1 |
| 54 |
64 |
|
| 55 |
65 |
|
| 56 |
|
−def test_real_filing_event_from_ingest(client, fundamentals_data): |
|
66 |
+def test_real_filing_event_from_ingest(keys, client, fundamentals_data): |
| 57 |
67 |
from datetime import date |
| 58 |
68 |
|
| 59 |
69 |
from fundamentals import ingest |
| 60 |
|
− with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: |
|
70 |
+ with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws: |
| 61 |
71 |
ws.receive_json() |
| 62 |
72 |
ws.send_json({"action": "subscribe", "channel": "filings", "tickers": "all"}) |
| 63 |
73 |
ws.receive_json() |
| 73 |
83 |
assert s["yoy"]["revenue"] == pytest.approx(90_753 / 94_836 - 1, rel=1e-4) |
| 74 |
84 |
|
| 75 |
85 |
|
| 76 |
|
−def test_resume_token_replays_buffer(client): |
|
86 |
+def test_resume_token_replays_buffer(keys, client): |
| 77 |
87 |
from stream import broker |
| 78 |
88 |
s1 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r1"}) |
| 79 |
89 |
s2 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r2"}) |
| 80 |
90 |
s3 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r3"}) |
| 81 |
|
− with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: |
|
91 |
+ with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws: |
| 82 |
92 |
assert ws.receive_json()["seq"] == s3 |
| 83 |
93 |
ws.send_json({"action": "subscribe", "channel": "filings", "tickers": ["AAPL"], "resume_token": s1}) |
| 84 |
94 |
assert ws.receive_json()["resume_from"] == s1 |
| 85 |
95 |
assert [ws.receive_json()["accn"] for _ in range(2)] == ["r2", "r3"] |
| 86 |
96 |
assert s2 < s3 |
| 87 |
|
− with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: # without resume: only new events |
|
97 |
+ with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws: # without resume: only new events |
| 88 |
98 |
ws.receive_json() |
| 89 |
99 |
ws.send_json({"action": "subscribe"}) |
| 90 |
100 |
ws.receive_json() |
| 92 |
102 |
assert ws.receive_json()["accn"] == "r4" |
| 93 |
103 |
|
| 94 |
104 |
|
| 95 |
|
−def test_protocol_errors(client): |
| 96 |
|
− with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: |
|
105 |
+def test_protocol_errors(keys, client): |
|
106 |
+ with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws: |
| 97 |
107 |
ws.receive_json() |
| 98 |
108 |
ws.send_text("not json") |
| 99 |
109 |
assert ws.receive_json()["code"] == "VALIDATION_ERROR" |
| 107 |
117 |
assert ws.receive_json()["code"] == "VALIDATION_ERROR" |
| 108 |
118 |
|
| 109 |
119 |
|
| 110 |
|
−def test_heartbeat(client, monkeypatch): |
|
120 |
+def test_heartbeat(keys, client, monkeypatch): |
| 111 |
121 |
from stream import routes |
| 112 |
122 |
monkeypatch.setattr(routes, "HEARTBEAT_SECONDS", 0.3) |
| 113 |
|
− with client.websocket_connect(f"/v1/stream?api_key={KEY2}") as ws: |
|
123 |
+ with client.websocket_connect(f"/v1/stream?api_key={keys[1]}") as ws: |
| 114 |
124 |
ws.receive_json() |
| 115 |
125 |
beat = ws.receive_json() |
| 116 |
126 |
assert beat["type"] == "heartbeat" and "ts" in beat and "seq" in beat |
| 117 |
127 |
|
| 118 |
128 |
|
| 119 |
|
−def test_connection_limit_per_key(client): |
|
129 |
+def test_connection_limit_per_key(keys, client): |
| 120 |
130 |
from contextlib import ExitStack |
| 121 |
131 |
|
| 122 |
132 |
from stream import routes |
| 123 |
133 |
with ExitStack() as stack: |
| 124 |
134 |
for _ in range(routes.MAX_CONNECTIONS_PER_KEY): |
| 125 |
|
− ws = stack.enter_context(client.websocket_connect(f"/v1/stream?api_key={KEY}")) |
|
135 |
+ ws = stack.enter_context(client.websocket_connect(f"/v1/stream?api_key={keys[0]}")) |
| 126 |
136 |
assert ws.receive_json()["type"] == "hello" |
| 127 |
|
− extra = stack.enter_context(client.websocket_connect(f"/v1/stream?api_key={KEY}")) |
|
137 |
+ extra = stack.enter_context(client.websocket_connect(f"/v1/stream?api_key={keys[0]}")) |
| 128 |
138 |
msg = extra.receive_json() |
| 129 |
139 |
assert msg["type"] == "error" and msg["code"] == "STREAM_CONNECTION_LIMIT" and msg["limit"] == 5 |
| 130 |
140 |
with pytest.raises(WebSocketDisconnect) as e: |
| 131 |
141 |
extra.receive_json() |
| 132 |
142 |
assert e.value.code == 4029 |
| 133 |
143 |
# slots are released on disconnect |
| 134 |
|
− with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: |
|
144 |
+ with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws: |
| 135 |
145 |
assert ws.receive_json()["type"] == "hello" |
| 136 |
146 |
|
| 137 |
147 |
|
| 146 |
156 |
accounting.set_charger(None) |
| 147 |
157 |
|
| 148 |
158 |
|
| 149 |
|
−def test_stream_info(client): |
| 150 |
|
− r = client.get("/v1/stream/info") |
|
159 |
+def test_stream_info(client_hu): |
|
160 |
+ r = client_hu.get("/v1/stream/info") |
| 151 |
161 |
assert r.status_code == 200 |
| 152 |
162 |
d = r.json()["data"] |
| 153 |
163 |
assert d["channels"] == ["filings"] and d["close_codes"]["4001"] == "AUTH_REQUIRED" and d["url"].startswith("wss://") |
| 154 |
164 |
|