Files
arc53--docsgpt/tests/api/test_async_sse_routes.py
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

253 lines
9.3 KiB
Python

"""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