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.
950 lines
34 KiB
950 lines
34 KiB
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"
|
|
|
|
|
|
@pytest.mark.skip(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
|
|
|
|
|
|
@pytest.mark.skip(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
|