pythonasyncioeventletgeventlong-pollinglow-latencysocket-iosocketiosocketio-serverweb-serverwebsocket
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1012 lines
36 KiB
1012 lines
36 KiB
diff --git a/new_tests/test_message_history.py b/new_tests/test_message_history.py
|
|
new file mode 100644
|
|
index 0000000..725a8c5
|
|
--- /dev/null
|
|
+++ b/new_tests/test_message_history.py
|
|
@@ -0,0 +1,950 @@
|
|
+import time
|
|
+import inspect
|
|
+import functools
|
|
+import socketio
|
|
+import pytest
|
|
+
|
|
+
|
|
+def _mk_server():
|
|
+ """Basic in-memory server suitable for unit tests."""
|
|
+ return socketio.Server(async_mode="threading")
|
|
+
|
|
+
|
|
+# ---------- Discovery and adaptation helpers ----------
|
|
+
|
|
+def _iter_callables(obj, max_depth=3):
|
|
+ """Yield (owner, name, callable) by recursively scanning attributes up to max_depth."""
|
|
+ seen = set()
|
|
+
|
|
+ def walk(target, depth):
|
|
+ if depth > max_depth:
|
|
+ return
|
|
+ for name in dir(target):
|
|
+ if name.startswith("_"):
|
|
+ continue
|
|
+ try:
|
|
+ cand = getattr(target, name, None)
|
|
+ except Exception:
|
|
+ continue
|
|
+ if callable(cand):
|
|
+ key = (id(target), name)
|
|
+ if key not in seen:
|
|
+ seen.add(key)
|
|
+ yield (target, name, cand)
|
|
+ if cand is None or callable(cand):
|
|
+ continue
|
|
+ try:
|
|
+ yield from walk(cand, depth + 1)
|
|
+ except Exception:
|
|
+ continue
|
|
+
|
|
+ yield from walk(obj, 0)
|
|
+
|
|
+
|
|
+def _find_callable(obj, predicate):
|
|
+ """Find first callable matching predicate."""
|
|
+ for owner, name, fn in _iter_callables(obj):
|
|
+ if predicate(name, fn):
|
|
+ return name, fn
|
|
+ return None, None
|
|
+
|
|
+
|
|
+def _has_roomish_param(fn):
|
|
+ """Check if function has a room-like parameter."""
|
|
+ try:
|
|
+ sig = inspect.signature(fn)
|
|
+ except (TypeError, ValueError):
|
|
+ return False
|
|
+ pnames = set(sig.parameters.keys())
|
|
+ # Only use aliases from the spec
|
|
+ return _pick_alias(pnames, ["room", "room_id", "room_name"]) is not None
|
|
+
|
|
+
|
|
+def _pick_alias(param_names, aliases):
|
|
+ """Return the first alias that exists in param_names, else None."""
|
|
+ for a in aliases:
|
|
+ if a in param_names:
|
|
+ return a
|
|
+ return None
|
|
+
|
|
+
|
|
+def _wrap_kwargs(fn, semantic_kwargs=True):
|
|
+ """Build a wrapper for fn that accepts semantic kwargs using spec-defined aliases."""
|
|
+ sig = inspect.signature(fn)
|
|
+ params = set(sig.parameters.keys())
|
|
+ # Only aliases from the spec
|
|
+ alias_map = {
|
|
+ "room": ["room", "room_id", "room_name"],
|
|
+ "limit": ["limit", "n", "count", "max_items"],
|
|
+ "namespace": ["namespace", "ns"],
|
|
+ "include_events": ["include_events", "include", "events_include"],
|
|
+ "exclude_events": ["exclude_events", "exclude", "events_exclude"],
|
|
+ "payload_size_cap": ["payload_size_cap", "cap", "size_cap"],
|
|
+ "enabled": ["enabled", "enable", "on"],
|
|
+ "max_entries": ["max_entries", "max", "limit"],
|
|
+ "retention_seconds": ["retention_seconds", "retention", "ttl"],
|
|
+ }
|
|
+
|
|
+ def map_kwargs(kwargs):
|
|
+ mapped = {}
|
|
+ for sem_key, value in kwargs.items():
|
|
+ aliases = alias_map.get(sem_key, [sem_key])
|
|
+ chosen = _pick_alias(params, aliases)
|
|
+ if chosen is not None:
|
|
+ mapped[chosen] = value
|
|
+ return mapped
|
|
+
|
|
+ @functools.wraps(fn)
|
|
+ def wrapper(**kwargs):
|
|
+ return fn(**map_kwargs(kwargs))
|
|
+
|
|
+ return wrapper
|
|
+
|
|
+
|
|
+def discover_get_history(sio):
|
|
+ """Find the best matching fetch method by scoring candidates."""
|
|
+ candidates = []
|
|
+ for owner, name, fn in _iter_callables(sio):
|
|
+ lname = name.lower()
|
|
+ try:
|
|
+ sig = inspect.signature(fn)
|
|
+ pnames = set(sig.parameters.keys())
|
|
+ except (TypeError, ValueError):
|
|
+ continue
|
|
+
|
|
+ # Only use aliases from the spec
|
|
+ room_ok = _pick_alias(pnames, ["room", "room_id", "room_name"]) is not None
|
|
+ limit_ok = _pick_alias(pnames, ["limit", "n", "count", "max_items"]) is not None
|
|
+ any_opt = any(_pick_alias(pnames, alist) for alist in [
|
|
+ ["namespace", "ns"],
|
|
+ ["include_events", "include", "events_include"],
|
|
+ ["exclude_events", "exclude", "events_exclude"],
|
|
+ ["payload_size_cap", "cap", "size_cap"],
|
|
+ ])
|
|
+
|
|
+ has_fetch_get = any(k in lname for k in ["get", "fetch"]) or lname.startswith("list")
|
|
+ is_setter_like = any(k in lname for k in ["set", "configure", "update", "assign"]) and not ("get" in lname or "fetch" in lname)
|
|
+ has_history_hint = ("history" in lname) or ("recent" in lname) or ("buffer" in lname)
|
|
+
|
|
+ score = 0
|
|
+ if room_ok:
|
|
+ score += 2
|
|
+ if limit_ok:
|
|
+ score += 2
|
|
+ if any_opt:
|
|
+ score += 1
|
|
+ if has_fetch_get:
|
|
+ score += 1
|
|
+ if is_setter_like:
|
|
+ score -= 2
|
|
+ if has_history_hint:
|
|
+ score += 1
|
|
+
|
|
+ if room_ok and limit_ok:
|
|
+ candidates.append((score, name, fn))
|
|
+
|
|
+ assert candidates, "Missing a room history getter per spec."
|
|
+ candidates.sort(key=lambda t: (-t[0], t[1]))
|
|
+ _, _, best = candidates[0]
|
|
+ return _wrap_kwargs(best, semantic_kwargs=True)
|
|
+
|
|
+
|
|
+def discover_enable_disable_configure(sio):
|
|
+ """Return a tuple of wrappers: (enable_fn, disable_fn, configure_fn, enable_params_set)."""
|
|
+ def pred_enable(name, fn):
|
|
+ lname = name.lower()
|
|
+ return any(k in lname for k in ["enable", "on", "start", "activate", "set"]) and _has_roomish_param(fn)
|
|
+
|
|
+ def pred_disable(name, fn):
|
|
+ lname = name.lower()
|
|
+ return any(k in lname for k in ["disable", "off", "stop", "deactivate", "unset"]) and _has_roomish_param(fn)
|
|
+
|
|
+ def pred_config(name, fn):
|
|
+ lname = name.lower()
|
|
+ return any(k in lname for k in ["config", "configure", "set"]) and _has_roomish_param(fn)
|
|
+
|
|
+ _, e_fn = _find_callable(sio, pred_enable)
|
|
+ _, d_fn = _find_callable(sio, pred_disable)
|
|
+ _, c_fn = _find_callable(sio, pred_config)
|
|
+
|
|
+ e_wrap = _wrap_kwargs(e_fn, semantic_kwargs=True) if e_fn else None
|
|
+ d_wrap = _wrap_kwargs(d_fn, semantic_kwargs=True) if d_fn else None
|
|
+ c_wrap = _wrap_kwargs(c_fn, semantic_kwargs=True) if c_fn else None
|
|
+
|
|
+ enable_params_set = set()
|
|
+ if e_fn:
|
|
+ p = set(inspect.signature(e_fn).parameters.keys())
|
|
+ for sem, aliases in {
|
|
+ "namespace": ["namespace", "ns"],
|
|
+ "max_entries": ["max_entries", "max", "limit"],
|
|
+ "retention_seconds": ["retention_seconds", "retention", "ttl"],
|
|
+ "payload_size_cap": ["payload_size_cap", "cap", "size_cap"],
|
|
+ "enabled": ["enabled", "enable", "on"],
|
|
+ }.items():
|
|
+ if _pick_alias(p, aliases):
|
|
+ enable_params_set.add(sem)
|
|
+
|
|
+ return e_wrap, d_wrap, c_wrap, enable_params_set
|
|
+
|
|
+
|
|
+def discover_stats_getter(sio):
|
|
+ """Return (stats_fn_wrapper, fields_alias) or (None, None)."""
|
|
+ def pred(name, fn):
|
|
+ lname = name.lower()
|
|
+ return any(k in lname for k in ["stats", "metrics", "observability", "counters", "counts"])
|
|
+
|
|
+ _, fn = _find_callable(sio, pred)
|
|
+ if not fn:
|
|
+ return None, None
|
|
+
|
|
+ stats_wrap = _wrap_kwargs(fn, semantic_kwargs=True)
|
|
+ # Only aliases from the spec
|
|
+ fields_alias = {
|
|
+ "entries": ["entries_count", "entries", "count"],
|
|
+ "evict_size": ["evictions_size", "size_evictions", "evictions_by_size"],
|
|
+ "evict_time": ["evictions_time", "time_evictions", "evictions_by_time"],
|
|
+ }
|
|
+ return stats_wrap, fields_alias
|
|
+
|
|
+
|
|
+def pick_stat_keys(stats_dict, fields_alias):
|
|
+ """Pick actual keys from stats dict based on allowed aliases."""
|
|
+ picked = {}
|
|
+ for need, aliases in fields_alias.items():
|
|
+ for key in aliases:
|
|
+ if key in stats_dict:
|
|
+ picked[need] = key
|
|
+ break
|
|
+ assert need in picked, f"stats missing required semantic field: {need}"
|
|
+ return picked
|
|
+
|
|
+
|
|
+# ---------- Tests ----------
|
|
+
|
|
+def test_default_disabled_is_required_with_enable_path():
|
|
+ """History must be disabled by default and require explicit enablement."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ assert enable_fn or configure_fn, (
|
|
+ "Spec requires per-room enablement."
|
|
+ )
|
|
+
|
|
+ hist = get_history(room="__t:default_disabled__", limit=10, namespace="/")
|
|
+ assert isinstance(hist, list)
|
|
+ assert hist == [], "History must be empty before enabling."
|
|
+
|
|
+ sio.emit("x_disabled", 1, room="__t:default_disabled__", namespace="/")
|
|
+ hist2 = get_history(room="__t:default_disabled__", limit=10, namespace="/")
|
|
+ assert hist2 == []
|
|
+
|
|
+
|
|
+def test_ordering_and_schema_after_enable():
|
|
+ """After enabling, messages should be recorded with correct schema and ordering."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room = "__t:order__"
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ if "max_entries" in enable_params:
|
|
+ kwargs["max_entries"] = 50
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ kwargs = {"room": room, "enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"])
|
|
+ if max_key:
|
|
+ kwargs[max_key] = 50
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ sio.emit("message", {"text": "hello"}, room=room)
|
|
+ time.sleep(0.01)
|
|
+ sio.emit("message", {"text": "world"}, room=room)
|
|
+ time.sleep(0.01)
|
|
+ sio.emit("message", {"text": "!"}, room=room)
|
|
+
|
|
+ hist = get_history(room=room, limit=2, namespace="/")
|
|
+ assert isinstance(hist, list)
|
|
+ assert len(hist) == 2
|
|
+ assert [e["data"]["text"] for e in hist] == ["world", "!"]
|
|
+
|
|
+ now = time.time()
|
|
+ for e in hist:
|
|
+ assert "event" in e and "data" in e and "timestamp" in e
|
|
+ assert isinstance(e["timestamp"], float)
|
|
+ assert now - 24 * 3600 <= e["timestamp"] <= now + 5
|
|
+
|
|
+
|
|
+def test_include_only_and_exclude_only_filters_required():
|
|
+ """Include and exclude filters must work correctly."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room = "__t:filters__"
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ kwargs = {"room": room, "enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ sio.emit("state", {"v": 1}, room=room)
|
|
+ sio.emit("info", {"v": 2}, room=room)
|
|
+ sio.emit("other", {"v": 3}, room=room)
|
|
+
|
|
+ inc = get_history(
|
|
+ room=room, limit=10, namespace="/",
|
|
+ include_events=["state", "info"], exclude_events=None, payload_size_cap=None
|
|
+ )
|
|
+ assert all(h["event"] in {"state", "info"} for h in inc)
|
|
+
|
|
+ exc = get_history(
|
|
+ room=room, limit=10, namespace="/",
|
|
+ include_events=None, exclude_events=["info"], payload_size_cap=None
|
|
+ )
|
|
+ assert all(h["event"] != "info" for h in exc)
|
|
+
|
|
+ both = get_history(
|
|
+ room=room, limit=10, namespace="/",
|
|
+ include_events=("state", "info"), exclude_events={"info"}, payload_size_cap=None
|
|
+ )
|
|
+ assert all(h["event"] in {"state", "info"} for h in both)
|
|
+ assert all(h["event"] != "info" for h in both)
|
|
+
|
|
+def test_stats_getter_is_per_room():
|
|
+ """Stats getter must accept room parameter and return room-specific stats."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ stats_fn, fields_alias = discover_stats_getter(sio)
|
|
+ assert stats_fn is not None, "Spec requires stats getter"
|
|
+
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ r1, r2 = "__t:stats_room:1__", "__t:stats_room:2__"
|
|
+
|
|
+ # Enable both rooms
|
|
+ if enable_fn:
|
|
+ kwargs = {}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ if "max_entries" in enable_params:
|
|
+ kwargs["max_entries"] = 10
|
|
+ enable_fn(room=r1, **kwargs)
|
|
+ enable_fn(room=r2, **kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ base = {"enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ base["namespace"] = "/"
|
|
+ max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"])
|
|
+ if max_key:
|
|
+ base[max_key] = 10
|
|
+ configure_fn(room=r1, **base)
|
|
+ configure_fn(room=r2, **base)
|
|
+
|
|
+ # Emit different counts to each room
|
|
+ sio.emit("a", 1, room=r1)
|
|
+ sio.emit("a", 2, room=r1)
|
|
+ sio.emit("a", 3, room=r1)
|
|
+ sio.emit("b", 1, room=r2)
|
|
+
|
|
+ # Stats should be per-room, not global
|
|
+ s1 = stats_fn(room=r1, namespace="/")
|
|
+ s2 = stats_fn(room=r2, namespace="/")
|
|
+
|
|
+ keys1 = pick_stat_keys(s1, fields_alias)
|
|
+ keys2 = pick_stat_keys(s2, fields_alias)
|
|
+
|
|
+ # Room 1 has 3 entries, Room 2 has 1
|
|
+ assert s1[keys1["entries"]] == 3, "Stats must be per-room"
|
|
+ assert s2[keys2["entries"]] == 1, "Stats must be per-room"
|
|
+
|
|
+def test_size_eviction_required():
|
|
+ """Size-based eviction must work correctly (required feature)."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room1 = "__t:evict:size__"
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room1}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ if "max_entries" in enable_params:
|
|
+ kwargs["max_entries"] = 2
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ enable_fn(**kwargs)
|
|
+ if configure_fn:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"])
|
|
+ if max_key:
|
|
+ cfg_kwargs = {"room": room1, "enabled": True, max_key: 2}
|
|
+ if "namespace" in cfg_sig:
|
|
+ cfg_kwargs["namespace"] = "/"
|
|
+ configure_fn(**cfg_kwargs)
|
|
+ else:
|
|
+ pytest.fail("Spec requires a size cap knob; none discovered")
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"])
|
|
+ assert max_key is not None, "Spec requires a size cap knob"
|
|
+ cfg_kwargs = {"room": room1, "enabled": True, max_key: 2}
|
|
+ if "namespace" in cfg_sig:
|
|
+ cfg_kwargs["namespace"] = "/"
|
|
+ configure_fn(**cfg_kwargs)
|
|
+
|
|
+ for i in range(5):
|
|
+ sio.emit("message", {"i": i}, room=room1)
|
|
+
|
|
+ h1 = get_history(room=room1, limit=10, namespace="/")
|
|
+ assert [e["data"]["i"] for e in h1] == [3, 4]
|
|
+
|
|
+def test_api_requirements_accessible_on_server():
|
|
+ """Required methods must be accessible directly on socketio.Server."""
|
|
+ sio = _mk_server()
|
|
+
|
|
+ assert hasattr(sio, 'enable_history') and callable(getattr(sio, 'enable_history'))
|
|
+ assert hasattr(sio, 'disable_history') and callable(getattr(sio, 'disable_history'))
|
|
+ assert hasattr(sio, 'get_history') and callable(getattr(sio, 'get_history'))
|
|
+ assert hasattr(sio, 'get_history_stats') and callable(getattr(sio, 'get_history_stats'))
|
|
+
|
|
+ sig_enable = inspect.signature(sio.enable_history)
|
|
+ assert 'room' in sig_enable.parameters
|
|
+
|
|
+ sig_disable = inspect.signature(sio.disable_history)
|
|
+ assert 'room' in sig_disable.parameters
|
|
+
|
|
+ sig_get = inspect.signature(sio.get_history)
|
|
+ assert 'room' in sig_get.parameters
|
|
+ assert 'limit' in sig_get.parameters
|
|
+
|
|
+ sig_stats = inspect.signature(sio.get_history_stats)
|
|
+ assert 'room' in sig_stats.parameters
|
|
+
|
|
+def test_filter_ordering_include_then_exclude():
|
|
+ """When both filters provided, include must be applied first, then exclude."""
|
|
+ sio = _mk_server()
|
|
+
|
|
+ room = "__t:filter_order__"
|
|
+ sio.enable_history(room=room)
|
|
+
|
|
+ sio.emit("A", {"val": 1}, room=room)
|
|
+ sio.emit("B", {"val": 2}, room=room)
|
|
+ sio.emit("C", {"val": 3}, room=room)
|
|
+ sio.emit("D", {"val": 4}, room=room)
|
|
+
|
|
+ result = sio.get_history(
|
|
+ room=room, limit=10,
|
|
+ include_events=["A", "B"],
|
|
+ exclude_events=["B", "D"]
|
|
+ )
|
|
+
|
|
+ events = [e["event"] for e in result]
|
|
+ assert "A" in events, "A should be included"
|
|
+ assert "B" not in events, "B should be excluded (after being included)"
|
|
+ assert "C" not in events, "C should not be included"
|
|
+ assert "D" not in events, "D should be excluded"
|
|
+ assert len(events) == 1, "Only event A should remain"
|
|
+
|
|
+ result2 = sio.get_history(
|
|
+ room=room, limit=10,
|
|
+ include_events=["B"],
|
|
+ exclude_events=["B"]
|
|
+ )
|
|
+ assert len(result2) == 0, "Include must be applied first, then exclude"
|
|
+
|
|
+def test_time_retention_optional():
|
|
+ """Time-based eviction (optional feature per spec)."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room2 = "__t:evict:time__"
|
|
+
|
|
+ # Check if TTL is supported
|
|
+ ttl_supported = False
|
|
+ ret_key = None
|
|
+
|
|
+ if enable_fn and "retention_seconds" in enable_params:
|
|
+ ttl_supported = True
|
|
+ elif configure_fn:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ ret_key = _pick_alias(cfg_sig, ["retention_seconds", "retention", "ttl"])
|
|
+ if ret_key:
|
|
+ ttl_supported = True
|
|
+
|
|
+ if not ttl_supported:
|
|
+ pytest.skip("Time-based expiry is optional per spec; not supported by this implementation")
|
|
+
|
|
+ if enable_fn and "retention_seconds" in enable_params:
|
|
+ kwargs = {"room": room2, "retention_seconds": 0.5}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ ret_key = _pick_alias(cfg_sig, ["retention_seconds", "retention", "ttl"])
|
|
+ cfg_kwargs = {"room": room2, "enabled": True, ret_key: 0.5}
|
|
+ if "namespace" in cfg_sig:
|
|
+ cfg_kwargs["namespace"] = "/"
|
|
+ configure_fn(**cfg_kwargs)
|
|
+
|
|
+ sio.emit("m", {"x": 1}, room=room2)
|
|
+ time.sleep(0.7)
|
|
+ h2 = get_history(room=room2, limit=10, namespace="/")
|
|
+ assert h2 == []
|
|
+
|
|
+
|
|
+def test_namespace_isolation_using_fetch_namespace():
|
|
+ """History must be isolated by namespace."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room = "__t:ns__"
|
|
+ ns1 = "/"
|
|
+ ns2 = "/admin"
|
|
+
|
|
+ if enable_fn:
|
|
+ if "namespace" in enable_params:
|
|
+ enable_fn(room=room, namespace=ns1)
|
|
+ enable_fn(room=room, namespace=ns2)
|
|
+ else:
|
|
+ enable_fn(room=room)
|
|
+ elif configure_fn:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ if "namespace" in cfg_sig:
|
|
+ configure_fn(room=room, enabled=True, namespace=ns1)
|
|
+ configure_fn(room=room, enabled=True, namespace=ns2)
|
|
+ else:
|
|
+ configure_fn(room=room, enabled=True)
|
|
+
|
|
+ sio.emit("e1", {"n": 1}, room=room, namespace=ns1)
|
|
+ sio.emit("e2", {"n": 2}, room=room, namespace=ns2)
|
|
+
|
|
+ h1 = get_history(room=room, limit=10, namespace=ns1)
|
|
+ h2 = get_history(room=room, limit=10, namespace=ns2)
|
|
+
|
|
+ assert any(e["event"] == "e1" for e in h1)
|
|
+ assert not any(e["event"] == "e2" for e in h1)
|
|
+ assert any(e["event"] == "e2" for e in h2)
|
|
+ assert not any(e["event"] == "e1" for e in h2)
|
|
+
|
|
+
|
|
+def test_limit_applies_after_filters_oldest_to_newest():
|
|
+ """Limit should apply after filtering, returning oldest-to-newest."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room = "__t:filters_limit__"
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ kwargs = {"room": room, "enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ for i in range(6):
|
|
+ sio.emit("state" if i % 2 == 0 else "info", {"i": i}, room=room)
|
|
+
|
|
+ hist = get_history(
|
|
+ room=room, limit=2, namespace="/",
|
|
+ include_events=["state"], exclude_events=None, payload_size_cap=None
|
|
+ )
|
|
+ assert len(hist) == 2
|
|
+ assert [e["data"]["i"] for e in hist] == [2, 4]
|
|
+
|
|
+
|
|
+def test_default_namespace_fallback_behaves_like_root():
|
|
+ """Omitting namespace should default to root namespace."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room = "__t:ns_default__"
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ kwargs = {"room": room, "enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ sio.emit("x", {"v": 1}, room=room)
|
|
+ h_no_ns = get_history(room=room, limit=10)
|
|
+ h_root = get_history(room=room, limit=10, namespace="/")
|
|
+ assert [e["event"] for e in h_no_ns] == [e["event"] for e in h_root]
|
|
+
|
|
+
|
|
+def test_enable_time_payload_cap_if_supported():
|
|
+ """Enable-time payload cap should apply when fetch-time cap is omitted (if supported)."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room = "__t:cap_enable_only__"
|
|
+ long_txt = "abcdefghijklmnopqrstuvwxyz"
|
|
+
|
|
+ # Check if enable-time cap is supported
|
|
+ applied = False
|
|
+ if enable_fn and "payload_size_cap" in enable_params:
|
|
+ kwargs = {"room": room, "payload_size_cap": 5}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(**kwargs)
|
|
+ applied = True
|
|
+ elif configure_fn:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ cap_key = _pick_alias(cfg_sig, ["payload_size_cap", "cap", "size_cap"])
|
|
+ enabled_key = _pick_alias(cfg_sig, ["enabled", "enable", "on"])
|
|
+ if enabled_key and cap_key:
|
|
+ kwargs = {"room": room, enabled_key: True, cap_key: 5}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ configure_fn(**kwargs)
|
|
+ applied = True
|
|
+
|
|
+ if not applied:
|
|
+ pytest.skip("Payload truncation is optional; not supported by this implementation")
|
|
+
|
|
+ sio.emit("text", long_txt, room=room)
|
|
+ hist = get_history(room=room, limit=10, namespace="/")
|
|
+ txt = next(e for e in hist if e["event"] == "text")["data"]
|
|
+ assert isinstance(txt, str) and len(txt) <= 5
|
|
+
|
|
+def test_payload_size_cap_at_fetch_time_if_supported():
|
|
+ """Payload size cap should truncate strings and bytes at fetch time (if supported)."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ # Check if fetch-time payload cap is supported
|
|
+ try:
|
|
+ sig = inspect.signature(get_history.__wrapped__)
|
|
+ except AttributeError:
|
|
+ sig = inspect.signature(get_history)
|
|
+
|
|
+ params = set(sig.parameters.keys())
|
|
+ cap_param = _pick_alias(params, ["payload_size_cap", "cap", "size_cap"])
|
|
+
|
|
+ if cap_param is None:
|
|
+ pytest.skip("Fetch-time payload truncation is optional; not supported by this implementation")
|
|
+
|
|
+ room = "__t:cap__"
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ kwargs = {"room": room, "enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ sio.emit("text", "abcdefghij", room=room)
|
|
+ sio.emit("blob", b"ABCDEFGHIJ", room=room)
|
|
+
|
|
+ hist = get_history(
|
|
+ room=room, limit=10, namespace="/",
|
|
+ include_events=None, exclude_events=None, payload_size_cap=5
|
|
+ )
|
|
+ txt = next(e for e in hist if e["event"] == "text")["data"]
|
|
+ blob = next(e for e in hist if e["event"] == "blob")["data"]
|
|
+ assert txt == "abcde"
|
|
+ assert blob == b"ABCDE"
|
|
+
|
|
+
|
|
+def test_fetch_time_cap_precedence_if_both_supported():
|
|
+ """Fetch-time payload cap takes precedence over enable-time cap (if both supported)."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ # Check if fetch-time cap is supported
|
|
+ try:
|
|
+ sig = inspect.signature(get_history.__wrapped__)
|
|
+ except AttributeError:
|
|
+ sig = inspect.signature(get_history)
|
|
+
|
|
+ params = set(sig.parameters.keys())
|
|
+ fetch_cap_param = _pick_alias(params, ["payload_size_cap", "cap", "size_cap"])
|
|
+
|
|
+ if fetch_cap_param is None:
|
|
+ pytest.skip("Fetch-time payload truncation is optional; not supported")
|
|
+
|
|
+ # Check if enable-time cap is supported
|
|
+ enable_cap_supported = False
|
|
+ if enable_fn and "payload_size_cap" in enable_params:
|
|
+ enable_cap_supported = True
|
|
+ elif configure_fn:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ if _pick_alias(cfg_sig, ["payload_size_cap", "cap", "size_cap"]):
|
|
+ enable_cap_supported = True
|
|
+
|
|
+ if not enable_cap_supported:
|
|
+ pytest.skip("Enable-time payload cap not supported; cannot test precedence")
|
|
+
|
|
+ room = "__t:cap-precedence__"
|
|
+
|
|
+ # Set enable-time cap to 10
|
|
+ if enable_fn and "payload_size_cap" in enable_params:
|
|
+ kwargs = {"room": room, "payload_size_cap": 10}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ cap_key = _pick_alias(cfg_sig, ["payload_size_cap", "cap", "size_cap"])
|
|
+ kwargs = {"room": room, "enabled": True, cap_key: 10}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ sio.emit("text", "abcdefghij", room=room)
|
|
+
|
|
+ # Fetch with smaller cap (5); should override enable-time cap (10)
|
|
+ hist = get_history(room=room, limit=10, namespace="/", payload_size_cap=5)
|
|
+ txt = next(e for e in hist if e["event"] == "text")["data"]
|
|
+ assert txt == "abcde", "Fetch-time cap should take precedence"
|
|
+
|
|
+def test_cross_room_isolation_same_namespace():
|
|
+ """Different rooms in same namespace must have isolated history."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ r1, r2 = "__t:rooms:1__", "__t:rooms:2__"
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(room=r1, **kwargs)
|
|
+ enable_fn(room=r2, **kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ base = {"enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ base["namespace"] = "/"
|
|
+ configure_fn(room=r1, **base)
|
|
+ configure_fn(room=r2, **base)
|
|
+
|
|
+ sio.emit("a", 1, room=r1)
|
|
+ sio.emit("b", 2, room=r2)
|
|
+
|
|
+ h1 = get_history(room=r1, limit=10, namespace="/")
|
|
+ h2 = get_history(room=r2, limit=10, namespace="/")
|
|
+
|
|
+ assert any(e["event"] == "a" for e in h1)
|
|
+ assert not any(e["event"] == "b" for e in h1)
|
|
+ assert any(e["event"] == "b" for e in h2)
|
|
+ assert not any(e["event"] == "a" for e in h2)
|
|
+
|
|
+
|
|
+def test_stats_required_with_size_eviction():
|
|
+ """Stats getter must return required fields with size eviction tracking."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ stats_fn, fields_alias = discover_stats_getter(sio)
|
|
+ assert stats_fn is not None, "Spec requires basic stats for observability"
|
|
+
|
|
+ enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room = "__t:stats__"
|
|
+
|
|
+ configured = False
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ if "max_entries" in enable_params:
|
|
+ kwargs["max_entries"] = 3
|
|
+ enable_fn(**kwargs)
|
|
+ configured = True
|
|
+
|
|
+ # Also try to configure via configure_fn if max_entries not in enable
|
|
+ if "max_entries" not in enable_params and configure_fn:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"])
|
|
+ if max_key:
|
|
+ cfg_kwargs = {"room": room, "enabled": True, max_key: 3}
|
|
+ if "namespace" in cfg_sig:
|
|
+ cfg_kwargs["namespace"] = "/"
|
|
+ try:
|
|
+ configure_fn(**cfg_kwargs)
|
|
+ except TypeError:
|
|
+ pass
|
|
+ elif configure_fn:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ cfg_kwargs = {"room": room, "enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ cfg_kwargs["namespace"] = "/"
|
|
+ max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"])
|
|
+ assert max_key, "Spec requires size limit parameter"
|
|
+ cfg_kwargs[max_key] = 3
|
|
+ configure_fn(**cfg_kwargs)
|
|
+ configured = True
|
|
+
|
|
+ assert configured, "Spec requires per-room enable/configuration"
|
|
+
|
|
+ s0 = stats_fn(room=room, namespace="/")
|
|
+ assert isinstance(s0, dict)
|
|
+ keys = pick_stat_keys(s0, fields_alias)
|
|
+
|
|
+ sio.emit("a", 1, room=room)
|
|
+ sio.emit("b", 2, room=room)
|
|
+ sio.emit("c", 3, room=room)
|
|
+ sio.emit("d", 4, room=room)
|
|
+
|
|
+ s1 = stats_fn(room=room, namespace="/")
|
|
+ assert s1[keys["entries"]] >= 0
|
|
+ assert s1[keys["evict_size"]] >= s0[keys["evict_size"]]
|
|
+
|
|
+ for k in keys.values():
|
|
+ assert s1[k] >= 0
|
|
+
|
|
+
|
|
+def test_disable_stops_new_recordings_and_getter_returns_empty():
|
|
+ """Disabling history must stop recording and return empty list."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, disable_fn, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ assert enable_fn or configure_fn, "Spec requires a per-room enable/configure mechanism."
|
|
+ assert disable_fn or configure_fn, "Spec requires a way to disable."
|
|
+
|
|
+ room = "__t:disable:getter__"
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ if "max_entries" in enable_params:
|
|
+ kwargs["max_entries"] = 5
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ kwargs = {"room": room, "enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"])
|
|
+ if max_key:
|
|
+ kwargs[max_key] = 5
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ sio.emit("before", {"v": 1}, room=room)
|
|
+ assert len(get_history(room=room, limit=10, namespace="/")) >= 1
|
|
+
|
|
+ if disable_fn:
|
|
+ disable_fn(room=room, namespace="/")
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ kwargs = {"room": room, "enabled": False}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ sio.emit("after_disable", {"v": 2}, room=room)
|
|
+ assert get_history(room=room, limit=10, namespace="/") == []
|
|
+
|
|
+ if enable_fn:
|
|
+ kwargs = {"room": room}
|
|
+ if "namespace" in enable_params:
|
|
+ kwargs["namespace"] = "/"
|
|
+ enable_fn(**kwargs)
|
|
+ else:
|
|
+ cfg_sig = set(inspect.signature(configure_fn).parameters.keys())
|
|
+ kwargs = {"room": room, "enabled": True}
|
|
+ if "namespace" in cfg_sig:
|
|
+ kwargs["namespace"] = "/"
|
|
+ configure_fn(**kwargs)
|
|
+
|
|
+ sio.emit("after_reenable", {"v": 3}, room=room)
|
|
+ reh = get_history(room=room, limit=10, namespace="/")
|
|
+ assert any(e["event"] == "after_reenable" for e in reh)
|
|
+ assert not any(e["event"] == "after_disable" for e in reh), \
|
|
+ "Events emitted while disabled MUST NOT surface after re-enabling"
|
|
+
|
|
+
|
|
+def test_re_enable_starts_fresh():
|
|
+ """Re-enabling a room must start with empty history (no retained entries)."""
|
|
+ sio = _mk_server()
|
|
+ get_history = discover_get_history(sio)
|
|
+ enable_fn, disable_fn, configure_fn, enable_params = discover_enable_disable_configure(sio)
|
|
+
|
|
+ room = "__t:re_enable_fresh__"
|
|
+
|
|
+ # Enable and emit
|
|
+ if enable_fn:
|
|
+ enable_fn(room=room, namespace="/")
|
|
+ else:
|
|
+ configure_fn(room=room, enabled=True, namespace="/")
|
|
+
|
|
+ sio.emit("old_event", {"data": "old"}, room=room)
|
|
+ assert len(get_history(room=room, limit=10, namespace="/")) == 1
|
|
+
|
|
+ # Disable
|
|
+ if disable_fn:
|
|
+ disable_fn(room=room, namespace="/")
|
|
+ else:
|
|
+ configure_fn(room=room, enabled=False, namespace="/")
|
|
+
|
|
+ # Re-enable
|
|
+ if enable_fn:
|
|
+ enable_fn(room=room, namespace="/")
|
|
+ else:
|
|
+ configure_fn(room=room, enabled=True, namespace="/")
|
|
+
|
|
+ # History must be empty after re-enable
|
|
+ hist = get_history(room=room, limit=10, namespace="/")
|
|
+ assert hist == [], "Re-enabling must start with fresh history"
|
|
+
|
|
+ # New events should be recorded
|
|
+ sio.emit("new_event", {"data": "new"}, room=room)
|
|
+ hist = get_history(room=room, limit=10, namespace="/")
|
|
+ assert len(hist) == 1
|
|
+ assert hist[0]["event"] == "new_event"
|
|
+
|
|
+
|
|
[email protected](reason="Non-functional: best-effort/non-blocking not reliably testable")
|
|
+def test_recording_is_best_effort_and_non_blocking():
|
|
+ """Placeholder for non-functional requirement."""
|
|
+ pass
|
|
+
|
|
+
|
|
[email protected](reason="Non-functional: single-process/in-memory not verifiable in unit tests")
|
|
+def test_in_memory_single_process_only():
|
|
+ """Placeholder for non-functional requirement."""
|
|
+ pass
|
|
\ No newline at end of file
|
|
diff --git a/test.sh b/test.sh
|
|
new file mode 100755
|
|
index 0000000..a9834d1
|
|
--- /dev/null
|
|
+++ b/test.sh
|
|
@@ -0,0 +1,49 @@
|
|
+#!/usr/bin/env bash
|
|
+set -euo pipefail
|
|
+
|
|
+# Test runner for python-socketio repository
|
|
+# Usage:
|
|
+# ./test.sh base # run existing tests (should pass at base commit)
|
|
+# ./test.sh new # run only the newly added tests
|
|
+
|
|
+cmd_exists() {
|
|
+ command -v "$1" >/dev/null 2>&1
|
|
+}
|
|
+
|
|
+run_pytest() {
|
|
+ if cmd_exists pytest; then
|
|
+ pytest -q "$@"
|
|
+ else
|
|
+ python -m pytest -q "$@"
|
|
+ fi
|
|
+}
|
|
+
|
|
+# Ensure an argument is provided before entering case
|
|
+if [ $# -lt 1 ]; then
|
|
+ echo "Usage: ./test.sh {base|new}" >&2
|
|
+ exit 1
|
|
+fi
|
|
+
|
|
+case "$1" in
|
|
+ base)
|
|
+ # Run the repository's existing test suite only
|
|
+ if [ -d tests ]; then
|
|
+ run_pytest tests/
|
|
+ else
|
|
+ echo "ERROR: Base suite not found at ./tests/. Provide baseline tests or adjust runner." >&2
|
|
+ exit 2
|
|
+ fi
|
|
+ ;;
|
|
+ new)
|
|
+ if [ -f new_tests/test_message_history.py ]; then
|
|
+ run_pytest new_tests/test_message_history.py
|
|
+ else
|
|
+ echo "ERROR: New test file not found: new_tests/test_message_history.py" >&2
|
|
+ exit 2
|
|
+ fi
|
|
+ ;;
|
|
+ *)
|
|
+ echo "Usage: ./test.sh {base|new}" >&2
|
|
+ exit 1
|
|
+ ;;
|
|
+esac
|
|
|