/ tests / gateway / test_channel_directory.py
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 == []