"""Tests for GET /api/world/state endpoint and /api/world/ws relay.""" import asyncio import json import logging import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from dashboard.routes.world import ( _GROUND_TTL, _REMIND_AFTER, _STALE_THRESHOLD, _bark_and_broadcast, _broadcast, _build_commitment_context, _build_matrix_agents_response, _build_world_state, _commitments, _compute_circular_positions, _conversation, _extract_commitments, _generate_bark, _get_agent_color, _get_agent_shape, _handle_client_message, _heartbeat, _log_bark_failure, _read_presence_file, _record_commitments, _refresh_ground, _tick_commitments, broadcast_world_state, close_commitment, get_commitments, reset_commitments, reset_conversation_ground, ) # --------------------------------------------------------------------------- # _build_world_state # --------------------------------------------------------------------------- def test_build_world_state_maps_fields(): presence = { "version": 1, "liveness": "2026-03-19T02:00:00Z", "mood": "exploring", "current_focus": "reviewing PR", "energy": 0.8, "confidence": 0.9, "active_threads": [{"type": "thinking", "ref": "test", "status": "active"}], "recent_events": [], "concerns": [], } result = _build_world_state(presence) assert result["timmyState"]["mood"] == "exploring" assert result["timmyState"]["activity"] == "reviewing PR" assert result["timmyState"]["energy"] == 0.8 assert result["timmyState"]["confidence"] == 0.9 assert result["updatedAt"] == "2026-03-19T02:00:00Z" assert result["version"] == 1 assert result["visitorPresent"] is False assert len(result["activeThreads"]) == 1 def test_build_world_state_defaults(): """Missing fields get safe defaults.""" result = _build_world_state({}) assert result["timmyState"]["mood"] == "calm" assert result["timmyState"]["energy"] == 0.5 assert result["version"] == 1 # --------------------------------------------------------------------------- # _read_presence_file # --------------------------------------------------------------------------- def test_read_presence_file_missing(tmp_path): with patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"): assert _read_presence_file() is None def test_read_presence_file_stale(tmp_path): f = tmp_path / "presence.json" f.write_text(json.dumps({"version": 1})) # Backdate the file stale_time = time.time() - _STALE_THRESHOLD - 10 import os os.utime(f, (stale_time, stale_time)) with patch("dashboard.routes.world.PRESENCE_FILE", f): assert _read_presence_file() is None def test_read_presence_file_fresh(tmp_path): f = tmp_path / "presence.json" f.write_text(json.dumps({"version": 1, "mood": "focused"})) with patch("dashboard.routes.world.PRESENCE_FILE", f): result = _read_presence_file() assert result is not None assert result["version"] == 1 def test_read_presence_file_bad_json(tmp_path): f = tmp_path / "presence.json" f.write_text("not json {{{") with patch("dashboard.routes.world.PRESENCE_FILE", f): assert _read_presence_file() is None # --------------------------------------------------------------------------- # Full endpoint via TestClient # --------------------------------------------------------------------------- @pytest.fixture def client(): from fastapi import FastAPI from fastapi.testclient import TestClient app = FastAPI() from dashboard.routes.world import router app.include_router(router) return TestClient(app) def test_world_state_endpoint_with_file(client, tmp_path): """Endpoint returns data from presence file when fresh.""" f = tmp_path / "presence.json" f.write_text( json.dumps( { "version": 1, "liveness": "2026-03-19T02:00:00Z", "mood": "exploring", "current_focus": "testing", "active_threads": [], "recent_events": [], "concerns": [], } ) ) with patch("dashboard.routes.world.PRESENCE_FILE", f): resp = client.get("/api/world/state") assert resp.status_code == 200 data = resp.json() assert data["timmyState"]["mood"] == "exploring" assert data["timmyState"]["activity"] == "testing" assert resp.headers["cache-control"] == "no-cache, no-store" def test_world_state_endpoint_fallback(client, tmp_path): """Endpoint falls back to live state when file missing.""" with ( patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"), patch("timmy.workshop_state.get_state_dict") as mock_get, ): mock_get.return_value = { "version": 1, "liveness": "2026-03-19T02:00:00Z", "mood": "calm", "current_focus": "", "active_threads": [], "recent_events": [], "concerns": [], } resp = client.get("/api/world/state") assert resp.status_code == 200 assert resp.json()["timmyState"]["mood"] == "calm" def test_world_state_endpoint_full_fallback(client, tmp_path): """Endpoint returns safe defaults when everything fails.""" with ( patch("dashboard.routes.world.PRESENCE_FILE", tmp_path / "nope.json"), patch( "timmy.workshop_state.get_state_dict", side_effect=RuntimeError("boom"), ), ): resp = client.get("/api/world/state") assert resp.status_code == 200 data = resp.json() assert data["timmyState"]["mood"] == "calm" assert data["version"] == 1 # --------------------------------------------------------------------------- # broadcast_world_state # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_broadcast_world_state_sends_timmy_state(): """broadcast_world_state sends timmy_state JSON to connected clients.""" from dashboard.routes.world import _ws_clients ws = AsyncMock() _ws_clients.append(ws) try: presence = { "version": 1, "mood": "exploring", "current_focus": "testing", "energy": 0.8, "confidence": 0.9, } await broadcast_world_state(presence) ws.send_text.assert_called_once() msg = json.loads(ws.send_text.call_args[0][0]) assert msg["type"] == "timmy_state" assert msg["mood"] == "exploring" assert msg["activity"] == "testing" finally: _ws_clients.clear() @pytest.mark.asyncio async def test_broadcast_world_state_removes_dead_clients(): """Dead WebSocket connections are cleaned up on broadcast.""" from dashboard.routes.world import _ws_clients dead_ws = AsyncMock() dead_ws.send_text.side_effect = ConnectionError("gone") _ws_clients.append(dead_ws) try: await broadcast_world_state({"mood": "idle"}) assert dead_ws not in _ws_clients finally: _ws_clients.clear() def test_world_ws_endpoint_accepts_connection(client): """WebSocket endpoint at /api/world/ws accepts connections.""" with client.websocket_connect("/api/world/ws"): pass # Connection accepted — just close it # --------------------------------------------------------------------------- # WebSocket Authentication Tests # --------------------------------------------------------------------------- class TestWebSocketAuth: """Tests for WebSocket token-based authentication.""" def test_ws_auth_disabled_when_token_unset(self, client): """When matrix_ws_token is empty, auth is disabled (dev mode).""" with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.matrix_ws_token = "" with client.websocket_connect("/api/world/ws") as ws: # Should receive world_state without auth msg = json.loads(ws.receive_text()) assert msg["type"] == "world_state" def test_ws_valid_token_via_query_param(self, client): """Valid token via ?token= query param allows connection.""" with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.matrix_ws_token = "secret123" with client.websocket_connect("/api/world/ws?token=secret123") as ws: # Should receive connection_ack first ack = json.loads(ws.receive_text()) assert ack["type"] == "connection_ack" # Then world_state msg = json.loads(ws.receive_text()) assert msg["type"] == "world_state" def test_ws_valid_token_via_first_message(self, client): """Valid token via first auth message allows connection.""" with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.matrix_ws_token = "secret123" with client.websocket_connect("/api/world/ws") as ws: # Send auth message ws.send_text(json.dumps({"type": "auth", "token": "secret123"})) # Should receive connection_ack ack = json.loads(ws.receive_text()) assert ack["type"] == "connection_ack" # Then world_state msg = json.loads(ws.receive_text()) assert msg["type"] == "world_state" def test_ws_invalid_token_via_query_param(self, client): """Invalid token via ?token= closes connection with code 4001.""" from starlette.websockets import WebSocketDisconnect with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.matrix_ws_token = "secret123" # When auth fails with query param, accept() is called then close() # The test client raises WebSocketDisconnect on close with pytest.raises(WebSocketDisconnect) as exc_info: with client.websocket_connect("/api/world/ws?token=wrongtoken") as ws: # Try to receive - should trigger the close ws.receive_text() assert exc_info.value.code == 4001 def test_ws_invalid_token_via_first_message(self, client): """Invalid token via first message closes connection with code 4001.""" from starlette.websockets import WebSocketDisconnect with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.matrix_ws_token = "secret123" with client.websocket_connect("/api/world/ws") as ws: # Send invalid auth message ws.send_text(json.dumps({"type": "auth", "token": "wrongtoken"})) # Connection should close with 4001 with pytest.raises(WebSocketDisconnect) as exc_info: ws.receive_text() assert exc_info.value.code == 4001 def test_ws_no_token_when_auth_required(self, client): """No token when auth is required closes connection with code 4001.""" from starlette.websockets import WebSocketDisconnect with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.matrix_ws_token = "secret123" with client.websocket_connect("/api/world/ws") as ws: # Send non-auth message without token ws.send_text(json.dumps({"type": "visitor_message", "text": "hello"})) # Connection should close with 4001 with pytest.raises(WebSocketDisconnect) as exc_info: ws.receive_text() assert exc_info.value.code == 4001 def test_ws_non_json_first_message_when_auth_required(self, client): """Non-JSON first message when auth required closes with 4001.""" from starlette.websockets import WebSocketDisconnect with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.matrix_ws_token = "secret123" with client.websocket_connect("/api/world/ws") as ws: # Send non-JSON message ws.send_text("not json") # Connection should close with 4001 with pytest.raises(WebSocketDisconnect) as exc_info: ws.receive_text() assert exc_info.value.code == 4001 def test_ws_existing_behavior_unchanged_when_token_not_configured(self, client, tmp_path): """Existing /api/world/ws behavior unchanged when token not configured.""" f = tmp_path / "presence.json" f.write_text( json.dumps( { "version": 1, "liveness": "2026-03-19T02:00:00Z", "mood": "exploring", "current_focus": "testing", "active_threads": [], "recent_events": [], "concerns": [], } ) ) with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.matrix_ws_token = "" # Not configured with patch("dashboard.routes.world.PRESENCE_FILE", f): with client.websocket_connect("/api/world/ws") as ws: # Should receive world_state directly (no connection_ack) msg = json.loads(ws.receive_text()) assert msg["type"] == "world_state" assert msg["timmyState"]["mood"] == "exploring" def test_world_ws_sends_snapshot_on_connect(client, tmp_path): """WebSocket sends a world_state snapshot immediately on connect.""" f = tmp_path / "presence.json" f.write_text( json.dumps( { "version": 1, "liveness": "2026-03-19T02:00:00Z", "mood": "exploring", "current_focus": "testing", "active_threads": [], "recent_events": [], "concerns": [], } ) ) with patch("dashboard.routes.world.PRESENCE_FILE", f): with client.websocket_connect("/api/world/ws") as ws: msg = json.loads(ws.receive_text()) assert msg["type"] == "world_state" assert msg["timmyState"]["mood"] == "exploring" assert msg["timmyState"]["activity"] == "testing" assert "updatedAt" in msg # --------------------------------------------------------------------------- # Visitor chat — bark engine # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_handle_client_message_ignores_non_json(): """Non-JSON messages are silently ignored.""" await _handle_client_message("not json") # should not raise @pytest.mark.asyncio async def test_handle_client_message_ignores_unknown_type(): """Unknown message types are ignored.""" await _handle_client_message(json.dumps({"type": "unknown"})) @pytest.mark.asyncio async def test_handle_client_message_ignores_empty_text(): """Empty visitor_message text is ignored.""" await _handle_client_message(json.dumps({"type": "visitor_message", "text": " "})) @pytest.mark.asyncio async def test_generate_bark_returns_response(): """_generate_bark returns the chat response.""" reset_conversation_ground() with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat: mock_chat.return_value = "Woof! Good to see you." result = await _generate_bark("Hey Timmy!") assert result == "Woof! Good to see you." mock_chat.assert_called_once_with("Hey Timmy!", session_id="workshop") @pytest.mark.asyncio async def test_generate_bark_fallback_on_error(): """_generate_bark returns canned response when chat fails.""" reset_conversation_ground() with patch( "timmy.session.chat", new_callable=AsyncMock, side_effect=RuntimeError("no model"), ): result = await _generate_bark("Hello?") assert "tangled" in result @pytest.mark.asyncio async def test_bark_and_broadcast_sends_thinking_then_speech(): """_bark_and_broadcast sends thinking indicator then speech.""" from dashboard.routes.world import _ws_clients ws = AsyncMock() _ws_clients.append(ws) _conversation.clear() reset_conversation_ground() try: with patch( "timmy.session.chat", new_callable=AsyncMock, return_value="All good here!", ): await _bark_and_broadcast("How are you?") # Should have sent two messages: thinking + speech assert ws.send_text.call_count == 2 thinking = json.loads(ws.send_text.call_args_list[0][0][0]) speech = json.loads(ws.send_text.call_args_list[1][0][0]) assert thinking["type"] == "timmy_thinking" assert speech["type"] == "timmy_speech" assert speech["text"] == "All good here!" assert len(speech["recentExchanges"]) == 1 assert speech["recentExchanges"][0]["visitor"] == "How are you?" finally: _ws_clients.clear() _conversation.clear() @pytest.mark.asyncio async def test_broadcast_removes_dead_clients(): """Dead clients are cleaned up during broadcast.""" from dashboard.routes.world import _ws_clients dead = AsyncMock() dead.send_text.side_effect = ConnectionError("gone") _ws_clients.append(dead) try: await _broadcast(json.dumps({"type": "timmy_speech", "text": "test"})) assert dead not in _ws_clients finally: _ws_clients.clear() @pytest.mark.asyncio async def test_conversation_buffer_caps_at_max(): """Conversation buffer only keeps the last _MAX_EXCHANGES entries.""" from dashboard.routes.world import _MAX_EXCHANGES, _ws_clients ws = AsyncMock() _ws_clients.append(ws) _conversation.clear() reset_conversation_ground() try: with patch( "timmy.session.chat", new_callable=AsyncMock, return_value="reply", ): for i in range(_MAX_EXCHANGES + 2): await _bark_and_broadcast(f"msg {i}") assert len(_conversation) == _MAX_EXCHANGES # Oldest messages should have been evicted assert _conversation[0]["visitor"] == f"msg {_MAX_EXCHANGES + 2 - _MAX_EXCHANGES}" finally: _ws_clients.clear() _conversation.clear() def test_log_bark_failure_logs_exception(caplog): """_log_bark_failure logs errors from failed bark tasks.""" loop = asyncio.new_event_loop() async def _fail(): raise RuntimeError("bark boom") task = loop.create_task(_fail()) loop.run_until_complete(asyncio.sleep(0.01)) loop.close() with caplog.at_level(logging.ERROR): _log_bark_failure(task) assert "bark boom" in caplog.text def test_log_bark_failure_ignores_cancelled(): """_log_bark_failure silently ignores cancelled tasks.""" task = MagicMock(spec=asyncio.Task) task.cancelled.return_value = True _log_bark_failure(task) # should not raise # --------------------------------------------------------------------------- # Conversation grounding (#322) # --------------------------------------------------------------------------- class TestConversationGrounding: """Tests for conversation grounding — prevent topic drift.""" def setup_method(self): reset_conversation_ground() def teardown_method(self): reset_conversation_ground() def test_refresh_ground_sets_topic_on_first_message(self): """First visitor message becomes the grounding anchor.""" import dashboard.routes.world as w _refresh_ground("Tell me about the Bible") assert w._ground_topic == "Tell me about the Bible" assert w._ground_set_at > 0 def test_refresh_ground_keeps_topic_on_subsequent_messages(self): """Subsequent messages don't overwrite the anchor.""" import dashboard.routes.world as w _refresh_ground("Tell me about the Bible") _refresh_ground("What about Genesis?") assert w._ground_topic == "Tell me about the Bible" def test_refresh_ground_resets_after_ttl(self): """Anchor expires after _GROUND_TTL seconds of inactivity.""" import dashboard.routes.world as w _refresh_ground("Tell me about the Bible") # Simulate TTL expiry w._ground_set_at = time.time() - _GROUND_TTL - 1 _refresh_ground("Now tell me about cooking") assert w._ground_topic == "Now tell me about cooking" def test_refresh_ground_truncates_long_messages(self): """Anchor text is capped at 120 characters.""" import dashboard.routes.world as w long_msg = "x" * 200 _refresh_ground(long_msg) assert len(w._ground_topic) == 120 def test_reset_conversation_ground_clears_state(self): """reset_conversation_ground clears the anchor.""" import dashboard.routes.world as w _refresh_ground("Some topic") reset_conversation_ground() assert w._ground_topic is None assert w._ground_set_at == 0.0 @pytest.mark.asyncio async def test_generate_bark_prepends_ground_topic(self): """When grounded, the topic is prepended to the visitor message.""" _refresh_ground("Tell me about prayer") with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat: mock_chat.return_value = "Great question!" await _generate_bark("What else can you share?") call_text = mock_chat.call_args[0][0] assert "[Workshop conversation topic: Tell me about prayer]" in call_text assert "What else can you share?" in call_text @pytest.mark.asyncio async def test_generate_bark_no_prefix_for_first_message(self): """First message (which IS the anchor) is not prefixed.""" _refresh_ground("Tell me about prayer") with patch("timmy.session.chat", new_callable=AsyncMock) as mock_chat: mock_chat.return_value = "Sure!" await _generate_bark("Tell me about prayer") call_text = mock_chat.call_args[0][0] assert "[Workshop conversation topic:" not in call_text assert call_text == "Tell me about prayer" @pytest.mark.asyncio async def test_bark_and_broadcast_sets_ground(self): """_bark_and_broadcast sets the ground topic automatically.""" import dashboard.routes.world as w from dashboard.routes.world import _ws_clients ws = AsyncMock() _ws_clients.append(ws) _conversation.clear() try: with patch( "timmy.session.chat", new_callable=AsyncMock, return_value="Interesting!", ): await _bark_and_broadcast("What is grace?") assert w._ground_topic == "What is grace?" finally: _ws_clients.clear() _conversation.clear() # --------------------------------------------------------------------------- # Conversation grounding — commitment tracking (rescued from PR #408) # --------------------------------------------------------------------------- @pytest.fixture(autouse=False) def _clean_commitments(): """Reset commitments before and after each commitment test.""" reset_commitments() yield reset_commitments() class TestExtractCommitments: def test_extracts_ill_pattern(self): text = "I'll draft the skeleton ticket in 30 minutes." result = _extract_commitments(text) assert len(result) == 1 assert "draft the skeleton ticket" in result[0] def test_extracts_i_will_pattern(self): result = _extract_commitments("I will review that PR tomorrow.") assert len(result) == 1 assert "review that PR tomorrow" in result[0] def test_extracts_let_me_pattern(self): result = _extract_commitments("Let me write up a summary for you.") assert len(result) == 1 assert "write up a summary" in result[0] def test_skips_short_matches(self): result = _extract_commitments("I'll do it.") # "do it" is 5 chars — should be skipped (needs > 5) assert result == [] def test_no_commitments_in_normal_text(self): result = _extract_commitments("The weather is nice today.") assert result == [] def test_truncates_long_commitments(self): long_phrase = "a" * 200 result = _extract_commitments(f"I'll {long_phrase}.") assert len(result) == 1 assert len(result[0]) == 120 class TestRecordCommitments: def test_records_new_commitment(self, _clean_commitments): _record_commitments("I'll draft the ticket now.") assert len(get_commitments()) == 1 assert get_commitments()[0]["messages_since"] == 0 def test_avoids_duplicate_commitments(self, _clean_commitments): _record_commitments("I'll draft the ticket now.") _record_commitments("I'll draft the ticket now.") assert len(get_commitments()) == 1 def test_caps_at_max(self, _clean_commitments): from dashboard.routes.world import _MAX_COMMITMENTS for i in range(_MAX_COMMITMENTS + 3): _record_commitments(f"I'll handle commitment number {i} right away.") assert len(get_commitments()) <= _MAX_COMMITMENTS class TestTickAndContext: def test_tick_increments_messages_since(self, _clean_commitments): _commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0}) _tick_commitments() _tick_commitments() assert _commitments[0]["messages_since"] == 2 def test_context_empty_when_no_overdue(self, _clean_commitments): _commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0}) assert _build_commitment_context() == "" def test_context_surfaces_overdue_commitments(self, _clean_commitments): _commitments.append( { "text": "draft the skeleton ticket", "created_at": 0, "messages_since": _REMIND_AFTER, } ) ctx = _build_commitment_context() assert "draft the skeleton ticket" in ctx assert "Open commitments" in ctx def test_context_only_includes_overdue(self, _clean_commitments): _commitments.append({"text": "recent thing", "created_at": 0, "messages_since": 1}) _commitments.append( { "text": "old thing", "created_at": 0, "messages_since": _REMIND_AFTER, } ) ctx = _build_commitment_context() assert "old thing" in ctx assert "recent thing" not in ctx class TestCloseCommitment: def test_close_valid_index(self, _clean_commitments): _commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0}) assert close_commitment(0) is True assert len(get_commitments()) == 0 def test_close_invalid_index(self, _clean_commitments): assert close_commitment(99) is False class TestGroundingIntegration: @pytest.mark.asyncio async def test_bark_records_commitments_from_reply(self, _clean_commitments): from dashboard.routes.world import _ws_clients ws = AsyncMock() _ws_clients.append(ws) _conversation.clear() try: with patch( "timmy.session.chat", new_callable=AsyncMock, return_value="I'll draft the ticket for you!", ): await _bark_and_broadcast("Can you help?") assert len(get_commitments()) == 1 assert "draft the ticket" in get_commitments()[0]["text"] finally: _ws_clients.clear() _conversation.clear() @pytest.mark.asyncio async def test_bark_prepends_context_after_n_messages(self, _clean_commitments): """After _REMIND_AFTER messages, commitment context is prepended.""" _commitments.append( { "text": "draft the skeleton ticket", "created_at": 0, "messages_since": _REMIND_AFTER - 1, } ) with patch( "timmy.session.chat", new_callable=AsyncMock, return_value="Sure thing!", ) as mock_chat: # This tick will push messages_since to _REMIND_AFTER await _generate_bark("Any updates?") # _generate_bark doesn't tick — _bark_and_broadcast does. # But we pre-set messages_since to _REMIND_AFTER - 1, # so we need to tick once to make it overdue. _tick_commitments() await _generate_bark("Any updates?") # Second call should have context prepended last_call = mock_chat.call_args_list[-1] sent_text = last_call[0][0] assert "draft the skeleton ticket" in sent_text assert "Open commitments" in sent_text # --------------------------------------------------------------------------- # WebSocket heartbeat ping (rescued from PR #399) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_heartbeat_sends_ping(): """Heartbeat sends a ping JSON frame after the interval elapses.""" ws = AsyncMock() with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: # Let the first sleep complete, then raise to exit the loop call_count = 0 async def sleep_side_effect(_interval): nonlocal call_count call_count += 1 if call_count > 1: raise ConnectionError("stop") mock_sleep.side_effect = sleep_side_effect await _heartbeat(ws) ws.send_text.assert_called_once() msg = json.loads(ws.send_text.call_args[0][0]) assert msg["type"] == "ping" @pytest.mark.asyncio async def test_heartbeat_exits_on_dead_connection(): """Heartbeat exits cleanly when the WebSocket is dead.""" ws = AsyncMock() ws.send_text.side_effect = ConnectionError("gone") with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock): await _heartbeat(ws) # should not raise # --------------------------------------------------------------------------- # Matrix Agent Registry (/api/matrix/agents) # --------------------------------------------------------------------------- class TestMatrixAgentRegistry: """Tests for the Matrix agent registry endpoint.""" def test_get_agent_color_known_agents(self): """Known agents return their assigned colors.""" assert _get_agent_color("timmy") == "#FFD700" # Gold assert _get_agent_color("orchestrator") == "#FFD700" # Gold assert _get_agent_color("kimi") == "#06B6D4" # Cyan assert _get_agent_color("claude") == "#A855F7" # Purple assert _get_agent_color("researcher") == "#10B981" # Emerald assert _get_agent_color("coder") == "#EF4444" # Red def test_get_agent_color_unknown_agent(self): """Unknown agents return the default gray color.""" assert _get_agent_color("unknown") == "#9CA3AF" assert _get_agent_color("xyz") == "#9CA3AF" def test_get_agent_color_case_insensitive(self): """Agent ID lookup is case insensitive.""" assert _get_agent_color("Timmy") == "#FFD700" assert _get_agent_color("KIMI") == "#06B6D4" def test_get_agent_shape_known_agents(self): """Known agents return their assigned shapes.""" assert _get_agent_shape("timmy") == "sphere" assert _get_agent_shape("coder") == "cube" assert _get_agent_shape("writer") == "cone" def test_get_agent_shape_unknown_agent(self): """Unknown agents return the default sphere shape.""" assert _get_agent_shape("unknown") == "sphere" def test_compute_circular_positions(self): """Agents are arranged in a circle on the XZ plane.""" positions = _compute_circular_positions(4, radius=3.0) assert len(positions) == 4 # All positions should have y=0 for pos in positions: assert pos["y"] == 0.0 assert "x" in pos assert "z" in pos # First position should be at angle 0 (x=radius, z=0) assert positions[0]["x"] == 3.0 assert positions[0]["z"] == 0.0 def test_compute_circular_positions_empty(self): """Zero agents returns empty positions list.""" positions = _compute_circular_positions(0) assert positions == [] def test_build_matrix_agents_response_structure(self): """Response contains all required fields for each agent.""" with patch("timmy.agents.loader.list_agents") as mock_list: mock_list.return_value = [ {"id": "timmy", "name": "Timmy", "role": "orchestrator", "status": "available"}, {"id": "researcher", "name": "Seer", "role": "research", "status": "busy"}, ] result = _build_matrix_agents_response() assert len(result) == 2 # Check first agent assert result[0]["id"] == "timmy" assert result[0]["display_name"] == "Timmy" assert result[0]["role"] == "orchestrator" assert result[0]["color"] == "#FFD700" assert result[0]["shape"] == "sphere" assert result[0]["status"] == "available" assert "position" in result[0] assert "x" in result[0]["position"] assert "y" in result[0]["position"] assert "z" in result[0]["position"] def test_build_matrix_agents_response_empty(self): """Returns empty list when no agents configured.""" with patch("timmy.agents.loader.list_agents") as mock_list: mock_list.return_value = [] result = _build_matrix_agents_response() assert result == [] def test_build_matrix_agents_response_handles_errors(self): """Returns empty list when loader fails.""" with patch("timmy.agents.loader.list_agents") as mock_list: mock_list.side_effect = RuntimeError("Loader failed") result = _build_matrix_agents_response() assert result == [] @pytest.fixture def matrix_client(): """TestClient with matrix router.""" from fastapi import FastAPI from fastapi.testclient import TestClient app = FastAPI() from dashboard.routes.world import matrix_router app.include_router(matrix_router) return TestClient(app) def test_matrix_agents_endpoint_returns_json(matrix_client): """GET /api/matrix/agents returns JSON list.""" with patch("timmy.agents.loader.list_agents") as mock_list: mock_list.return_value = [ {"id": "timmy", "name": "Timmy", "role": "orchestrator", "status": "available"}, ] resp = matrix_client.get("/api/matrix/agents") assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) == 1 assert data[0]["id"] == "timmy" assert resp.headers["cache-control"] == "no-cache, no-store" def test_matrix_agents_endpoint_empty_list(matrix_client): """Endpoint returns 200 with empty list when no agents.""" with patch("timmy.agents.loader.list_agents") as mock_list: mock_list.return_value = [] resp = matrix_client.get("/api/matrix/agents") assert resp.status_code == 200 assert resp.json() == [] def test_matrix_agents_endpoint_graceful_degradation(matrix_client): """Endpoint returns empty list when loader fails.""" with patch("timmy.agents.loader.list_agents") as mock_list: mock_list.side_effect = FileNotFoundError("agents.yaml not found") resp = matrix_client.get("/api/matrix/agents") assert resp.status_code == 200 assert resp.json() == [] # --------------------------------------------------------------------------- # Matrix Configuration Endpoint (/api/matrix/config) # --------------------------------------------------------------------------- class TestMatrixConfigEndpoint: """Tests for the Matrix configuration endpoint.""" def test_matrix_config_endpoint_returns_json(self, matrix_client): """GET /api/matrix/config returns JSON config.""" resp = matrix_client.get("/api/matrix/config") assert resp.status_code == 200 data = resp.json() assert isinstance(data, dict) assert "lighting" in data assert "environment" in data assert "features" in data assert resp.headers["cache-control"] == "no-cache, no-store" def test_matrix_config_lighting_structure(self, matrix_client): """Config has correct lighting section structure.""" resp = matrix_client.get("/api/matrix/config") data = resp.json() lighting = data["lighting"] assert "ambient_color" in lighting assert "ambient_intensity" in lighting assert "point_lights" in lighting assert isinstance(lighting["point_lights"], list) # Check first point light structure if lighting["point_lights"]: pl = lighting["point_lights"][0] assert "color" in pl assert "intensity" in pl assert "position" in pl assert "x" in pl["position"] assert "y" in pl["position"] assert "z" in pl["position"] def test_matrix_config_environment_structure(self, matrix_client): """Config has correct environment section structure.""" resp = matrix_client.get("/api/matrix/config") data = resp.json() env = data["environment"] assert "rain_enabled" in env assert "starfield_enabled" in env assert "fog_color" in env assert "fog_density" in env assert isinstance(env["rain_enabled"], bool) assert isinstance(env["starfield_enabled"], bool) def test_matrix_config_features_structure(self, matrix_client): """Config has correct features section structure.""" resp = matrix_client.get("/api/matrix/config") data = resp.json() features = data["features"] assert "chat_enabled" in features assert "visitor_avatars" in features assert "pip_familiar" in features assert "workshop_portal" in features assert isinstance(features["chat_enabled"], bool) class TestMatrixConfigLoading: """Tests for _load_matrix_config function.""" def test_load_matrix_config_returns_dict(self): """_load_matrix_config returns a dictionary.""" from dashboard.routes.world import _load_matrix_config config = _load_matrix_config() assert isinstance(config, dict) assert "lighting" in config assert "environment" in config assert "features" in config def test_load_matrix_config_has_all_required_sections(self): """Config contains all required sections.""" from dashboard.routes.world import _load_matrix_config config = _load_matrix_config() lighting = config["lighting"] env = config["environment"] features = config["features"] # Lighting fields assert "ambient_color" in lighting assert "ambient_intensity" in lighting assert "point_lights" in lighting # Environment fields assert "rain_enabled" in env assert "starfield_enabled" in env assert "fog_color" in env assert "fog_density" in env # Features fields assert "chat_enabled" in features assert "visitor_avatars" in features assert "pip_familiar" in features assert "workshop_portal" in features def test_load_matrix_config_fallback_on_missing_file(self, tmp_path): """Returns defaults when matrix.yaml is missing.""" from dashboard.routes.world import _load_matrix_config with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.repo_root = str(tmp_path) config = _load_matrix_config() # Should return defaults assert config["lighting"]["ambient_color"] == "#1a1a2e" assert config["environment"]["rain_enabled"] is False assert config["features"]["chat_enabled"] is True def test_load_matrix_config_merges_with_defaults(self, tmp_path): """Partial config file is merged with defaults.""" from dashboard.routes.world import _load_matrix_config # Create a partial config file config_dir = tmp_path / "config" config_dir.mkdir() config_file = config_dir / "matrix.yaml" config_file.write_text(""" lighting: ambient_color: "#ff0000" ambient_intensity: 0.8 environment: rain_enabled: true """) with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.repo_root = str(tmp_path) config = _load_matrix_config() # Custom values assert config["lighting"]["ambient_color"] == "#ff0000" assert config["lighting"]["ambient_intensity"] == 0.8 assert config["environment"]["rain_enabled"] is True # Defaults preserved assert config["features"]["chat_enabled"] is True assert config["environment"]["starfield_enabled"] is True assert len(config["lighting"]["point_lights"]) == 3 def test_load_matrix_config_handles_invalid_yaml(self, tmp_path): """Returns defaults when YAML is invalid.""" from dashboard.routes.world import _load_matrix_config config_dir = tmp_path / "config" config_dir.mkdir() config_file = config_dir / "matrix.yaml" config_file.write_text("not: valid: yaml: [{") with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.repo_root = str(tmp_path) config = _load_matrix_config() # Should return defaults despite invalid YAML assert "lighting" in config assert "environment" in config assert "features" in config def test_load_matrix_config_custom_point_lights(self, tmp_path): """Custom point lights override defaults completely.""" from dashboard.routes.world import _load_matrix_config config_dir = tmp_path / "config" config_dir.mkdir() config_file = config_dir / "matrix.yaml" config_file.write_text(""" lighting: point_lights: - color: "#FFFFFF" intensity: 2.0 position: { x: 1, y: 2, z: 3 } """) with patch("dashboard.routes.world.settings") as mock_settings: mock_settings.repo_root = str(tmp_path) config = _load_matrix_config() # Should have custom point lights, not defaults lights = config["lighting"]["point_lights"] assert len(lights) == 1 assert lights[0]["color"] == "#FFFFFF" assert lights[0]["intensity"] == 2.0 assert lights[0]["position"] == {"x": 1, "y": 2, "z": 3} # --------------------------------------------------------------------------- # Matrix Thoughts Endpoint (/api/matrix/thoughts) # --------------------------------------------------------------------------- class TestMatrixThoughtsEndpoint: """Tests for the Matrix thoughts endpoint.""" def test_thoughts_endpoint_returns_json(self, matrix_client): """GET /api/matrix/thoughts returns JSON list.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [ MagicMock( id="test-1", content="First thought", created_at="2026-03-21T10:00:00Z", parent_id=None, ), ] resp = matrix_client.get("/api/matrix/thoughts") assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) == 1 assert resp.headers["cache-control"] == "no-cache, no-store" def test_thoughts_endpoint_default_limit(self, matrix_client): """Default limit is 10 thoughts.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [] matrix_client.get("/api/matrix/thoughts") # Should call with default limit of 10 mock_engine.get_recent_thoughts.assert_called_once_with(limit=10) def test_thoughts_endpoint_custom_limit(self, matrix_client): """Custom limit can be specified via query param.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [] matrix_client.get("/api/matrix/thoughts?limit=25") mock_engine.get_recent_thoughts.assert_called_once_with(limit=25) def test_thoughts_endpoint_max_limit_capped(self, matrix_client): """Limit is capped at 50 maximum.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [] matrix_client.get("/api/matrix/thoughts?limit=100") mock_engine.get_recent_thoughts.assert_called_once_with(limit=50) def test_thoughts_endpoint_min_limit(self, matrix_client): """Limit less than 1 is clamped to 1.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [] matrix_client.get("/api/matrix/thoughts?limit=0") mock_engine.get_recent_thoughts.assert_called_once_with(limit=1) def test_thoughts_endpoint_response_structure(self, matrix_client): """Response has all required fields with correct types.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [ MagicMock( id="thought-uuid-1", content="This is a test thought", created_at="2026-03-21T10:00:00Z", parent_id="parent-uuid", ), ] resp = matrix_client.get("/api/matrix/thoughts") data = resp.json() assert len(data) == 1 thought = data[0] assert thought["id"] == "thought-uuid-1" assert thought["text"] == "This is a test thought" assert thought["created_at"] == "2026-03-21T10:00:00Z" assert thought["chain_id"] == "parent-uuid" def test_thoughts_endpoint_null_parent_id(self, matrix_client): """Root thoughts have chain_id as null.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [ MagicMock( id="root-thought", content="Root thought", created_at="2026-03-21T10:00:00Z", parent_id=None, ), ] resp = matrix_client.get("/api/matrix/thoughts") data = resp.json() assert data[0]["chain_id"] is None def test_thoughts_endpoint_text_truncation(self, matrix_client): """Text is truncated to 500 characters.""" long_content = "A" * 1000 with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [ MagicMock( id="long-thought", content=long_content, created_at="2026-03-21T10:00:00Z", parent_id=None, ), ] resp = matrix_client.get("/api/matrix/thoughts") data = resp.json() assert len(data[0]["text"]) == 500 assert data[0]["text"] == "A" * 500 def test_thoughts_endpoint_empty_list(self, matrix_client): """Endpoint returns 200 with empty list when no thoughts.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [] resp = matrix_client.get("/api/matrix/thoughts") assert resp.status_code == 200 assert resp.json() == [] def test_thoughts_endpoint_graceful_degradation(self, matrix_client): """Returns empty list when thinking engine fails.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.side_effect = RuntimeError("Engine down") resp = matrix_client.get("/api/matrix/thoughts") assert resp.status_code == 200 assert resp.json() == [] def test_thoughts_endpoint_multiple_thoughts(self, matrix_client): """Multiple thoughts are returned in order from thinking engine.""" with patch("timmy.thinking.thinking_engine") as mock_engine: mock_engine.get_recent_thoughts.return_value = [ MagicMock( id="t1", content="First", created_at="2026-03-21T10:00:00Z", parent_id=None, ), MagicMock( id="t2", content="Second", created_at="2026-03-21T10:01:00Z", parent_id="t1", ), ] resp = matrix_client.get("/api/matrix/thoughts") data = resp.json() assert len(data) == 2 assert data[0]["id"] == "t1" assert data[1]["id"] == "t2" # --------------------------------------------------------------------------- # Matrix Bark Endpoint (/api/matrix/bark) # --------------------------------------------------------------------------- class TestMatrixBarkEndpoint: """Tests for the Matrix bark HTTP fallback endpoint.""" def setup_method(self): """Reset rate limiting state before each test.""" from dashboard.routes.world import _bark_last_request _bark_last_request.clear() def teardown_method(self): """Clean up rate limiting state after each test.""" from dashboard.routes.world import _bark_last_request _bark_last_request.clear() def test_bark_endpoint_requires_text(self, matrix_client): """POST /api/matrix/bark returns 422 if text is missing.""" resp = matrix_client.post("/api/matrix/bark", json={"text": "", "visitor_id": "test123"}) assert resp.status_code == 422 data = resp.json() assert "error" in data def test_bark_endpoint_requires_visitor_id(self, matrix_client): """POST /api/matrix/bark returns 422 if visitor_id is missing.""" resp = matrix_client.post("/api/matrix/bark", json={"text": "Hello", "visitor_id": ""}) assert resp.status_code == 422 data = resp.json() assert "error" in data def test_bark_endpoint_returns_bark_format(self, matrix_client): """POST /api/matrix/bark returns bark in produce_bark format.""" with patch( "dashboard.routes.world._generate_bark", new_callable=AsyncMock, return_value="Woof! Hello there!", ): resp = matrix_client.post( "/api/matrix/bark", json={"text": "Hey Timmy!", "visitor_id": "visitor_123"}, ) assert resp.status_code == 200 data = resp.json() # Check produce_bark format assert data["type"] == "bark" assert data["agent_id"] == "timmy" assert "data" in data assert data["data"]["text"] == "Woof! Hello there!" assert data["data"]["style"] == "speech" assert "ts" in data assert resp.headers["cache-control"] == "no-cache, no-store" def test_bark_endpoint_uses_generate_bark(self, matrix_client): """POST /api/matrix/bark uses _generate_bark for response generation.""" with patch( "dashboard.routes.world._generate_bark", new_callable=AsyncMock, return_value="Generated response", ) as mock_generate: resp = matrix_client.post( "/api/matrix/bark", json={"text": "Test message", "visitor_id": "v1"}, ) assert resp.status_code == 200 mock_generate.assert_called_once_with("Test message") def test_bark_endpoint_rate_limit_blocks_second_request(self, matrix_client): """Second request within 3 seconds returns 429.""" with patch( "dashboard.routes.world._generate_bark", new_callable=AsyncMock, return_value="Response", ): # First request should succeed resp1 = matrix_client.post( "/api/matrix/bark", json={"text": "First", "visitor_id": "rate_test_visitor"}, ) assert resp1.status_code == 200 # Second request within 3 seconds should be rate limited resp2 = matrix_client.post( "/api/matrix/bark", json={"text": "Second", "visitor_id": "rate_test_visitor"}, ) assert resp2.status_code == 429 data = resp2.json() assert "error" in data assert "Rate limit" in data["error"] def test_bark_endpoint_rate_limit_per_visitor(self, matrix_client): """Rate limiting is per-visitor_id, not global.""" with patch( "dashboard.routes.world._generate_bark", new_callable=AsyncMock, return_value="Response", ): # First visitor makes a request resp1 = matrix_client.post( "/api/matrix/bark", json={"text": "Hello", "visitor_id": "visitor_a"}, ) assert resp1.status_code == 200 # Different visitor can still make a request resp2 = matrix_client.post( "/api/matrix/bark", json={"text": "Hello", "visitor_id": "visitor_b"}, ) assert resp2.status_code == 200 def test_bark_endpoint_rate_limit_retry_after_header(self, matrix_client): """429 response includes Retry-After header.""" with patch( "dashboard.routes.world._generate_bark", new_callable=AsyncMock, return_value="Response", ): # First request matrix_client.post( "/api/matrix/bark", json={"text": "First", "visitor_id": "retry_test_visitor"}, ) # Second request (rate limited) resp = matrix_client.post( "/api/matrix/bark", json={"text": "Second", "visitor_id": "retry_test_visitor"}, ) assert resp.status_code == 429 assert "Retry-After" in resp.headers # Should be approximately 3 seconds retry_after = int(resp.headers["Retry-After"]) assert 1 <= retry_after <= 4 def test_bark_endpoint_graceful_fallback_on_error(self, matrix_client): """When _generate_bark fails, returns graceful fallback.""" with patch( "dashboard.routes.world._generate_bark", new_callable=AsyncMock, side_effect=RuntimeError("LLM unavailable"), ): resp = matrix_client.post( "/api/matrix/bark", json={"text": "Hello", "visitor_id": "error_test_visitor"}, ) assert resp.status_code == 200 data = resp.json() assert data["type"] == "bark" assert "tangled" in data["data"]["text"] def test_bark_endpoint_strips_whitespace(self, matrix_client): """Whitespace is stripped from text and visitor_id.""" with patch( "dashboard.routes.world._generate_bark", new_callable=AsyncMock, return_value="Response", ) as mock_generate: resp = matrix_client.post( "/api/matrix/bark", json={"text": " Hello Timmy! ", "visitor_id": " visitor_123 "}, ) assert resp.status_code == 200 # Should be called with stripped text mock_generate.assert_called_once_with("Hello Timmy!") def test_bark_endpoint_response_truncation(self, matrix_client): """Long responses are truncated to 280 characters.""" long_response = "A" * 500 with patch( "dashboard.routes.world._generate_bark", new_callable=AsyncMock, return_value=long_response, ): resp = matrix_client.post( "/api/matrix/bark", json={"text": "Tell me a long story", "visitor_id": "long_test"}, ) assert resp.status_code == 200 data = resp.json() assert len(data["data"]["text"]) == 280 # --------------------------------------------------------------------------- # Matrix Memory Search Endpoint (/api/matrix/memory/search) # --------------------------------------------------------------------------- class TestMatrixMemorySearchEndpoint: """Tests for the Matrix memory search endpoint.""" def setup_method(self): """Reset rate limiting state before each test.""" from dashboard.routes.world import _memory_search_last_request _memory_search_last_request.clear() def teardown_method(self): """Clean up rate limiting state after each test.""" from dashboard.routes.world import _memory_search_last_request _memory_search_last_request.clear() def test_memory_search_requires_query(self, matrix_client): """GET /api/matrix/memory/search returns 400 if q is missing.""" resp = matrix_client.get("/api/matrix/memory/search") assert resp.status_code == 400 data = resp.json() assert "error" in data assert "'q' is required" in data["error"] def test_memory_search_rejects_empty_query(self, matrix_client): """GET /api/matrix/memory/search returns 400 if q is empty.""" resp = matrix_client.get("/api/matrix/memory/search?q=") assert resp.status_code == 400 data = resp.json() assert "error" in data def test_memory_search_rejects_whitespace_query(self, matrix_client): """GET /api/matrix/memory/search returns 400 if q is whitespace.""" resp = matrix_client.get("/api/matrix/memory/search?q= ") assert resp.status_code == 400 data = resp.json() assert "error" in data def test_memory_search_returns_json_array(self, matrix_client): """GET /api/matrix/memory/search returns JSON array of results.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [ MagicMock( content="Bitcoin is a decentralized digital currency", relevance_score=0.92, timestamp="2026-03-21T10:00:00Z", context_type="conversation", ), ] resp = matrix_client.get("/api/matrix/memory/search?q=bitcoin") assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) == 1 assert resp.headers["cache-control"] == "no-cache, no-store" def test_memory_search_result_structure(self, matrix_client): """Each result has required fields with correct types.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [ MagicMock( content="Bitcoin sovereignty content here", relevance_score=0.85, timestamp="2026-03-21T10:00:00Z", context_type="fact", ), ] resp = matrix_client.get("/api/matrix/memory/search?q=sovereignty") data = resp.json() assert len(data) == 1 result = data[0] assert "text" in result assert "relevance" in result assert "created_at" in result assert "context_type" in result assert isinstance(result["text"], str) assert isinstance(result["relevance"], (int, float)) assert isinstance(result["created_at"], str) assert isinstance(result["context_type"], str) def test_memory_search_text_truncation(self, matrix_client): """Text is truncated to 200 characters with ellipsis.""" long_content = "A" * 300 with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [ MagicMock( content=long_content, relevance_score=0.75, timestamp="2026-03-21T10:00:00Z", context_type="conversation", ), ] resp = matrix_client.get("/api/matrix/memory/search?q=test") data = resp.json() assert len(data[0]["text"]) == 203 # 200 chars + "..." assert data[0]["text"].endswith("...") def test_memory_search_relevance_rounding(self, matrix_client): """Relevance score is rounded to 4 decimal places.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [ MagicMock( content="Test content", relevance_score=0.123456789, timestamp="2026-03-21T10:00:00Z", context_type="conversation", ), ] resp = matrix_client.get("/api/matrix/memory/search?q=test") data = resp.json() # Should be rounded to 4 decimal places assert data[0]["relevance"] == 0.1235 def test_memory_search_max_results(self, matrix_client): """Endpoint returns max 5 results.""" with patch("dashboard.routes.world.search_memories") as mock_search: # Return more than 5 results mock_search.return_value = [ MagicMock( content=f"Memory {i}", relevance_score=0.9 - (i * 0.05), timestamp="2026-03-21T10:00:00Z", context_type="conversation", ) for i in range(10) ] resp = matrix_client.get("/api/matrix/memory/search?q=test") data = resp.json() # Should be limited to 5 results assert len(data) <= 5 def test_memory_search_passes_limit_to_search(self, matrix_client): """Endpoint passes correct limit to search_memories.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [] matrix_client.get("/api/matrix/memory/search?q=bitcoin") mock_search.assert_called_once_with("bitcoin", limit=5) def test_memory_search_empty_results(self, matrix_client): """Endpoint returns empty array when no memories found.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [] resp = matrix_client.get("/api/matrix/memory/search?q=nonexistent") assert resp.status_code == 200 assert resp.json() == [] def test_memory_search_graceful_degradation(self, matrix_client): """Returns empty array when search fails.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.side_effect = RuntimeError("Database error") resp = matrix_client.get("/api/matrix/memory/search?q=test") assert resp.status_code == 200 assert resp.json() == [] def test_memory_search_rate_limit_blocks_second_request(self, matrix_client): """Second request within 5 seconds returns 429.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [] # First request should succeed resp1 = matrix_client.get("/api/matrix/memory/search?q=first") assert resp1.status_code == 200 # Second request within 5 seconds should be rate limited resp2 = matrix_client.get("/api/matrix/memory/search?q=second") assert resp2.status_code == 429 data = resp2.json() assert "error" in data assert "Rate limit" in data["error"] def test_memory_search_rate_limit_per_ip(self, matrix_client): """Rate limiting is per-IP address.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [] # First request from one IP resp1 = matrix_client.get( "/api/matrix/memory/search?q=test", headers={"X-Forwarded-For": "1.2.3.4"}, ) assert resp1.status_code == 200 # Same request from different IP should succeed resp2 = matrix_client.get( "/api/matrix/memory/search?q=test", headers={"X-Forwarded-For": "5.6.7.8"}, ) assert resp2.status_code == 200 def test_memory_search_rate_limit_uses_client_host(self, matrix_client): """Rate limiting falls back to client.host when no X-Forwarded-For.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [] # First request resp1 = matrix_client.get("/api/matrix/memory/search?q=first") assert resp1.status_code == 200 # Second request should be rate limited (same client) resp2 = matrix_client.get("/api/matrix/memory/search?q=second") assert resp2.status_code == 429 def test_memory_search_rate_limit_retry_after_header(self, matrix_client): """429 response includes Retry-After header.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [] # First request matrix_client.get("/api/matrix/memory/search?q=first") # Second request (rate limited) resp = matrix_client.get("/api/matrix/memory/search?q=second") assert resp.status_code == 429 assert "Retry-After" in resp.headers retry_after = int(resp.headers["Retry-After"]) assert 1 <= retry_after <= 6 # Should be around 5 seconds def test_memory_search_multiple_results_ordering(self, matrix_client): """Results maintain order from search_memories.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [ MagicMock( content="First memory", relevance_score=0.95, timestamp="2026-03-21T10:00:00Z", context_type="fact", ), MagicMock( content="Second memory", relevance_score=0.85, timestamp="2026-03-21T10:01:00Z", context_type="conversation", ), ] resp = matrix_client.get("/api/matrix/memory/search?q=test") data = resp.json() assert len(data) == 2 assert data[0]["text"] == "First memory" assert data[1]["text"] == "Second memory" def test_memory_search_url_encoding(self, matrix_client): """Query parameter can be URL encoded.""" with patch("dashboard.routes.world.search_memories") as mock_search: mock_search.return_value = [] resp = matrix_client.get("/api/matrix/memory/search?q=bitcoin%20sovereignty") assert resp.status_code == 200 mock_search.assert_called_once_with("bitcoin sovereignty", limit=5) # --------------------------------------------------------------------------- # Matrix Health Endpoint (/api/matrix/health) # --------------------------------------------------------------------------- class TestMatrixHealthEndpoint: """Tests for the Matrix health endpoint.""" def test_health_endpoint_returns_json(self, matrix_client): """GET /api/matrix/health returns JSON response.""" resp = matrix_client.get("/api/matrix/health") assert resp.status_code == 200 data = resp.json() assert isinstance(data, dict) assert "status" in data assert "version" in data assert "capabilities" in data assert resp.headers["cache-control"] == "no-cache, no-store" def test_health_endpoint_status_field(self, matrix_client): """Response contains status field with valid values.""" resp = matrix_client.get("/api/matrix/health") data = resp.json() assert data["status"] in ["ok", "degraded"] def test_health_endpoint_version_field(self, matrix_client): """Response contains version string.""" resp = matrix_client.get("/api/matrix/health") data = resp.json() assert isinstance(data["version"], str) assert len(data["version"]) > 0 def test_health_endpoint_capabilities_structure(self, matrix_client): """Capabilities dict has all required features.""" resp = matrix_client.get("/api/matrix/health") data = resp.json() caps = data["capabilities"] assert isinstance(caps, dict) # All required capabilities required_caps = ["thinking", "memory", "bark", "familiar", "lightning"] for cap in required_caps: assert cap in caps, f"Missing capability: {cap}" assert isinstance(caps[cap], bool), f"Capability {cap} should be bool" def test_health_endpoint_returns_200_when_degraded(self, matrix_client): """Returns 200 even when some capabilities are unavailable.""" # Mock all capability checks to return False with patch.multiple( "dashboard.routes.world", _check_capability_thinking=lambda: False, _check_capability_memory=lambda: False, _check_capability_bark=lambda: False, _check_capability_familiar=lambda: False, _check_capability_lightning=lambda: False, ): resp = matrix_client.get("/api/matrix/health") assert resp.status_code == 200 data = resp.json() assert data["status"] == "degraded" def test_health_endpoint_ok_when_core_caps_available(self, matrix_client): """Status is 'ok' when core capabilities are available.""" with patch.multiple( "dashboard.routes.world", _check_capability_thinking=lambda: True, _check_capability_memory=lambda: True, _check_capability_bark=lambda: True, _check_capability_familiar=lambda: False, _check_capability_lightning=lambda: False, ): resp = matrix_client.get("/api/matrix/health") assert resp.status_code == 200 data = resp.json() assert data["status"] == "ok" assert data["capabilities"]["thinking"] is True assert data["capabilities"]["memory"] is True assert data["capabilities"]["bark"] is True assert data["capabilities"]["familiar"] is False assert data["capabilities"]["lightning"] is False class TestMatrixHealthCapabilityChecks: """Tests for individual capability check functions.""" def test_check_thinking_returns_true_when_available(self): """_check_capability_thinking returns True when thinking engine is available.""" from dashboard.routes.world import _check_capability_thinking # Mock thinking engine with _db attribute mock_engine = MagicMock() mock_engine._db = "/path/to/thoughts.db" with patch("timmy.thinking.thinking_engine", mock_engine): result = _check_capability_thinking() assert result is True def test_check_thinking_returns_false_when_no_db(self): """_check_capability_thinking returns False when _db is None.""" from dashboard.routes.world import _check_capability_thinking mock_engine = MagicMock() mock_engine._db = None with patch("timmy.thinking.thinking_engine", mock_engine): result = _check_capability_thinking() assert result is False def test_check_memory_returns_true_when_hot_memory_exists(self, tmp_path): """_check_capability_memory returns True when HOT_MEMORY_PATH exists.""" from dashboard.routes.world import _check_capability_memory # Create a temporary memory path mock_path = MagicMock() mock_path.exists.return_value = True with patch("timmy.memory_system.HOT_MEMORY_PATH", mock_path): result = _check_capability_memory() assert result is True def test_check_memory_returns_false_when_not_exists(self): """_check_capability_memory returns False when HOT_MEMORY_PATH doesn't exist.""" from dashboard.routes.world import _check_capability_memory mock_path = MagicMock() mock_path.exists.return_value = False with patch("timmy.memory_system.HOT_MEMORY_PATH", mock_path): result = _check_capability_memory() assert result is False def test_check_bark_returns_true_when_produce_bark_available(self): """_check_capability_bark returns True when produce_bark is callable.""" from dashboard.routes.world import _check_capability_bark with patch( "infrastructure.presence.produce_bark", return_value={"type": "bark"}, ): result = _check_capability_bark() assert result is True def test_check_familiar_returns_true_when_pip_available(self): """_check_capability_familiar returns True when pip_familiar is available.""" from dashboard.routes.world import _check_capability_familiar mock_pip = MagicMock() with patch("timmy.familiar.pip_familiar", mock_pip): result = _check_capability_familiar() assert result is True def test_check_familiar_returns_false_when_pip_none(self): """_check_capability_familiar returns False when pip_familiar is None.""" from dashboard.routes.world import _check_capability_familiar with patch("timmy.familiar.pip_familiar", None): result = _check_capability_familiar() assert result is False def test_check_lightning_returns_false(self): """_check_capability_lightning returns False (disabled per health.py).""" from dashboard.routes.world import _check_capability_lightning result = _check_capability_lightning() assert result is False def test_health_response_time_is_fast(self, matrix_client): """Health endpoint responds quickly (<100ms for lightweight checks).""" import time start = time.time() resp = matrix_client.get("/api/matrix/health") elapsed = time.time() - start assert resp.status_code == 200 # Should be very fast since checks are lightweight assert elapsed < 1.0 # Generous timeout for test environments