183 lines
5.4 KiB
Python
183 lines
5.4 KiB
Python
import json
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from web.app import app
|
|
from web.routers import sse_router
|
|
|
|
|
|
def _decode_sse_events(text: str):
|
|
events = []
|
|
for frame in text.strip().split("\n\n"):
|
|
if not frame.startswith("data: "):
|
|
continue
|
|
events.append(json.loads(frame[len("data: "):]))
|
|
return events
|
|
|
|
|
|
def test_events_endpoint_replays_only_requested_cities(monkeypatch):
|
|
captured = {}
|
|
|
|
class FakeStore:
|
|
def latest_revision(self):
|
|
return 44
|
|
|
|
def replay_events(self, *, cities, since_revision, limit):
|
|
captured["cities"] = cities
|
|
captured["since_revision"] = since_revision
|
|
captured["limit"] = limit
|
|
return [
|
|
{
|
|
"type": "city_observation_patch.v1",
|
|
"revision": 43,
|
|
"city": "taipei",
|
|
"source": "cwa",
|
|
"obs_time": "2026-05-26T08:15:00Z",
|
|
"ts": 1780000000000,
|
|
"payload": {"temp": 31.2},
|
|
}
|
|
]
|
|
|
|
def replay_requires_resync(self, *, cities, since_revision, replay_count, limit):
|
|
return False
|
|
|
|
async def finite_stream(
|
|
user_id,
|
|
*,
|
|
cities=None,
|
|
replay_events=None,
|
|
connected_revision=0,
|
|
resync_event=None,
|
|
):
|
|
yield sse_router.sse_manager._format_event(
|
|
{"type": "connected", "revision": connected_revision}
|
|
)
|
|
for event in replay_events or []:
|
|
yield sse_router.sse_manager._format_event(event)
|
|
|
|
monkeypatch.setattr(sse_router, "event_store", FakeStore())
|
|
monkeypatch.setattr(sse_router.sse_manager, "event_stream", finite_stream)
|
|
|
|
response = TestClient(app).get(
|
|
"/api/events?cities=taipei,hong%20kong&since_revision=42&replay_limit=25"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert captured == {
|
|
"cities": {"taipei", "hong kong"},
|
|
"since_revision": 42,
|
|
"limit": 25,
|
|
}
|
|
events = _decode_sse_events(response.text)
|
|
assert [event["type"] for event in events] == [
|
|
"connected",
|
|
"city_observation_patch.v1",
|
|
]
|
|
assert events[1]["city"] == "taipei"
|
|
|
|
|
|
def test_events_endpoint_emits_resync_when_replay_is_incomplete(monkeypatch):
|
|
class FakeStore:
|
|
def latest_revision(self):
|
|
return 99
|
|
|
|
def replay_events(self, *, cities, since_revision, limit):
|
|
return [
|
|
{
|
|
"type": "city_observation_patch.v1",
|
|
"revision": 98,
|
|
"city": "taipei",
|
|
"source": "cwa",
|
|
"obs_time": "2026-05-26T08:15:00Z",
|
|
"ts": 1780000000000,
|
|
"payload": {"temp": 31.2},
|
|
}
|
|
]
|
|
|
|
def replay_requires_resync(self, *, cities, since_revision, replay_count, limit):
|
|
return True
|
|
|
|
async def finite_stream(
|
|
user_id,
|
|
*,
|
|
cities=None,
|
|
replay_events=None,
|
|
connected_revision=0,
|
|
resync_event=None,
|
|
):
|
|
yield sse_router.sse_manager._format_event(
|
|
{"type": "connected", "revision": connected_revision}
|
|
)
|
|
for event in replay_events or []:
|
|
yield sse_router.sse_manager._format_event(event)
|
|
if resync_event:
|
|
yield sse_router.sse_manager._format_event(resync_event)
|
|
|
|
monkeypatch.setattr(sse_router, "event_store", FakeStore())
|
|
monkeypatch.setattr(sse_router.sse_manager, "event_stream", finite_stream)
|
|
|
|
response = TestClient(app).get(
|
|
"/api/events?cities=taipei&since_revision=1&replay_limit=1"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
events = _decode_sse_events(response.text)
|
|
assert events[-1]["type"] == "resync_required"
|
|
assert events[-1]["reason"] == "replay_window_exceeded"
|
|
assert events[-1]["latest_revision"] == 99
|
|
|
|
|
|
def test_replay_limit_is_bounded():
|
|
assert sse_router._bounded_replay_limit(0) == 1
|
|
assert sse_router._bounded_replay_limit(500) == 500
|
|
assert sse_router._bounded_replay_limit(5000) == 2000
|
|
|
|
|
|
def test_ingest_patch_uses_external_fanout_without_direct_broadcast(monkeypatch):
|
|
class FakeExternalStore:
|
|
uses_external_live_fanout = True
|
|
|
|
def __init__(self):
|
|
self.started = 0
|
|
|
|
def start_live_subscription(self, callback):
|
|
self.started += 1
|
|
self.callback = callback
|
|
|
|
def append_event(self, event):
|
|
return {
|
|
**event,
|
|
"revision": 12,
|
|
}
|
|
|
|
class FakeManager:
|
|
def __init__(self):
|
|
self.broadcasted = []
|
|
|
|
def broadcast_event(self, event):
|
|
self.broadcasted.append(event)
|
|
return event
|
|
|
|
store = FakeExternalStore()
|
|
manager = FakeManager()
|
|
monkeypatch.setattr(sse_router, "event_store", store)
|
|
monkeypatch.setattr(sse_router, "sse_manager", manager)
|
|
monkeypatch.setattr(sse_router, "_live_subscription_started", False)
|
|
|
|
response = TestClient(app).post(
|
|
"/api/internal/collector-patch",
|
|
json={
|
|
"city": "taipei",
|
|
"changes": {
|
|
"temp": 34.2,
|
|
"source": "cwa",
|
|
"obs_time": "2026-05-27T10:00:00+08:00",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["revision"] == 12
|
|
assert store.started == 1
|
|
assert manager.broadcasted == []
|