"""Tests for the multi-user AI bridge — session isolation, crisis detection, HTTP endpoints.""" import asyncio import json import time import pytest from nexus.multi_user_bridge import ( CRISIS_988_MESSAGE, CrisisState, MultiUserBridge, SessionManager, UserSession, ) # ── Session Isolation ───────────────────────────────────────── class TestSessionIsolation: def test_separate_users_have_independent_history(self): mgr = SessionManager() s1 = mgr.get_or_create("alice", "Alice", "Tower") s2 = mgr.get_or_create("bob", "Bob", "Tower") s1.add_message("user", "hello from alice") s2.add_message("user", "hello from bob") assert len(s1.message_history) == 1 assert len(s2.message_history) == 1 assert s1.message_history[0]["content"] == "hello from alice" assert s2.message_history[0]["content"] == "hello from bob" def test_same_user_reuses_session(self): mgr = SessionManager() s1 = mgr.get_or_create("alice", "Alice", "Tower") s1.add_message("user", "msg1") s2 = mgr.get_or_create("alice", "Alice", "Tower") s2.add_message("user", "msg2") assert s1 is s2 assert len(s1.message_history) == 2 def test_room_transitions_track_occupants(self): mgr = SessionManager() mgr.get_or_create("alice", "Alice", "Tower") mgr.get_or_create("bob", "Bob", "Tower") assert set(mgr.get_room_occupants("Tower")) == {"alice", "bob"} # Alice moves mgr.get_or_create("alice", "Alice", "Chapel") assert mgr.get_room_occupants("Tower") == ["bob"] assert mgr.get_room_occupants("Chapel") == ["alice"] def test_max_sessions_evicts_oldest(self): mgr = SessionManager(max_sessions=2) mgr.get_or_create("a", "A", "Tower") time.sleep(0.01) mgr.get_or_create("b", "B", "Tower") time.sleep(0.01) mgr.get_or_create("c", "C", "Tower") assert mgr.get("a") is None # evicted assert mgr.get("b") is not None assert mgr.get("c") is not None assert mgr.active_count == 2 def test_history_window(self): s = UserSession(user_id="test", username="Test") for i in range(30): s.add_message("user", f"msg{i}") assert len(s.message_history) == 30 recent = s.get_history(window=5) assert len(recent) == 5 assert recent[-1]["content"] == "msg29" def test_session_to_dict(self): s = UserSession(user_id="alice", username="Alice", room="Chapel") s.add_message("user", "hello") d = s.to_dict() assert d["user_id"] == "alice" assert d["username"] == "Alice" assert d["room"] == "Chapel" assert d["message_count"] == 1 assert d["command_count"] == 1 # ── Crisis Detection ────────────────────────────────────────── class TestCrisisDetection: def test_no_crisis_on_normal_messages(self): cs = CrisisState() assert cs.check("hello world") is False assert cs.check("how are you") is False def test_crisis_triggers_after_3_turns(self): cs = CrisisState() assert cs.check("I want to die") is False # turn 1 assert cs.check("I want to die") is False # turn 2 assert cs.check("I want to die") is True # turn 3 -> deliver 988 def test_crisis_resets_on_normal_message(self): cs = CrisisState() cs.check("I want to die") # turn 1 cs.check("actually never mind") # resets assert cs.turn_count == 0 assert cs.check("I want to die") is False # turn 1 again def test_crisis_delivers_once_per_window(self): cs = CrisisState() cs.check("I want to die") cs.check("I want to die") assert cs.check("I want to die") is True # delivered assert cs.check("I want to die") is False # already delivered def test_crisis_pattern_variations(self): cs = CrisisState() assert cs.check("I want to kill myself") is False # flagged, turn 1 assert cs.check("I want to kill myself") is False # turn 2 assert cs.check("I want to kill myself") is True # turn 3 def test_crisis_expired_window_redelivers(self): cs = CrisisState() cs.CRISIS_WINDOW_SECONDS = 0.1 cs.check("I want to die") cs.check("I want to die") assert cs.check("I want to die") is True time.sleep(0.15) # New window — should redeliver after 1 turn since window expired assert cs.check("I want to die") is True def test_self_harm_pattern(self): cs = CrisisState() # Note: "self-harming" doesn't match (has trailing "ing"), "self-harm" does assert cs.check("I've been doing self-harm") is False # turn 1 assert cs.check("self harm is getting worse") is False # turn 2 assert cs.check("I can't stop self-harm") is True # turn 3 # ── HTTP Endpoint Tests (requires aiohttp test client) ──────── @pytest.fixture async def bridge_app(): bridge = MultiUserBridge() app = bridge.create_app() yield app, bridge @pytest.fixture async def client(bridge_app): from aiohttp.test_utils import TestClient, TestServer app, bridge = bridge_app async with TestClient(TestServer(app)) as client: yield client, bridge class TestHTTPEndpoints: @pytest.mark.asyncio async def test_health_endpoint(self, client): c, bridge = client resp = await c.get("/bridge/health") data = await resp.json() assert data["status"] == "ok" assert data["active_sessions"] == 0 @pytest.mark.asyncio async def test_chat_creates_session(self, client): c, bridge = client resp = await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hello", "room": "Tower", }) data = await resp.json() assert "response" in data assert data["user_id"] == "alice" assert data["room"] == "Tower" assert data["session_messages"] == 2 # user + assistant @pytest.mark.asyncio async def test_chat_missing_user_id(self, client): c, _ = client resp = await c.post("/bridge/chat", json={"message": "hello"}) assert resp.status == 400 @pytest.mark.asyncio async def test_chat_missing_message(self, client): c, _ = client resp = await c.post("/bridge/chat", json={"user_id": "alice"}) assert resp.status == 400 @pytest.mark.asyncio async def test_sessions_list(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hey", "room": "Chapel" }) resp = await c.get("/bridge/sessions") data = await resp.json() assert data["total"] == 2 user_ids = {s["user_id"] for s in data["sessions"]} assert user_ids == {"alice", "bob"} @pytest.mark.asyncio async def test_look_command_returns_occupants(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hey", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "look", "room": "Tower" }) data = await resp.json() assert "bob" in data["response"].lower() or "bob" in str(data.get("room_occupants", [])) @pytest.mark.asyncio async def test_room_occupants_tracked(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hey", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "look", "room": "Tower" }) data = await resp.json() assert set(data["room_occupants"]) == {"alice", "bob"} @pytest.mark.asyncio async def test_crisis_detection_returns_flag(self, client): c, _ = client for i in range(3): resp = await c.post("/bridge/chat", json={ "user_id": "user1", "message": "I want to die", }) data = await resp.json() assert data["crisis_detected"] is True assert "988" in data["response"] @pytest.mark.asyncio async def test_concurrent_users_independent_responses(self, client): c, _ = client r1 = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "I love cats" }) r2 = await c.post("/bridge/chat", json={ "user_id": "bob", "message": "I love dogs" }) d1 = await r1.json() d2 = await r2.json() # Each user's response references their own message assert "cats" in d1["response"].lower() or d1["user_id"] == "alice" assert "dogs" in d2["response"].lower() or d2["user_id"] == "bob" assert d1["user_id"] != d2["user_id"] # ── Room Broadcast Tests ───────────────────────────────────── class TestRoomBroadcast: @pytest.mark.asyncio async def test_say_broadcasts_to_room_occupants(self, client): c, _ = client # Position both users in the same room await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "username": "Bob", "message": "hi", "room": "Tower" }) # Alice says something await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "say Hello everyone!", "room": "Tower" }) # Bob should have a pending room event resp = await c.get("/bridge/room_events/bob") data = await resp.json() assert data["count"] >= 1 assert any("Alice" in e.get("message", "") for e in data["events"]) @pytest.mark.asyncio async def test_say_does_not_echo_to_speaker(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "alice", "message": 'say Hello!', "room": "Tower" }) # Alice should NOT have room events from herself resp = await c.get("/bridge/room_events/alice") data = await resp.json() alice_events = [e for e in data["events"] if e.get("from_user") == "alice"] assert len(alice_events) == 0 @pytest.mark.asyncio async def test_say_no_broadcast_to_different_room(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hi", "room": "Chapel" }) await c.post("/bridge/chat", json={ "user_id": "alice", "message": 'say Hello!', "room": "Tower" }) # Bob is in Chapel, shouldn't get Tower broadcasts resp = await c.get("/bridge/room_events/bob") data = await resp.json() assert data["count"] == 0 @pytest.mark.asyncio async def test_room_events_drain_after_read(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "alice", "message": 'say First!', "room": "Tower" }) # First read drains resp = await c.get("/bridge/room_events/bob") data = await resp.json() assert data["count"] >= 1 # Second read is empty resp2 = await c.get("/bridge/room_events/bob") data2 = await resp2.json() assert data2["count"] == 0 @pytest.mark.asyncio async def test_room_events_404_for_unknown_user(self, client): c, _ = client resp = await c.get("/bridge/room_events/nonexistent") assert resp.status == 404 @pytest.mark.asyncio async def test_rooms_lists_all_rooms_with_occupants(self, client): c, bridge = client await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "username": "Bob", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "carol", "username": "Carol", "message": "hi", "room": "Library" }) resp = await c.get("/bridge/rooms") assert resp.status == 200 data = await resp.json() assert data["total_rooms"] == 2 assert data["total_users"] == 3 assert "Tower" in data["rooms"] assert "Library" in data["rooms"] assert data["rooms"]["Tower"]["count"] == 2 assert data["rooms"]["Library"]["count"] == 1 tower_users = {o["user_id"] for o in data["rooms"]["Tower"]["occupants"]} assert tower_users == {"alice", "bob"} @pytest.mark.asyncio async def test_rooms_empty_when_no_sessions(self, client): c, _ = client resp = await c.get("/bridge/rooms") data = await resp.json() assert data["total_rooms"] == 0 assert data["total_users"] == 0 assert data["rooms"] == {} # ── Rate Limiting Tests ────────────────────────────────────── @pytest.fixture async def rate_limited_client(): """Bridge with very low rate limit for testing.""" from aiohttp.test_utils import TestClient, TestServer bridge = MultiUserBridge(rate_limit=3, rate_window=60.0) app = bridge.create_app() async with TestClient(TestServer(app)) as client: yield client, bridge class TestRateLimitingHTTP: @pytest.mark.asyncio async def test_allowed_within_limit(self, rate_limited_client): c, _ = rate_limited_client for i in range(3): resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": f"msg {i}", }) assert resp.status == 200 @pytest.mark.asyncio async def test_returns_429_on_exceed(self, rate_limited_client): c, _ = rate_limited_client for i in range(3): await c.post("/bridge/chat", json={ "user_id": "alice", "message": f"msg {i}", }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "one too many", }) assert resp.status == 429 data = await resp.json() assert "rate limit" in data["error"].lower() @pytest.mark.asyncio async def test_rate_limit_headers_on_success(self, rate_limited_client): c, _ = rate_limited_client resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hello", }) assert resp.status == 200 assert "X-RateLimit-Limit" in resp.headers assert "X-RateLimit-Remaining" in resp.headers assert resp.headers["X-RateLimit-Limit"] == "3" assert resp.headers["X-RateLimit-Remaining"] == "2" @pytest.mark.asyncio async def test_rate_limit_headers_on_reject(self, rate_limited_client): c, _ = rate_limited_client for _ in range(3): await c.post("/bridge/chat", json={ "user_id": "alice", "message": "msg", }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "excess", }) assert resp.status == 429 assert resp.headers.get("Retry-After") == "1" assert resp.headers.get("X-RateLimit-Remaining") == "0" @pytest.mark.asyncio async def test_rate_limit_is_per_user(self, rate_limited_client): c, _ = rate_limited_client # Exhaust alice for _ in range(3): await c.post("/bridge/chat", json={ "user_id": "alice", "message": "msg", }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "blocked", }) assert resp.status == 429 # Bob should still work resp2 = await c.post("/bridge/chat", json={ "user_id": "bob", "message": "im fine", }) assert resp2.status == 200 # ── Stats Endpoint Tests ───────────────────────────────────── class TestStatsEndpoint: @pytest.mark.asyncio async def test_stats_empty_bridge(self, client): c, _ = client resp = await c.get("/bridge/stats") assert resp.status == 200 data = await resp.json() assert data["active_sessions"] == 0 assert data["total_messages"] == 0 assert data["total_commands"] == 0 assert data["room_count"] == 0 assert data["ws_connections"] == 0 assert "uptime_seconds" in data @pytest.mark.asyncio async def test_stats_after_activity(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hey", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "alice", "message": "look", "room": "Tower" }) resp = await c.get("/bridge/stats") data = await resp.json() assert data["active_sessions"] == 2 assert data["total_messages"] == 6 # 3 chats × 2 (user + assistant) = 6 assert data["room_count"] == 1 assert "Tower" in data["rooms"] # ── Go Command Tests ───────────────────────────────────────── class TestGoCommand: @pytest.mark.asyncio async def test_go_changes_room(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "go Chapel", "room": "Tower" }) data = await resp.json() assert "Chapel" in data["response"] assert data["room"] == "Chapel" @pytest.mark.asyncio async def test_go_updates_room_occupants(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hi", "room": "Tower" }) # Alice moves to Chapel await c.post("/bridge/chat", json={ "user_id": "alice", "message": "go Chapel", "room": "Tower" }) # Tower should only have bob resp = await c.get("/bridge/rooms") data = await resp.json() tower_users = {o["user_id"] for o in data["rooms"]["Tower"]["occupants"]} assert tower_users == {"bob"} @pytest.mark.asyncio async def test_go_notifies_old_room(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "username": "Bob", "message": "hi", "room": "Tower" }) # Alice leaves Tower await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "go Chapel", "room": "Tower" }) # Bob should get a room event about Alice leaving resp = await c.get("/bridge/room_events/bob") data = await resp.json() assert data["count"] >= 1 assert any("Alice" in e.get("message", "") and "Chapel" in e.get("message", "") for e in data["events"]) @pytest.mark.asyncio async def test_go_same_room_rejected(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "go Tower", "room": "Tower" }) data = await resp.json() assert "already" in data["response"].lower() @pytest.mark.asyncio async def test_go_no_room_given(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "go", "room": "Tower" }) data = await resp.json() assert "usage" in data["response"].lower() # ── Emote Command Tests ────────────────────────────────────── class TestEmoteCommand: @pytest.mark.asyncio async def test_emote_broadcasts_to_room(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "username": "Bob", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "emote waves hello", "room": "Tower" }) resp = await c.get("/bridge/room_events/bob") data = await resp.json() assert data["count"] >= 1 assert any("Alice waves hello" in e.get("message", "") for e in data["events"]) @pytest.mark.asyncio async def test_emote_returns_first_person(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "emote dances wildly", "room": "Tower" }) data = await resp.json() assert "dances wildly" in data["response"] assert "Alice" not in data["response"] # first person, no username @pytest.mark.asyncio async def test_emote_no_echo_to_self(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "alice", "message": "emote sits down", "room": "Tower" }) resp = await c.get("/bridge/room_events/alice") data = await resp.json() assert data["count"] == 0 @pytest.mark.asyncio async def test_slash_me_alias(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "username": "Bob", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "/me stretches", "room": "Tower" }) resp = await c.get("/bridge/room_events/bob") data = await resp.json() assert any("Alice stretches" in e.get("message", "") for e in data["events"]) # ── Whisper Command Tests ────────────────────────────────────── class TestWhisperCommand: @pytest.mark.asyncio async def test_whisper_delivers_to_target(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "username": "Bob", "message": "hi", "room": "Tower" }) # Alice whispers to Bob resp = await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "whisper bob secret meeting at midnight", "room": "Tower" }) data = await resp.json() assert "Bob" in data["response"] assert "secret meeting" in data["response"] # Bob should see the whisper resp2 = await c.get("/bridge/room_events/bob") data2 = await resp2.json() assert data2["count"] >= 1 whisper_events = [e for e in data2["events"] if e.get("type") == "whisper"] assert len(whisper_events) >= 1 assert "Alice" in whisper_events[0]["message"] assert "secret meeting" in whisper_events[0]["message"] @pytest.mark.asyncio async def test_whisper_not_visible_to_third_party(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "username": "Bob", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "carol", "username": "Carol", "message": "hi", "room": "Tower" }) # Alice whispers to Bob await c.post("/bridge/chat", json={ "user_id": "alice", "message": "whisper bob secret", "room": "Tower" }) # Carol should NOT see the whisper resp = await c.get("/bridge/room_events/carol") data = await resp.json() whisper_events = [e for e in data["events"] if e.get("type") == "whisper"] assert len(whisper_events) == 0 @pytest.mark.asyncio async def test_whisper_cross_room(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "username": "Alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "username": "Bob", "message": "hi", "room": "Chapel" }) # Alice in Tower whispers to Bob in Chapel (cross-room works!) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "whisper bob come to the tower", "room": "Tower" }) data = await resp.json() assert "Bob" in data["response"] resp2 = await c.get("/bridge/room_events/bob") data2 = await resp2.json() whisper_events = [e for e in data2["events"] if e.get("type") == "whisper"] assert len(whisper_events) >= 1 @pytest.mark.asyncio async def test_whisper_to_nonexistent_user(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "whisper nobody hello", "room": "Tower" }) data = await resp.json() assert "not online" in data["response"].lower() @pytest.mark.asyncio async def test_whisper_to_self_rejected(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "whisper alice hello me", "room": "Tower" }) data = await resp.json() assert "yourself" in data["response"].lower() @pytest.mark.asyncio async def test_whisper_missing_message(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) await c.post("/bridge/chat", json={ "user_id": "bob", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "whisper bob", "room": "Tower" }) data = await resp.json() assert "usage" in data["response"].lower() # ── Inventory Command Tests ──────────────────────────────────── class TestInventoryCommand: @pytest.mark.asyncio async def test_inventory_returns_stub(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "inventory", "room": "Tower" }) data = await resp.json() assert "pockets" in data["response"].lower() or "inventory" in data["response"].lower() @pytest.mark.asyncio async def test_inventory_short_alias(self, client): c, _ = client await c.post("/bridge/chat", json={ "user_id": "alice", "message": "hi", "room": "Tower" }) resp = await c.post("/bridge/chat", json={ "user_id": "alice", "message": "i", "room": "Tower" }) data = await resp.json() assert "pockets" in data["response"].lower() or "inventory" in data["response"].lower()