test_channel_directory.py
1 """Tests for gateway/channel_directory.py — channel resolution and display.""" 2 3 import asyncio 4 import json 5 import os 6 from pathlib import Path 7 from types import SimpleNamespace 8 from unittest.mock import AsyncMock, MagicMock, patch 9 10 from gateway.channel_directory import ( 11 build_channel_directory, 12 lookup_channel_type, 13 resolve_channel_name, 14 format_directory_for_display, 15 load_directory, 16 _build_from_sessions, 17 _build_slack, 18 DIRECTORY_PATH, 19 ) 20 21 22 def _write_directory(tmp_path, platforms): 23 """Helper to write a fake channel directory.""" 24 data = {"updated_at": "2026-01-01T00:00:00", "platforms": platforms} 25 cache_file = tmp_path / "channel_directory.json" 26 cache_file.write_text(json.dumps(data)) 27 return cache_file 28 29 30 class TestLoadDirectory: 31 def test_missing_file(self, tmp_path): 32 with patch("gateway.channel_directory.DIRECTORY_PATH", tmp_path / "nope.json"): 33 result = load_directory() 34 assert result["updated_at"] is None 35 assert result["platforms"] == {} 36 37 def test_valid_file(self, tmp_path): 38 cache_file = _write_directory(tmp_path, { 39 "telegram": [{"id": "123", "name": "John", "type": "dm"}] 40 }) 41 with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): 42 result = load_directory() 43 assert result["platforms"]["telegram"][0]["name"] == "John" 44 45 def test_corrupt_file(self, tmp_path): 46 cache_file = tmp_path / "channel_directory.json" 47 cache_file.write_text("{bad json") 48 with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): 49 result = load_directory() 50 assert result["updated_at"] is None 51 52 53 class TestBuildChannelDirectoryWrites: 54 def test_failed_write_preserves_previous_cache(self, tmp_path, monkeypatch): 55 cache_file = _write_directory(tmp_path, { 56 "telegram": [{"id": "123", "name": "Alice", "type": "dm"}] 57 }) 58 previous = json.loads(cache_file.read_text()) 59 60 def broken_dump(data, fp, *args, **kwargs): 61 fp.write('{"updated_at":') 62 fp.flush() 63 raise OSError("disk full") 64 65 monkeypatch.setattr(json, "dump", broken_dump) 66 67 with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): 68 asyncio.run(build_channel_directory({})) 69 result = load_directory() 70 71 assert result == previous 72 73 74 class TestResolveChannelName: 75 def _setup(self, tmp_path, platforms): 76 cache_file = _write_directory(tmp_path, platforms) 77 return patch("gateway.channel_directory.DIRECTORY_PATH", cache_file) 78 79 def test_exact_match(self, tmp_path): 80 platforms = { 81 "discord": [ 82 {"id": "111", "name": "bot-home", "guild": "MyServer", "type": "channel"}, 83 {"id": "222", "name": "general", "guild": "MyServer", "type": "channel"}, 84 ] 85 } 86 with self._setup(tmp_path, platforms): 87 assert resolve_channel_name("discord", "bot-home") == "111" 88 assert resolve_channel_name("discord", "#bot-home") == "111" 89 90 def test_case_insensitive(self, tmp_path): 91 platforms = { 92 "slack": [{"id": "C01", "name": "Engineering", "type": "channel"}] 93 } 94 with self._setup(tmp_path, platforms): 95 assert resolve_channel_name("slack", "engineering") == "C01" 96 assert resolve_channel_name("slack", "ENGINEERING") == "C01" 97 98 def test_guild_qualified_match(self, tmp_path): 99 platforms = { 100 "discord": [ 101 {"id": "111", "name": "general", "guild": "ServerA", "type": "channel"}, 102 {"id": "222", "name": "general", "guild": "ServerB", "type": "channel"}, 103 ] 104 } 105 with self._setup(tmp_path, platforms): 106 assert resolve_channel_name("discord", "ServerA/general") == "111" 107 assert resolve_channel_name("discord", "ServerB/general") == "222" 108 109 def test_prefix_match_unambiguous(self, tmp_path): 110 platforms = { 111 "slack": [ 112 {"id": "C01", "name": "engineering-backend", "type": "channel"}, 113 {"id": "C02", "name": "design-team", "type": "channel"}, 114 ] 115 } 116 with self._setup(tmp_path, platforms): 117 # "engineering" prefix matches only one channel 118 assert resolve_channel_name("slack", "engineering") == "C01" 119 120 def test_prefix_match_ambiguous_returns_none(self, tmp_path): 121 platforms = { 122 "slack": [ 123 {"id": "C01", "name": "eng-backend", "type": "channel"}, 124 {"id": "C02", "name": "eng-frontend", "type": "channel"}, 125 ] 126 } 127 with self._setup(tmp_path, platforms): 128 assert resolve_channel_name("slack", "eng") is None 129 130 def test_no_channels_returns_none(self, tmp_path): 131 with self._setup(tmp_path, {}): 132 assert resolve_channel_name("telegram", "someone") is None 133 134 def test_no_match_returns_none(self, tmp_path): 135 platforms = { 136 "telegram": [{"id": "123", "name": "John", "type": "dm"}] 137 } 138 with self._setup(tmp_path, platforms): 139 assert resolve_channel_name("telegram", "nonexistent") is None 140 141 def test_topic_name_resolves_to_composite_id(self, tmp_path): 142 platforms = { 143 "telegram": [{"id": "-1001:17585", "name": "Coaching Chat / topic 17585", "type": "group"}] 144 } 145 with self._setup(tmp_path, platforms): 146 assert resolve_channel_name("telegram", "Coaching Chat / topic 17585") == "-1001:17585" 147 148 def test_id_match_takes_precedence_over_name(self, tmp_path): 149 """A raw channel ID resolves to itself, even when a different 150 channel happens to be named the same string. Case-sensitive: Slack 151 IDs are uppercase and must not be normalized away.""" 152 platforms = { 153 "slack": [ 154 {"id": "C0B0QV5434G", "name": "engineering", "type": "channel"}, 155 {"id": "C99", "name": "c0b0qv5434g", "type": "channel"}, 156 ] 157 } 158 with self._setup(tmp_path, platforms): 159 assert resolve_channel_name("slack", "C0B0QV5434G") == "C0B0QV5434G" 160 # Lowercase still falls through to name matching (case-insensitive) 161 assert resolve_channel_name("slack", "c0b0qv5434g") == "C99" 162 163 def test_display_label_with_type_suffix_resolves(self, tmp_path): 164 platforms = { 165 "telegram": [ 166 {"id": "123", "name": "Alice", "type": "dm"}, 167 {"id": "456", "name": "Dev Group", "type": "group"}, 168 {"id": "-1001:17585", "name": "Coaching Chat / topic 17585", "type": "group"}, 169 ] 170 } 171 with self._setup(tmp_path, platforms): 172 assert resolve_channel_name("telegram", "Alice (dm)") == "123" 173 assert resolve_channel_name("telegram", "Dev Group (group)") == "456" 174 assert resolve_channel_name("telegram", "Coaching Chat / topic 17585 (group)") == "-1001:17585" 175 176 177 class TestBuildFromSessions: 178 def _write_sessions(self, tmp_path, sessions_data): 179 """Write sessions.json at the path _build_from_sessions expects.""" 180 sessions_path = tmp_path / "sessions" / "sessions.json" 181 sessions_path.parent.mkdir(parents=True) 182 sessions_path.write_text(json.dumps(sessions_data)) 183 184 def test_builds_from_sessions_json(self, tmp_path): 185 self._write_sessions(tmp_path, { 186 "session_1": { 187 "origin": { 188 "platform": "telegram", 189 "chat_id": "12345", 190 "chat_name": "Alice", 191 }, 192 "chat_type": "dm", 193 }, 194 "session_2": { 195 "origin": { 196 "platform": "telegram", 197 "chat_id": "67890", 198 "user_name": "Bob", 199 }, 200 "chat_type": "group", 201 }, 202 "session_3": { 203 "origin": { 204 "platform": "discord", 205 "chat_id": "99999", 206 }, 207 }, 208 }) 209 210 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 211 entries = _build_from_sessions("telegram") 212 213 assert len(entries) == 2 214 names = {e["name"] for e in entries} 215 assert "Alice" in names 216 assert "Bob" in names 217 218 def test_missing_sessions_file(self, tmp_path): 219 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 220 entries = _build_from_sessions("telegram") 221 assert entries == [] 222 223 def test_deduplication_by_chat_id(self, tmp_path): 224 self._write_sessions(tmp_path, { 225 "s1": {"origin": {"platform": "telegram", "chat_id": "123", "chat_name": "X"}}, 226 "s2": {"origin": {"platform": "telegram", "chat_id": "123", "chat_name": "X"}}, 227 }) 228 229 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 230 entries = _build_from_sessions("telegram") 231 232 assert len(entries) == 1 233 234 def test_keeps_distinct_topics_with_same_chat_id(self, tmp_path): 235 self._write_sessions(tmp_path, { 236 "group_root": { 237 "origin": {"platform": "telegram", "chat_id": "-1001", "chat_name": "Coaching Chat"}, 238 "chat_type": "group", 239 }, 240 "topic_a": { 241 "origin": { 242 "platform": "telegram", 243 "chat_id": "-1001", 244 "chat_name": "Coaching Chat", 245 "thread_id": "17585", 246 }, 247 "chat_type": "group", 248 }, 249 "topic_b": { 250 "origin": { 251 "platform": "telegram", 252 "chat_id": "-1001", 253 "chat_name": "Coaching Chat", 254 "thread_id": "17587", 255 }, 256 "chat_type": "group", 257 }, 258 }) 259 260 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 261 entries = _build_from_sessions("telegram") 262 263 ids = {entry["id"] for entry in entries} 264 names = {entry["name"] for entry in entries} 265 assert ids == {"-1001", "-1001:17585", "-1001:17587"} 266 assert "Coaching Chat" in names 267 assert "Coaching Chat / topic 17585" in names 268 assert "Coaching Chat / topic 17587" in names 269 270 271 class TestFormatDirectoryForDisplay: 272 def test_empty_directory(self, tmp_path): 273 with patch("gateway.channel_directory.DIRECTORY_PATH", tmp_path / "nope.json"): 274 result = format_directory_for_display() 275 assert "No messaging platforms" in result 276 277 def test_telegram_display(self, tmp_path): 278 cache_file = _write_directory(tmp_path, { 279 "telegram": [ 280 {"id": "123", "name": "Alice", "type": "dm"}, 281 {"id": "456", "name": "Dev Group", "type": "group"}, 282 {"id": "-1001:17585", "name": "Coaching Chat / topic 17585", "type": "group"}, 283 ] 284 }) 285 with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): 286 result = format_directory_for_display() 287 288 assert "Telegram:" in result 289 assert "telegram:Alice" in result 290 assert "telegram:Dev Group" in result 291 assert "telegram:Coaching Chat / topic 17585" in result 292 293 def test_discord_grouped_by_guild(self, tmp_path): 294 cache_file = _write_directory(tmp_path, { 295 "discord": [ 296 {"id": "1", "name": "general", "guild": "Server1", "type": "channel"}, 297 {"id": "2", "name": "bot-home", "guild": "Server1", "type": "channel"}, 298 {"id": "3", "name": "chat", "guild": "Server2", "type": "channel"}, 299 ] 300 }) 301 with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): 302 result = format_directory_for_display() 303 304 assert "Discord (Server1):" in result 305 assert "Discord (Server2):" in result 306 assert "discord:#general" in result 307 308 309 class TestLookupChannelType: 310 def _setup(self, tmp_path, platforms): 311 cache_file = _write_directory(tmp_path, platforms) 312 return patch("gateway.channel_directory.DIRECTORY_PATH", cache_file) 313 314 def test_forum_channel(self, tmp_path): 315 platforms = { 316 "discord": [ 317 {"id": "100", "name": "ideas", "guild": "Server1", "type": "forum"}, 318 ] 319 } 320 with self._setup(tmp_path, platforms): 321 assert lookup_channel_type("discord", "100") == "forum" 322 323 def test_regular_channel(self, tmp_path): 324 platforms = { 325 "discord": [ 326 {"id": "200", "name": "general", "guild": "Server1", "type": "channel"}, 327 ] 328 } 329 with self._setup(tmp_path, platforms): 330 assert lookup_channel_type("discord", "200") == "channel" 331 332 def test_unknown_chat_id_returns_none(self, tmp_path): 333 platforms = { 334 "discord": [ 335 {"id": "200", "name": "general", "guild": "Server1", "type": "channel"}, 336 ] 337 } 338 with self._setup(tmp_path, platforms): 339 assert lookup_channel_type("discord", "999") is None 340 341 def test_unknown_platform_returns_none(self, tmp_path): 342 with self._setup(tmp_path, {}): 343 assert lookup_channel_type("discord", "100") is None 344 345 def test_channel_without_type_key_returns_none(self, tmp_path): 346 platforms = { 347 "discord": [ 348 {"id": "300", "name": "general", "guild": "Server1"}, 349 ] 350 } 351 with self._setup(tmp_path, platforms): 352 assert lookup_channel_type("discord", "300") is None 353 354 355 def _make_slack_adapter(team_clients): 356 """Build a stand-in for SlackAdapter exposing only ``_team_clients``.""" 357 return SimpleNamespace(_team_clients=team_clients) 358 359 360 def _make_slack_client(pages): 361 """Build an AsyncWebClient mock whose ``users_conversations`` returns pages.""" 362 client = MagicMock() 363 client.users_conversations = AsyncMock(side_effect=pages) 364 return client 365 366 367 class TestBuildSlack: 368 """_build_slack actually calls users.conversations on each workspace client.""" 369 370 def test_no_team_clients_falls_back_to_sessions(self, tmp_path): 371 sessions_path = tmp_path / "sessions" / "sessions.json" 372 sessions_path.parent.mkdir(parents=True) 373 sessions_path.write_text(json.dumps({ 374 "s1": {"origin": {"platform": "slack", "chat_id": "D123", "chat_name": "Alice"}}, 375 })) 376 377 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 378 entries = asyncio.run(_build_slack(_make_slack_adapter({}))) 379 380 assert len(entries) == 1 381 assert entries[0]["id"] == "D123" 382 383 def test_lists_channels_from_users_conversations(self, tmp_path): 384 client = _make_slack_client([ 385 { 386 "ok": True, 387 "channels": [ 388 {"id": "C0B0QV5434G", "name": "engineering", "is_private": False}, 389 {"id": "G123ABCDEF", "name": "secret-chat", "is_private": True}, 390 ], 391 "response_metadata": {}, 392 }, 393 ]) 394 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 395 entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client}))) 396 397 ids = {e["id"] for e in entries} 398 assert ids == {"C0B0QV5434G", "G123ABCDEF"} 399 types = {e["id"]: e["type"] for e in entries} 400 assert types["C0B0QV5434G"] == "channel" 401 assert types["G123ABCDEF"] == "private" 402 client.users_conversations.assert_awaited_once() 403 404 def test_paginates_via_response_metadata_cursor(self, tmp_path): 405 client = _make_slack_client([ 406 { 407 "ok": True, 408 "channels": [{"id": "C001", "name": "first", "is_private": False}], 409 "response_metadata": {"next_cursor": "cur1"}, 410 }, 411 { 412 "ok": True, 413 "channels": [{"id": "C002", "name": "second", "is_private": False}], 414 "response_metadata": {"next_cursor": ""}, 415 }, 416 ]) 417 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 418 entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client}))) 419 420 assert {e["id"] for e in entries} == {"C001", "C002"} 421 assert client.users_conversations.await_count == 2 422 423 def test_per_workspace_error_does_not_block_others(self, tmp_path): 424 bad = MagicMock() 425 bad.users_conversations = AsyncMock(side_effect=RuntimeError("boom")) 426 good = _make_slack_client([ 427 { 428 "ok": True, 429 "channels": [{"id": "C999", "name": "ok-channel", "is_private": False}], 430 "response_metadata": {}, 431 }, 432 ]) 433 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 434 entries = asyncio.run(_build_slack(_make_slack_adapter({"BAD": bad, "GOOD": good}))) 435 436 assert {e["id"] for e in entries} == {"C999"} 437 438 def test_session_dms_merged_when_not_in_api_results(self, tmp_path): 439 sessions_path = tmp_path / "sessions" / "sessions.json" 440 sessions_path.parent.mkdir(parents=True) 441 sessions_path.write_text(json.dumps({ 442 "s1": {"origin": {"platform": "slack", "chat_id": "D456", "chat_name": "Bob"}}, 443 "dup": {"origin": {"platform": "slack", "chat_id": "C001", "chat_name": "first"}}, 444 })) 445 client = _make_slack_client([ 446 { 447 "ok": True, 448 "channels": [{"id": "C001", "name": "first", "is_private": False}], 449 "response_metadata": {}, 450 }, 451 ]) 452 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 453 entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client}))) 454 455 ids = {e["id"] for e in entries} 456 assert "C001" in ids and "D456" in ids 457 # Channel ID from API should not be duplicated by the session merge 458 assert sum(1 for e in entries if e["id"] == "C001") == 1 459 460 def test_skips_channels_with_no_id_or_name(self, tmp_path): 461 client = _make_slack_client([ 462 { 463 "ok": True, 464 "channels": [ 465 {"id": "C001", "name": "good", "is_private": False}, 466 {"id": "", "name": "no-id"}, 467 {"id": "C002"}, # no name (e.g. IM) 468 ], 469 "response_metadata": {}, 470 }, 471 ]) 472 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 473 entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client}))) 474 475 assert {e["id"] for e in entries} == {"C001"} 476 477 def test_response_not_ok_breaks_pagination_for_that_workspace(self, tmp_path): 478 client = _make_slack_client([ 479 {"ok": False, "error": "missing_scope"}, 480 ]) 481 with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): 482 entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client}))) 483 484 assert entries == []