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

391 lines
15 KiB
Python

"""Unit tests for the SSE substrate (publisher + Topic + route helpers).
Round-trip integration tests against a real / fake Redis live in
``tests/test_events_integration.py``. The tests here lock in the
closure-mutated, race-sensitive bits the route relies on so future
refactors can't regress them silently.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from application.api.events.routes import (
_SSE_LINE_SPLIT,
_format_sse,
_normalize_last_event_id,
)
from application.events.keys import (
connection_counter_key,
stream_id_compare,
stream_key,
topic_name,
)
from application.events.publisher import publish_user_event
from application.streaming.broadcast_channel import Topic
# ── keys ────────────────────────────────────────────────────────────────
@pytest.mark.unit
class TestKeys:
def test_stream_key(self):
assert stream_key("alice") == "user:alice:stream"
def test_topic_name(self):
assert topic_name("alice") == "user:alice"
def test_connection_counter_key(self):
assert connection_counter_key("alice") == "user:alice:sse_count"
# ── stream_id_compare ───────────────────────────────────────────────────
@pytest.mark.unit
class TestStreamIdCompare:
def test_equal(self):
assert stream_id_compare("1234-0", "1234-0") == 0
def test_seq_ordering(self):
assert stream_id_compare("1234-0", "1234-1") == -1
assert stream_id_compare("1234-1", "1234-0") == 1
def test_ms_ordering(self):
assert stream_id_compare("1234-0", "5678-0") == -1
assert stream_id_compare("5678-0", "1234-0") == 1
def test_digit_count_does_not_break_int_compare(self):
# String compare would say "9" > "100"; integer compare must not.
assert stream_id_compare("9-0", "100-0") == -1
assert stream_id_compare("100-0", "9-0") == 1
def test_missing_seq_treated_as_zero(self):
assert stream_id_compare("1234", "1234-0") == 0
def test_malformed_input_raises(self):
# Callers must pre-validate; the function refuses to silently
# lex-compare garbage because a malformed id that sorts
# lex-greater would pin dedup forever.
with pytest.raises(ValueError):
stream_id_compare("foo", "bar")
with pytest.raises(ValueError):
stream_id_compare("123-0", "foo")
with pytest.raises(ValueError):
stream_id_compare("foo", "123-0")
# ── _format_sse ─────────────────────────────────────────────────────────
@pytest.mark.unit
class TestFormatSse:
def test_simple_payload(self):
assert _format_sse("hello") == "data: hello\n\n"
def test_with_event_id(self):
assert _format_sse("hello", event_id="42-0") == "id: 42-0\ndata: hello\n\n"
def test_lf_split(self):
assert _format_sse("a\nb") == "data: a\ndata: b\n\n"
def test_crlf_split(self):
assert _format_sse("a\r\nb") == "data: a\ndata: b\n\n"
def test_cr_only_split(self):
# WHATWG SSE treats CR alone as a line terminator. Must match.
assert _format_sse("a\rb") == "data: a\ndata: b\n\n"
def test_mixed_terminators(self):
# Each variant in one payload — all should split.
out = _format_sse("a\rb\nc\r\nd")
assert out == "data: a\ndata: b\ndata: c\ndata: d\n\n"
def test_empty_payload(self):
# WHATWG: empty data field is dispatched as a message with data="".
assert _format_sse("") == "data: \n\n"
def test_terminator_regex_compiles(self):
# Defensive: ensure the regex itself is well-formed.
assert _SSE_LINE_SPLIT.split("a\r\nb\rc\nd") == ["a", "b", "c", "d"]
# ── _normalize_last_event_id ────────────────────────────────────────────
@pytest.mark.unit
class TestNormalizeLastEventId:
def test_none(self):
assert _normalize_last_event_id(None) is None
def test_empty_string(self):
assert _normalize_last_event_id("") is None
def test_whitespace_only(self):
assert _normalize_last_event_id(" ") is None
def test_stripped_valid_id(self):
assert _normalize_last_event_id(" 1234-0 ") == "1234-0"
def test_ms_only(self):
assert _normalize_last_event_id("1234567890") == "1234567890"
def test_ms_seq(self):
assert _normalize_last_event_id("1234567890-0") == "1234567890-0"
def test_high_seq(self):
assert _normalize_last_event_id("1-99999") == "1-99999"
def test_garbage_rejected(self):
for bad in ("foo", "foo-bar", "1234-foo", "-1", "1234-", "1-2-3", "0x1234"):
assert _normalize_last_event_id(bad) is None, bad
# ── publisher ───────────────────────────────────────────────────────────
@pytest.mark.unit
class TestPublishUserEvent:
def setup_method(self):
# Reset the cache singleton so the patched mock gets used.
import application.cache as cache_mod
cache_mod._redis_instance = None
cache_mod._redis_creation_failed = False
def teardown_method(self):
import application.cache as cache_mod
cache_mod._redis_instance = None
cache_mod._redis_creation_failed = False
def test_returns_none_on_missing_user_id(self):
assert publish_user_event("", "x.y", {}) is None
def test_returns_none_on_missing_event_type(self):
assert publish_user_event("alice", "", {}) is None
@patch("application.events.publisher.get_redis_instance")
@patch("application.events.publisher.settings")
def test_returns_none_when_push_disabled(self, mock_settings, mock_redis):
mock_settings.ENABLE_SSE_PUSH = False
mock_settings.EVENTS_STREAM_MAXLEN = 1000
result = publish_user_event("alice", "src.ingest.progress", {"current": 1})
assert result is None
# Must not even reach Redis when the master switch is off.
mock_redis.assert_not_called()
@patch("application.events.publisher.get_redis_instance")
@patch("application.events.publisher.settings")
def test_returns_none_when_redis_unavailable(self, mock_settings, mock_redis):
mock_settings.ENABLE_SSE_PUSH = True
mock_settings.EVENTS_STREAM_MAXLEN = 1000
mock_redis.return_value = None
assert publish_user_event("alice", "x.y", {}) is None
@patch("application.events.publisher.get_redis_instance")
@patch("application.events.publisher.settings")
def test_returns_none_on_unserializable_payload(
self, mock_settings, mock_redis
):
# Set object can't be JSON-encoded; the publisher must catch
# this *before* hitting Redis, with a single warning log.
mock_settings.ENABLE_SSE_PUSH = True
mock_settings.EVENTS_STREAM_MAXLEN = 1000
result = publish_user_event(
"alice", "x.y", {"bad": {1, 2, 3}} # type: ignore[arg-type]
)
assert result is None
mock_redis.assert_not_called()
@patch("application.events.publisher.Topic")
@patch("application.events.publisher.get_redis_instance")
@patch("application.events.publisher.settings")
def test_xadd_and_publish_both_invoked_on_happy_path(
self, mock_settings, mock_redis, mock_topic_cls
):
mock_settings.ENABLE_SSE_PUSH = True
mock_settings.EVENTS_STREAM_MAXLEN = 750
mock_client = MagicMock()
mock_client.xadd.return_value = b"1735682400000-0"
mock_redis.return_value = mock_client
mock_topic = MagicMock()
mock_topic_cls.return_value = mock_topic
result = publish_user_event(
"alice",
"source.ingest.progress",
{"current": 30, "total": 100},
scope={"kind": "source", "id": "abc"},
)
assert result == "1735682400000-0"
# XADD: stream key, MAXLEN, approximate — and the envelope before
# the id was known.
mock_client.xadd.assert_called_once()
args, kwargs = mock_client.xadd.call_args
assert args[0] == "user:alice:stream"
assert kwargs.get("maxlen") == 750
assert kwargs.get("approximate") is True
# The envelope inside the XADD does NOT carry an ``id`` yet (it
# only exists after Redis returns), so the publish-time envelope
# is the source of truth for ``id``.
stored_event = json.loads(args[1]["event"])
assert "id" not in stored_event
assert stored_event["type"] == "source.ingest.progress"
assert stored_event["topic"] == "user:alice"
assert stored_event["payload"] == {"current": 30, "total": 100}
assert stored_event["scope"] == {"kind": "source", "id": "abc"}
# PUBLISH: same envelope plus the ``id`` from XADD.
mock_topic_cls.assert_called_once_with("user:alice")
mock_topic.publish.assert_called_once()
published = json.loads(mock_topic.publish.call_args[0][0])
assert published["id"] == "1735682400000-0"
assert published["type"] == "source.ingest.progress"
@patch("application.events.publisher.Topic")
@patch("application.events.publisher.get_redis_instance")
@patch("application.events.publisher.settings")
def test_xadd_failure_skips_live_publish(
self, mock_settings, mock_redis, mock_topic_cls
):
"""If the durable journal write fails there is no canonical id
to ship. Publishing an id-less envelope would put a record on
the wire that bypasses the SSE route's dedup floor and breaks
``Last-Event-ID`` semantics for any reconnect — so we drop the
live publish too. Best-effort delivery means dropping
consistently, not delivering inconsistent state.
"""
mock_settings.ENABLE_SSE_PUSH = True
mock_settings.EVENTS_STREAM_MAXLEN = 1000
mock_client = MagicMock()
mock_client.xadd.side_effect = Exception("redis went down")
mock_redis.return_value = mock_client
mock_topic = MagicMock()
mock_topic_cls.return_value = mock_topic
result = publish_user_event("alice", "x.y", {"k": 1})
# Backlog write failed → publisher returns None and skips the
# live publish so subscribers never see an id-less envelope.
assert result is None
mock_topic.publish.assert_not_called()
# ── Topic ───────────────────────────────────────────────────────────────
@pytest.mark.unit
class TestTopic:
@patch("application.streaming.broadcast_channel.get_redis_instance")
def test_publish_returns_zero_when_redis_unavailable(self, mock_redis):
mock_redis.return_value = None
assert Topic("user:alice").publish("hi") == 0
@patch("application.streaming.broadcast_channel.get_redis_instance")
def test_publish_calls_redis_publish(self, mock_redis):
client = MagicMock()
client.publish.return_value = 3
mock_redis.return_value = client
result = Topic("user:alice").publish("hi")
assert result == 3
client.publish.assert_called_once_with("user:alice", "hi")
@patch("application.streaming.broadcast_channel.get_redis_instance")
def test_publish_swallows_exceptions(self, mock_redis):
client = MagicMock()
client.publish.side_effect = Exception("boom")
mock_redis.return_value = client
# Must not raise.
assert Topic("user:alice").publish("hi") == 0
@patch("application.streaming.broadcast_channel.get_pubsub_redis_instance")
def test_subscribe_returns_immediately_when_redis_unavailable(
self, mock_redis
):
mock_redis.return_value = None
# Generator should produce nothing, not raise.
assert list(Topic("user:alice").subscribe(poll_timeout=0.01)) == []
@patch("application.streaming.broadcast_channel.get_pubsub_redis_instance")
def test_subscribe_yields_none_on_poll_timeout(self, mock_redis):
client = MagicMock()
pubsub = MagicMock()
# Two timeouts (None) then a real message — caller breaks after.
pubsub.get_message.side_effect = [None, None, {"type": "message", "data": b"x"}]
client.pubsub.return_value = pubsub
mock_redis.return_value = client
gen = Topic("user:alice").subscribe(poll_timeout=0.01)
first = next(gen)
second = next(gen)
third = next(gen)
assert first is None
assert second is None
assert third == b"x"
gen.close()
@patch("application.streaming.broadcast_channel.get_pubsub_redis_instance")
def test_subscribe_fires_on_subscribe_after_ack(self, mock_redis):
client = MagicMock()
pubsub = MagicMock()
pubsub.get_message.side_effect = [
{"type": "subscribe", "data": 1},
{"type": "message", "data": b"hi"},
]
client.pubsub.return_value = pubsub
mock_redis.return_value = client
callback_calls = []
def cb():
callback_calls.append(1)
gen = Topic("user:alice").subscribe(on_subscribe=cb, poll_timeout=0.01)
# The "subscribe" message is consumed without yielding; the next
# value yielded is the actual data message.
msg = next(gen)
assert msg == b"hi"
assert callback_calls == [1]
gen.close()
@patch("application.streaming.broadcast_channel.get_pubsub_redis_instance")
def test_subscribe_cleans_up_on_generator_close(self, mock_redis):
client = MagicMock()
pubsub = MagicMock()
pubsub.get_message.side_effect = [
{"type": "subscribe", "data": 1},
None, # one tick so we stay in the loop
]
client.pubsub.return_value = pubsub
mock_redis.return_value = client
gen = Topic("user:alice").subscribe(poll_timeout=0.01)
next(gen) # advance into the subscribe-confirmation
gen.close() # client disconnect
pubsub.unsubscribe.assert_called_once_with("user:alice")
pubsub.close.assert_called_once()
@patch("application.streaming.broadcast_channel.get_pubsub_redis_instance")
def test_subscribe_skips_unsubscribe_if_subscribe_never_acked(
self, mock_redis
):
client = MagicMock()
pubsub = MagicMock()
pubsub.subscribe.side_effect = Exception("connection lost")
client.pubsub.return_value = pubsub
mock_redis.return_value = client
# The generator must not yield, must not raise, and must not call
# unsubscribe (we never confirmed a subscription).
list(Topic("user:alice").subscribe(poll_timeout=0.01))
pubsub.unsubscribe.assert_not_called()
pubsub.close.assert_called_once()