mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 14:16:05 +00:00
feat: async events on asgi
This commit is contained in:
1 parent
1f06f31aa3
commit
73ed1cf607
13 files changed
+1270
-1054
No files matched your search
@@ -1,14 +1,17 @@
|
||||
"""Integration tests for the end-to-end snapshot+tail handoff.
|
||||
|
||||
Exercises the publisher → journal → reconnect endpoint round-trip
|
||||
without mocking the journal layer, so a regression in any of:
|
||||
Exercises the publisher → journal → reconnect-reader round-trip without
|
||||
mocking the journal layer, so a regression in any of:
|
||||
- complete_stream's _emit closure
|
||||
- record_event's commit-per-call contract
|
||||
- build_message_event_stream's snapshot-from-DB path
|
||||
- the reconnect route's auth + ownership gates
|
||||
- the shared snapshot read (``event_replay.read_snapshot_lines``)
|
||||
- the reconnect route's ownership SQL (``async_sse._user_owns_message``)
|
||||
- message_events repo SQL
|
||||
|
||||
would surface here as a failed integration assertion.
|
||||
|
||||
The live tail itself (pub/sub) and the full async HTTP route are covered by
|
||||
``scripts/e2e_async_sse.py`` against a real Redis + uvicorn. These tests pin
|
||||
the DB-backed halves against a transactional ``pg_conn`` fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -56,55 +59,47 @@ def _seed_message(conn, user_id: str | None = None):
|
||||
return user_id, str(msg_id)
|
||||
|
||||
|
||||
def _emitted_ids(lines: list[str]) -> list[int]:
|
||||
"""Extract the ``id:`` sequence numbers from formatted SSE frames."""
|
||||
return sorted(
|
||||
int(line.split("\n", 1)[0].split(": ", 1)[1])
|
||||
for line in lines
|
||||
if line.startswith("id: ")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSnapshotPlusTailRoundTrip:
|
||||
def test_record_event_then_snapshot_returns_what_was_journaled(
|
||||
self, pg_conn,
|
||||
):
|
||||
"""End-to-end of the journal half: ``record_event`` writes through
|
||||
a real ``MessageEventsRepository``, ``build_message_event_stream``
|
||||
reads the snapshot back via the same repo + a stub Topic.
|
||||
"""End-to-end of the journal half: ``record_event`` writes through a
|
||||
real ``MessageEventsRepository``, ``read_snapshot_lines`` reads the
|
||||
snapshot back via the same repo and formats it for the wire — the
|
||||
exact primitive the async reader replays through.
|
||||
"""
|
||||
from application.streaming import event_replay
|
||||
from application.streaming.event_replay import read_snapshot_lines
|
||||
from application.streaming.message_journal import record_event
|
||||
|
||||
_, message_id = _seed_message(pg_conn)
|
||||
|
||||
with _patch_journal_session(pg_conn):
|
||||
# Three events stamp the journal.
|
||||
record_event(message_id, 0, "answer", {"type": "answer", "answer": "A"})
|
||||
record_event(message_id, 1, "answer", {"type": "answer", "answer": "B"})
|
||||
record_event(message_id, 2, "end", {"type": "end"})
|
||||
|
||||
# Reconnect path: subscribe yields nothing (Redis-down
|
||||
# branch); the post-loop fallback runs the snapshot read
|
||||
# synchronously and yields the journal contents.
|
||||
def _empty_subscribe(self, on_subscribe=None, poll_timeout=1.0):
|
||||
return
|
||||
yield # pragma: no cover
|
||||
lines, max_seq, terminal = read_snapshot_lines(message_id, None)
|
||||
|
||||
with patch.object(
|
||||
event_replay.Topic,
|
||||
"subscribe",
|
||||
_empty_subscribe,
|
||||
create=False,
|
||||
):
|
||||
gen = event_replay.build_message_event_stream(
|
||||
message_id,
|
||||
last_event_id=None,
|
||||
keepalive_seconds=0.05,
|
||||
poll_timeout_seconds=0.01,
|
||||
)
|
||||
out = list(gen)
|
||||
|
||||
# Prelude + 3 snapshot frames.
|
||||
assert out[0] == ": connected\n\n"
|
||||
assert "id: 0" in out[1] and '"answer": "A"' in out[1]
|
||||
assert "id: 1" in out[2] and '"answer": "B"' in out[2]
|
||||
assert "id: 2" in out[3] and '"type": "end"' in out[3]
|
||||
assert len(lines) == 3
|
||||
assert "id: 0" in lines[0] and '"answer": "A"' in lines[0]
|
||||
assert "id: 1" in lines[1] and '"answer": "B"' in lines[1]
|
||||
assert "id: 2" in lines[2] and '"type": "end"' in lines[2]
|
||||
assert max_seq == 2
|
||||
# A terminal event in the snapshot tells the reader to close.
|
||||
assert terminal is True
|
||||
|
||||
def test_snapshot_resumes_past_last_event_id(self, pg_conn):
|
||||
from application.streaming import event_replay
|
||||
from application.streaming.event_replay import read_snapshot_lines
|
||||
from application.streaming.message_journal import record_event
|
||||
|
||||
_, message_id = _seed_message(pg_conn)
|
||||
@@ -115,119 +110,81 @@ class TestSnapshotPlusTailRoundTrip:
|
||||
message_id, seq, "answer", {"type": "answer", "answer": str(seq)}
|
||||
)
|
||||
|
||||
def _empty_subscribe(self, on_subscribe=None, poll_timeout=1.0):
|
||||
return
|
||||
yield # pragma: no cover
|
||||
# Client says it has seen up through seq=2; expect 3 + 4.
|
||||
lines, max_seq, terminal = read_snapshot_lines(message_id, 2)
|
||||
|
||||
with patch.object(
|
||||
event_replay.Topic,
|
||||
"subscribe",
|
||||
_empty_subscribe,
|
||||
create=False,
|
||||
):
|
||||
# Client says it has seen up through seq=2; expect 3 + 4.
|
||||
out = list(
|
||||
event_replay.build_message_event_stream(
|
||||
message_id,
|
||||
last_event_id=2,
|
||||
keepalive_seconds=0.05,
|
||||
poll_timeout_seconds=0.01,
|
||||
)
|
||||
)
|
||||
assert _emitted_ids(lines) == [3, 4]
|
||||
assert max_seq == 4
|
||||
assert terminal is False
|
||||
|
||||
ids_seen = [line for line in out if line.startswith("id: ")]
|
||||
# Multi-line records: extract the id integers we delivered.
|
||||
emitted = sorted(
|
||||
int(line.split(": ", 1)[1].split("\n")[0])
|
||||
for line in out
|
||||
if line.startswith("id: ")
|
||||
)
|
||||
# Filter for non-negative (the snapshot-failure synthetic uses -1).
|
||||
emitted = [e for e in emitted if e >= 0]
|
||||
assert emitted == [3, 4]
|
||||
assert ids_seen # sanity
|
||||
|
||||
def test_reconnect_route_round_trip(self, pg_conn, flask_app):
|
||||
"""``/api/messages/<id>/events`` returns the journaled events
|
||||
for an authenticated owner.
|
||||
def test_ownership_sql_accepts_owner_and_rejects_others(self, pg_conn):
|
||||
"""The async route's ownership gate runs real SQL against
|
||||
``conversation_messages`` — the owner passes, everyone else 404s.
|
||||
"""
|
||||
from flask import Flask, request
|
||||
from application.api import async_sse
|
||||
|
||||
from application.api.answer.routes.messages import messages_bp
|
||||
user_id, message_id = _seed_message(pg_conn)
|
||||
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield pg_conn
|
||||
|
||||
with patch("application.api.async_sse.db_readonly", _yield):
|
||||
assert async_sse._user_owns_message(message_id, user_id) is True
|
||||
assert async_sse._user_owns_message(message_id, "different-user") is False
|
||||
# A well-formed but unknown id is also not owned.
|
||||
assert async_sse._user_owns_message(str(_uuid.uuid4()), user_id) is False
|
||||
|
||||
def test_snapshot_read_is_user_scoped(self, pg_conn):
|
||||
"""``read_snapshot_lines(..., user_id=)`` re-asserts ownership at the
|
||||
data layer: the owner gets the journal rows, a non-owner gets none.
|
||||
"""
|
||||
from application.streaming.event_replay import read_snapshot_lines
|
||||
from application.streaming.message_journal import record_event
|
||||
|
||||
# Build a fresh Flask app routing to the reconnect blueprint
|
||||
# plus a tiny auth shim that injects the test user.
|
||||
user_id, message_id = _seed_message(pg_conn)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(messages_bp)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
@app.before_request
|
||||
def _shim_auth():
|
||||
request.decoded_token = {"sub": user_id}
|
||||
|
||||
with _patch_journal_session(pg_conn):
|
||||
record_event(message_id, 0, "answer", {"type": "answer", "answer": "x"})
|
||||
record_event(message_id, 1, "end", {"type": "end"})
|
||||
|
||||
from application.streaming import event_replay
|
||||
owner_lines, _, owner_terminal = read_snapshot_lines(
|
||||
message_id, None, user_id
|
||||
)
|
||||
other_lines, _, other_terminal = read_snapshot_lines(
|
||||
message_id, None, "different-user"
|
||||
)
|
||||
# Unscoped (user_id=None) still returns everything.
|
||||
unscoped_lines, _, _ = read_snapshot_lines(message_id, None)
|
||||
|
||||
def _empty_subscribe(self, on_subscribe=None, poll_timeout=1.0):
|
||||
return
|
||||
yield # pragma: no cover
|
||||
assert _emitted_ids(owner_lines) == [0, 1] and owner_terminal is True
|
||||
assert other_lines == [] and other_terminal is False
|
||||
assert _emitted_ids(unscoped_lines) == [0, 1]
|
||||
|
||||
with patch.object(
|
||||
event_replay.Topic,
|
||||
"subscribe",
|
||||
_empty_subscribe,
|
||||
create=False,
|
||||
), patch(
|
||||
"application.api.answer.routes.messages.db_readonly"
|
||||
) as ro:
|
||||
ro.return_value.__enter__.return_value = pg_conn
|
||||
|
||||
with app.test_client() as c:
|
||||
r = c.get(f"/api/messages/{message_id}/events")
|
||||
assert r.status_code == 200
|
||||
body = b""
|
||||
for chunk in r.iter_encoded():
|
||||
body += chunk
|
||||
if body.count(b"\n\n") >= 4:
|
||||
break
|
||||
r.close()
|
||||
# Both journaled events present in the response.
|
||||
text = body.decode("utf-8")
|
||||
assert ": connected" in text
|
||||
assert '"answer": "x"' in text
|
||||
assert '"type": "end"' in text
|
||||
# The seq lines are correct.
|
||||
assert "id: 0" in text and "id: 1" in text
|
||||
|
||||
def test_reconnect_rejects_non_owner(self, pg_conn, flask_app):
|
||||
from flask import Flask, request
|
||||
|
||||
from application.api.answer.routes.messages import messages_bp
|
||||
def test_watchdog_is_user_scoped(self, pg_conn):
|
||||
"""``_check_producer_liveness(..., user_id)`` only sees the caller's
|
||||
own row; a non-owner reads as missing (terminal), not as the row's
|
||||
real status.
|
||||
"""
|
||||
from application.streaming.event_replay import _check_producer_liveness
|
||||
|
||||
# Seed a row and flip it to a terminal status the watchdog reports.
|
||||
user_id, message_id = _seed_message(pg_conn)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(messages_bp)
|
||||
pg_conn.execute(
|
||||
sql_text(
|
||||
"UPDATE conversation_messages SET status='complete' WHERE id = :id"
|
||||
),
|
||||
{"id": message_id},
|
||||
)
|
||||
|
||||
@app.before_request
|
||||
def _shim_auth():
|
||||
request.decoded_token = {"sub": "different-user"}
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield pg_conn
|
||||
|
||||
# Make the ownership check use the test connection.
|
||||
with patch(
|
||||
"application.api.answer.routes.messages.db_readonly"
|
||||
) as ro:
|
||||
from contextlib import contextmanager as _cm
|
||||
with patch("application.streaming.event_replay.db_readonly", _yield):
|
||||
owner = _check_producer_liveness(message_id, user_id, 90.0)
|
||||
other = _check_producer_liveness(message_id, "different-user", 90.0)
|
||||
|
||||
@_cm
|
||||
def _yield():
|
||||
yield pg_conn
|
||||
|
||||
ro.side_effect = lambda: _yield()
|
||||
with app.test_client() as c:
|
||||
r = c.get(f"/api/messages/{message_id}/events")
|
||||
assert r.status_code == 404
|
||||
# Owner sees the real terminal state; non-owner sees "missing".
|
||||
assert owner == {"type": "end"}
|
||||
assert other is not None and other.get("code") == "message_missing"
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Tests for ``application/api/async_sse.py``.
|
||||
|
||||
Native-async reconnect endpoint: GET /api/messages/<id>/events. Auth gate,
|
||||
ownership gate, malformed-id rejection, Last-Event-ID normalisation, and the
|
||||
SSE response shape (headers + ``: connected`` prelude). The route is a
|
||||
Starlette endpoint, so it's driven through Starlette's TestClient over a
|
||||
minimal app built from ``async_sse_routes``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from application.api.async_sse import (
|
||||
_MESSAGE_ID_RE,
|
||||
_normalise_last_event_id,
|
||||
async_sse_routes,
|
||||
)
|
||||
from application.core.settings import settings
|
||||
|
||||
VALID_UUID = "67d65e8f-e7fb-4df1-9e6e-99ea6c830206"
|
||||
|
||||
_AUTH = "application.api.async_sse.handle_auth"
|
||||
_OWNS = "application.api.async_sse._user_owns_message"
|
||||
_STREAM = "application.api.async_sse.build_message_event_stream_async"
|
||||
_AREDIS = "application.api.async_sse.get_async_redis_instance"
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
return TestClient(Starlette(routes=async_sse_routes))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_redis_by_default():
|
||||
"""Disable the per-user cap (no Redis) so gate tests stay hermetic.
|
||||
|
||||
Cap-specific tests override this with their own mock Redis.
|
||||
"""
|
||||
with patch(_AREDIS, AsyncMock(return_value=None)):
|
||||
yield
|
||||
|
||||
|
||||
def _mock_redis(incr_value: int) -> AsyncMock:
|
||||
redis = AsyncMock()
|
||||
redis.incr = AsyncMock(return_value=incr_value)
|
||||
redis.expire = AsyncMock(return_value=True)
|
||||
redis.decr = AsyncMock(return_value=incr_value - 1)
|
||||
return redis
|
||||
|
||||
|
||||
def _fake_stream(record: dict | None = None):
|
||||
"""Async builder stub: records the cursor, yields the prelude, returns."""
|
||||
|
||||
async def _gen(message_id, last_event_id=None, **kwargs):
|
||||
if record is not None:
|
||||
record["message_id"] = message_id
|
||||
record["last_event_id"] = last_event_id
|
||||
yield ": connected\n\n"
|
||||
|
||||
return _gen
|
||||
|
||||
|
||||
# ── pure helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormaliseLastEventId:
|
||||
def test_none_passthrough(self):
|
||||
assert _normalise_last_event_id(None) is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _normalise_last_event_id("") is None
|
||||
|
||||
def test_whitespace_only(self):
|
||||
assert _normalise_last_event_id(" ") is None
|
||||
|
||||
def test_valid_int(self):
|
||||
assert _normalise_last_event_id("42") == 42
|
||||
|
||||
def test_stripped_whitespace(self):
|
||||
assert _normalise_last_event_id(" 7 ") == 7
|
||||
|
||||
def test_zero_is_valid(self):
|
||||
assert _normalise_last_event_id("0") == 0
|
||||
|
||||
def test_negative_rejected(self):
|
||||
# We expose only non-negative cursors; -1 is reserved for the
|
||||
# snapshot-failure synthetic terminal event.
|
||||
assert _normalise_last_event_id("-1") is None
|
||||
|
||||
def test_non_numeric_rejected(self):
|
||||
for bad in ("foo", "1.5", "1e3", "abc-123", "null"):
|
||||
assert _normalise_last_event_id(bad) is None, bad
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMessageIdRegex:
|
||||
def test_canonical_uuid_accepted(self):
|
||||
assert _MESSAGE_ID_RE.match(VALID_UUID)
|
||||
|
||||
def test_uppercase_uuid_accepted(self):
|
||||
assert _MESSAGE_ID_RE.match(VALID_UUID.upper())
|
||||
|
||||
def test_no_dashes_rejected(self):
|
||||
assert not _MESSAGE_ID_RE.match(VALID_UUID.replace("-", ""))
|
||||
|
||||
def test_legacy_mongo_id_rejected(self):
|
||||
assert not _MESSAGE_ID_RE.match("507f1f77bcf86cd799439011")
|
||||
|
||||
|
||||
# ── auth gate ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAuthGate:
|
||||
def test_401_when_handle_auth_returns_none(self):
|
||||
with patch(_AUTH, return_value=None):
|
||||
r = _client().get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_401_when_decoded_token_missing_sub(self):
|
||||
with patch(_AUTH, return_value={"email": "x@y"}):
|
||||
r = _client().get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_401_when_handle_auth_returns_error(self):
|
||||
with patch(_AUTH, return_value={"error": "invalid_token"}):
|
||||
r = _client().get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ── message-id validation ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMessageIdValidation:
|
||||
def test_400_on_malformed_id(self):
|
||||
with patch(_AUTH, return_value={"sub": "alice"}):
|
||||
r = _client().get("/api/messages/not-a-uuid/events")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── ownership gate ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOwnershipGate:
|
||||
def test_404_when_user_does_not_own_message(self):
|
||||
with patch(_AUTH, return_value={"sub": "alice"}), patch(
|
||||
_OWNS, return_value=False
|
||||
):
|
||||
r = _client().get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_200_when_user_owns_message(self):
|
||||
with patch(_AUTH, return_value={"sub": "alice"}), patch(
|
||||
_OWNS, return_value=True
|
||||
), patch(_STREAM, _fake_stream()):
|
||||
r = _client().get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("content-type", "").startswith("text/event-stream")
|
||||
assert r.headers.get("Cache-Control") == "no-store"
|
||||
assert r.headers.get("X-Accel-Buffering") == "no"
|
||||
assert r.headers.get("X-SSE-Transport") == "async"
|
||||
assert ": connected" in r.text
|
||||
|
||||
|
||||
# ── Last-Event-ID parsing ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLastEventIdParsing:
|
||||
def test_header_passes_through_to_builder(self):
|
||||
captured: dict = {}
|
||||
with patch(_AUTH, return_value={"sub": "alice"}), patch(
|
||||
_OWNS, return_value=True
|
||||
), patch(_STREAM, _fake_stream(captured)):
|
||||
_client().get(
|
||||
f"/api/messages/{VALID_UUID}/events",
|
||||
headers={"Last-Event-ID": "12"},
|
||||
)
|
||||
assert captured["message_id"] == VALID_UUID
|
||||
assert captured["last_event_id"] == 12
|
||||
|
||||
def test_query_param_fallback(self):
|
||||
captured: dict = {}
|
||||
with patch(_AUTH, return_value={"sub": "alice"}), patch(
|
||||
_OWNS, return_value=True
|
||||
), patch(_STREAM, _fake_stream(captured)):
|
||||
_client().get(f"/api/messages/{VALID_UUID}/events?last_event_id=5")
|
||||
assert captured["last_event_id"] == 5
|
||||
|
||||
def test_invalid_cursor_normalised_to_none(self):
|
||||
captured: dict = {}
|
||||
with patch(_AUTH, return_value={"sub": "alice"}), patch(
|
||||
_OWNS, return_value=True
|
||||
), patch(_STREAM, _fake_stream(captured)):
|
||||
_client().get(
|
||||
f"/api/messages/{VALID_UUID}/events",
|
||||
headers={"Last-Event-ID": "definitely-not-a-number"},
|
||||
)
|
||||
assert captured["last_event_id"] is None
|
||||
|
||||
|
||||
# ── per-user concurrent-connection cap ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestConnectionCap:
|
||||
def _cap(self) -> int:
|
||||
return int(settings.SSE_MAX_CONCURRENT_PER_USER) or 8
|
||||
|
||||
def test_429_when_over_cap(self):
|
||||
cap = self._cap()
|
||||
redis = _mock_redis(incr_value=cap + 1) # post-incr count exceeds cap
|
||||
with patch(_AUTH, return_value={"sub": "alice"}), patch(
|
||||
_OWNS, return_value=True
|
||||
), patch(_STREAM, _fake_stream()), patch(
|
||||
_AREDIS, AsyncMock(return_value=redis)
|
||||
):
|
||||
r = _client().get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 429
|
||||
# The increment is rolled back so a rejected attempt doesn't wedge
|
||||
# the counter at the cap forever.
|
||||
redis.decr.assert_awaited_once()
|
||||
|
||||
def test_200_and_slot_released_when_under_cap(self):
|
||||
redis = _mock_redis(incr_value=1)
|
||||
with patch(_AUTH, return_value={"sub": "alice"}), patch(
|
||||
_OWNS, return_value=True
|
||||
), patch(_STREAM, _fake_stream()), patch(
|
||||
_AREDIS, AsyncMock(return_value=redis)
|
||||
):
|
||||
r = _client().get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 200
|
||||
assert ": connected" in r.text
|
||||
redis.incr.assert_awaited_once()
|
||||
# Slot released when the stream finishes (terminal/close).
|
||||
redis.decr.assert_awaited_once()
|
||||
|
||||
def test_cap_skipped_when_redis_unavailable(self):
|
||||
# The autouse fixture already makes get_async_redis_instance -> None;
|
||||
# the stream is served (fail-open, like /api/events).
|
||||
with patch(_AUTH, return_value={"sub": "alice"}), patch(
|
||||
_OWNS, return_value=True
|
||||
), patch(_STREAM, _fake_stream()):
|
||||
r = _client().get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 200
|
||||
@@ -1,220 +0,0 @@
|
||||
"""Tests for ``application/api/answer/routes/messages.py``.
|
||||
|
||||
Reconnect endpoint: GET /api/messages/<id>/events. Auth gate, ownership
|
||||
gate, malformed-id rejection, Last-Event-ID normalisation, and a smoke
|
||||
test that the SSE response shape matches the user-events endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask import Flask, request
|
||||
|
||||
from application.api.answer.routes.messages import (
|
||||
_MESSAGE_ID_RE,
|
||||
_normalise_last_event_id,
|
||||
messages_bp,
|
||||
)
|
||||
|
||||
|
||||
def _make_app(decoded_token=None):
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(messages_bp)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
@app.before_request
|
||||
def _shim_auth():
|
||||
request.decoded_token = decoded_token
|
||||
|
||||
return app
|
||||
|
||||
|
||||
VALID_UUID = "67d65e8f-e7fb-4df1-9e6e-99ea6c830206"
|
||||
|
||||
|
||||
class TestNormaliseLastEventId:
|
||||
def test_none_passthrough(self):
|
||||
assert _normalise_last_event_id(None) is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _normalise_last_event_id("") is None
|
||||
|
||||
def test_whitespace_only(self):
|
||||
assert _normalise_last_event_id(" ") is None
|
||||
|
||||
def test_valid_int(self):
|
||||
assert _normalise_last_event_id("42") == 42
|
||||
|
||||
def test_stripped_whitespace(self):
|
||||
assert _normalise_last_event_id(" 7 ") == 7
|
||||
|
||||
def test_zero_is_valid(self):
|
||||
assert _normalise_last_event_id("0") == 0
|
||||
|
||||
def test_negative_rejected(self):
|
||||
# We expose only non-negative cursors; -1 is reserved for the
|
||||
# snapshot-failure synthetic terminal event and shouldn't
|
||||
# round-trip back.
|
||||
assert _normalise_last_event_id("-1") is None
|
||||
|
||||
def test_non_numeric_rejected(self):
|
||||
for bad in ("foo", "1.5", "1e3", "abc-123", "null"):
|
||||
assert _normalise_last_event_id(bad) is None, bad
|
||||
|
||||
|
||||
class TestMessageIdRegex:
|
||||
def test_canonical_uuid_accepted(self):
|
||||
assert _MESSAGE_ID_RE.match(VALID_UUID)
|
||||
|
||||
def test_uppercase_uuid_accepted(self):
|
||||
assert _MESSAGE_ID_RE.match(VALID_UUID.upper())
|
||||
|
||||
def test_no_dashes_rejected(self):
|
||||
assert not _MESSAGE_ID_RE.match(VALID_UUID.replace("-", ""))
|
||||
|
||||
def test_legacy_mongo_id_rejected(self):
|
||||
# 24-char hex with no dashes — a Mongo objectid-shaped string
|
||||
# that happened to leak through somewhere.
|
||||
assert not _MESSAGE_ID_RE.match("507f1f77bcf86cd799439011")
|
||||
|
||||
|
||||
class TestAuthGate:
|
||||
def test_401_when_no_decoded_token(self):
|
||||
app = _make_app(decoded_token=None)
|
||||
with app.test_client() as c:
|
||||
r = c.get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_401_when_decoded_token_missing_sub(self):
|
||||
app = _make_app(decoded_token={"email": "x@y"})
|
||||
with app.test_client() as c:
|
||||
r = c.get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
class TestMessageIdValidation:
|
||||
def test_400_on_malformed_id(self):
|
||||
app = _make_app(decoded_token={"sub": "alice"})
|
||||
with app.test_client() as c:
|
||||
r = c.get("/api/messages/not-a-uuid/events")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestOwnershipGate:
|
||||
def test_404_when_user_does_not_own_message(self):
|
||||
from application.api.answer.routes import messages as messages_module
|
||||
|
||||
app = _make_app(decoded_token={"sub": "alice"})
|
||||
with patch.object(
|
||||
messages_module, "_user_owns_message", return_value=False
|
||||
):
|
||||
with app.test_client() as c:
|
||||
r = c.get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_200_when_user_owns_message(self):
|
||||
from application.api.answer.routes import messages as messages_module
|
||||
|
||||
app = _make_app(decoded_token={"sub": "alice"})
|
||||
|
||||
# Have build_message_event_stream yield just the prelude then
|
||||
# exit so the test can drain the response without blocking on
|
||||
# a live pubsub subscription.
|
||||
def _fake_builder(message_id, last_event_id=None, **kwargs):
|
||||
yield ": connected\n\n"
|
||||
|
||||
with patch.object(
|
||||
messages_module, "_user_owns_message", return_value=True
|
||||
), patch.object(
|
||||
messages_module, "build_message_event_stream", _fake_builder
|
||||
):
|
||||
with app.test_client() as c:
|
||||
r = c.get(f"/api/messages/{VALID_UUID}/events")
|
||||
assert r.status_code == 200
|
||||
assert r.mimetype == "text/event-stream"
|
||||
assert r.headers.get("Cache-Control") == "no-store"
|
||||
assert r.headers.get("X-Accel-Buffering") == "no"
|
||||
body = b""
|
||||
for chunk in r.iter_encoded():
|
||||
body += chunk
|
||||
if b": connected" in body:
|
||||
break
|
||||
r.close()
|
||||
assert b": connected" in body
|
||||
|
||||
|
||||
class TestLastEventIdParsing:
|
||||
def test_header_passes_through_to_builder(self):
|
||||
from application.api.answer.routes import messages as messages_module
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_builder(message_id, last_event_id=None, **kwargs):
|
||||
captured["message_id"] = message_id
|
||||
captured["last_event_id"] = last_event_id
|
||||
yield ": connected\n\n"
|
||||
|
||||
app = _make_app(decoded_token={"sub": "alice"})
|
||||
with patch.object(
|
||||
messages_module, "_user_owns_message", return_value=True
|
||||
), patch.object(
|
||||
messages_module, "build_message_event_stream", _fake_builder
|
||||
):
|
||||
with app.test_client() as c:
|
||||
r = c.get(
|
||||
f"/api/messages/{VALID_UUID}/events",
|
||||
headers={"Last-Event-ID": "12"},
|
||||
)
|
||||
# Drain a tick.
|
||||
next(iter(r.iter_encoded()), None)
|
||||
r.close()
|
||||
assert captured["message_id"] == VALID_UUID
|
||||
assert captured["last_event_id"] == 12
|
||||
|
||||
def test_query_param_fallback(self):
|
||||
from application.api.answer.routes import messages as messages_module
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_builder(message_id, last_event_id=None, **kwargs):
|
||||
captured["last_event_id"] = last_event_id
|
||||
yield ": connected\n\n"
|
||||
|
||||
app = _make_app(decoded_token={"sub": "alice"})
|
||||
with patch.object(
|
||||
messages_module, "_user_owns_message", return_value=True
|
||||
), patch.object(
|
||||
messages_module, "build_message_event_stream", _fake_builder
|
||||
):
|
||||
with app.test_client() as c:
|
||||
r = c.get(
|
||||
f"/api/messages/{VALID_UUID}/events?last_event_id=5"
|
||||
)
|
||||
next(iter(r.iter_encoded()), None)
|
||||
r.close()
|
||||
assert captured["last_event_id"] == 5
|
||||
|
||||
def test_invalid_cursor_normalised_to_none(self):
|
||||
from application.api.answer.routes import messages as messages_module
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_builder(message_id, last_event_id=None, **kwargs):
|
||||
captured["last_event_id"] = last_event_id
|
||||
yield ": connected\n\n"
|
||||
|
||||
app = _make_app(decoded_token={"sub": "alice"})
|
||||
with patch.object(
|
||||
messages_module, "_user_owns_message", return_value=True
|
||||
), patch.object(
|
||||
messages_module, "build_message_event_stream", _fake_builder
|
||||
):
|
||||
with app.test_client() as c:
|
||||
r = c.get(
|
||||
f"/api/messages/{VALID_UUID}/events",
|
||||
headers={"Last-Event-ID": "definitely-not-a-number"},
|
||||
)
|
||||
next(iter(r.iter_encoded()), None)
|
||||
r.close()
|
||||
assert captured["last_event_id"] is None
|
||||
Reference in new issue
Block a user