/ tests / plugins / test_google_meet_node.py
test_google_meet_node.py
  1  """Tests for the google_meet node primitive.
  2  
  3  Covers protocol helpers, the file-backed registry, the server's
  4  token-and-dispatch machinery, a mocked client, and the CLI plumbing.
  5  We never open a real socket — websockets.serve / websockets.sync.client
  6  are fully mocked.
  7  """
  8  
  9  from __future__ import annotations
 10  
 11  import argparse
 12  import asyncio
 13  import json
 14  from pathlib import Path
 15  from unittest.mock import MagicMock, patch
 16  
 17  import pytest
 18  
 19  
 20  @pytest.fixture(autouse=True)
 21  def _isolate_home(tmp_path, monkeypatch):
 22      hermes_home = tmp_path / ".hermes"
 23      hermes_home.mkdir()
 24      monkeypatch.setenv("HERMES_HOME", str(hermes_home))
 25      yield hermes_home
 26  
 27  
 28  # ---------------------------------------------------------------------------
 29  # protocol.py
 30  # ---------------------------------------------------------------------------
 31  
 32  def test_protocol_encode_decode_roundtrip():
 33      from plugins.google_meet.node import protocol
 34  
 35      msg = protocol.make_request("ping", "tok", {"x": 1}, req_id="abc")
 36      raw = protocol.encode(msg)
 37      out = protocol.decode(raw)
 38      assert out == msg
 39      assert out["type"] == "ping"
 40      assert out["id"] == "abc"
 41      assert out["token"] == "tok"
 42      assert out["payload"] == {"x": 1}
 43  
 44  
 45  def test_protocol_make_request_autogenerates_id():
 46      from plugins.google_meet.node import protocol
 47  
 48      a = protocol.make_request("ping", "tok", {})
 49      b = protocol.make_request("ping", "tok", {})
 50      assert a["id"] != b["id"]
 51      assert len(a["id"]) >= 16  # uuid4 hex
 52  
 53  
 54  def test_protocol_make_request_rejects_bad_input():
 55      from plugins.google_meet.node import protocol
 56  
 57      with pytest.raises(ValueError):
 58          protocol.make_request("", "tok", {})
 59      with pytest.raises(ValueError):
 60          protocol.make_request("unknown_type", "tok", {})
 61      with pytest.raises(ValueError):
 62          protocol.make_request("ping", "tok", "not a dict")  # type: ignore[arg-type]
 63  
 64  
 65  def test_protocol_decode_raises_on_malformed():
 66      from plugins.google_meet.node import protocol
 67  
 68      with pytest.raises(ValueError):
 69          protocol.decode("not json at all")
 70      with pytest.raises(ValueError):
 71          protocol.decode("[]")  # list, not object
 72      with pytest.raises(ValueError):
 73          protocol.decode(json.dumps({"id": "x"}))  # missing type
 74      with pytest.raises(ValueError):
 75          protocol.decode(json.dumps({"type": "ping"}))  # missing id
 76  
 77  
 78  def test_protocol_validate_request_happy_path():
 79      from plugins.google_meet.node import protocol
 80  
 81      msg = protocol.make_request("status", "secret", {})
 82      ok, reason = protocol.validate_request(msg, "secret")
 83      assert ok is True
 84      assert reason == ""
 85  
 86  
 87  def test_protocol_validate_request_rejects_bad_token():
 88      from plugins.google_meet.node import protocol
 89  
 90      msg = protocol.make_request("status", "wrong", {})
 91      ok, reason = protocol.validate_request(msg, "right")
 92      assert ok is False
 93      assert "token" in reason.lower()
 94  
 95  
 96  def test_protocol_validate_request_rejects_unknown_type():
 97      from plugins.google_meet.node import protocol
 98  
 99      raw = {"type": "nope", "id": "1", "token": "t", "payload": {}}
100      ok, reason = protocol.validate_request(raw, "t")
101      assert ok is False
102      assert "unknown" in reason.lower()
103  
104  
105  def test_protocol_validate_request_rejects_missing_id():
106      from plugins.google_meet.node import protocol
107  
108      raw = {"type": "ping", "token": "t", "payload": {}}
109      ok, reason = protocol.validate_request(raw, "t")
110      assert ok is False
111      assert "id" in reason.lower()
112  
113  
114  def test_protocol_validate_request_rejects_non_dict_payload():
115      from plugins.google_meet.node import protocol
116  
117      raw = {"type": "ping", "id": "1", "token": "t", "payload": "oops"}
118      ok, reason = protocol.validate_request(raw, "t")
119      assert ok is False
120  
121  
122  def test_protocol_error_envelope_shape():
123      from plugins.google_meet.node import protocol
124  
125      err = protocol.make_error("abc", "nope")
126      assert err == {"type": "error", "id": "abc", "error": "nope"}
127  
128  
129  # ---------------------------------------------------------------------------
130  # registry.py
131  # ---------------------------------------------------------------------------
132  
133  def test_registry_add_get_roundtrip_persists(tmp_path):
134      from plugins.google_meet.node.registry import NodeRegistry
135  
136      p = tmp_path / "nodes.json"
137      r = NodeRegistry(path=p)
138      r.add("mac", "ws://mac.local:18789", "deadbeef")
139  
140      # Second instance sees it.
141      r2 = NodeRegistry(path=p)
142      entry = r2.get("mac")
143      assert entry is not None
144      assert entry["name"] == "mac"
145      assert entry["url"] == "ws://mac.local:18789"
146      assert entry["token"] == "deadbeef"
147      assert "added_at" in entry
148  
149  
150  def test_registry_get_returns_none_when_missing(tmp_path):
151      from plugins.google_meet.node.registry import NodeRegistry
152  
153      r = NodeRegistry(path=tmp_path / "n.json")
154      assert r.get("ghost") is None
155  
156  
157  def test_registry_remove(tmp_path):
158      from plugins.google_meet.node.registry import NodeRegistry
159  
160      r = NodeRegistry(path=tmp_path / "n.json")
161      r.add("a", "ws://a", "t")
162      assert r.remove("a") is True
163      assert r.get("a") is None
164      assert r.remove("a") is False  # idempotent
165  
166  
167  def test_registry_list_all_sorted(tmp_path):
168      from plugins.google_meet.node.registry import NodeRegistry
169  
170      r = NodeRegistry(path=tmp_path / "n.json")
171      r.add("zeta", "ws://z", "t1")
172      r.add("alpha", "ws://a", "t2")
173      names = [n["name"] for n in r.list_all()]
174      assert names == ["alpha", "zeta"]
175  
176  
177  def test_registry_resolve_auto_picks_single(tmp_path):
178      from plugins.google_meet.node.registry import NodeRegistry
179  
180      r = NodeRegistry(path=tmp_path / "n.json")
181      r.add("mac", "ws://mac", "t")
182      picked = r.resolve(None)
183      assert picked is not None
184      assert picked["name"] == "mac"
185  
186  
187  def test_registry_resolve_ambiguous_returns_none(tmp_path):
188      from plugins.google_meet.node.registry import NodeRegistry
189  
190      r = NodeRegistry(path=tmp_path / "n.json")
191      r.add("a", "ws://a", "t")
192      r.add("b", "ws://b", "t")
193      assert r.resolve(None) is None
194  
195  
196  def test_registry_resolve_empty_returns_none(tmp_path):
197      from plugins.google_meet.node.registry import NodeRegistry
198  
199      r = NodeRegistry(path=tmp_path / "n.json")
200      assert r.resolve(None) is None
201  
202  
203  def test_registry_resolve_by_name(tmp_path):
204      from plugins.google_meet.node.registry import NodeRegistry
205  
206      r = NodeRegistry(path=tmp_path / "n.json")
207      r.add("a", "ws://a", "t")
208      r.add("b", "ws://b", "t")
209      picked = r.resolve("b")
210      assert picked is not None
211      assert picked["name"] == "b"
212      assert r.resolve("ghost") is None
213  
214  
215  def test_registry_defaults_to_hermes_home(tmp_path, monkeypatch):
216      from plugins.google_meet.node.registry import NodeRegistry
217  
218      # _isolate_home already set HERMES_HOME to tmp_path/.hermes; the
219      # registry default path must live inside that tree.
220      r = NodeRegistry()
221      r.add("x", "ws://x", "t")
222      expected = Path(tmp_path) / ".hermes" / "workspace" / "meetings" / "nodes.json"
223      assert expected.is_file()
224  
225  
226  # ---------------------------------------------------------------------------
227  # server.py — token + dispatch
228  # ---------------------------------------------------------------------------
229  
230  def test_server_ensure_token_generates_and_persists(tmp_path):
231      from plugins.google_meet.node.server import NodeServer
232  
233      p = tmp_path / "tok.json"
234      s1 = NodeServer(token_path=p)
235      t1 = s1.ensure_token()
236      assert isinstance(t1, str) and len(t1) == 32
237  
238      # Reuse on a fresh instance.
239      s2 = NodeServer(token_path=p)
240      t2 = s2.ensure_token()
241      assert t1 == t2
242  
243      data = json.loads(p.read_text(encoding="utf-8"))
244      assert data["token"] == t1
245      assert "generated_at" in data
246  
247  
248  def test_server_get_token_is_idempotent(tmp_path):
249      from plugins.google_meet.node.server import NodeServer
250  
251      s = NodeServer(token_path=tmp_path / "t.json")
252      assert s.get_token() == s.get_token()
253  
254  
255  def _run(coro):
256      return asyncio.new_event_loop().run_until_complete(coro) if False else asyncio.run(coro)
257  
258  
259  def test_server_handle_request_rejects_bad_token(tmp_path):
260      from plugins.google_meet.node.server import NodeServer
261      from plugins.google_meet.node import protocol
262  
263      s = NodeServer(token_path=tmp_path / "t.json")
264      s.ensure_token()
265      bad = protocol.make_request("ping", "not-the-token", {})
266      resp = asyncio.run(s._handle_request(bad))
267      assert resp["type"] == "error"
268      assert "token" in resp["error"].lower()
269  
270  
271  def test_server_handle_request_ping(tmp_path):
272      from plugins.google_meet.node.server import NodeServer
273      from plugins.google_meet.node import protocol
274  
275      s = NodeServer(token_path=tmp_path / "t.json", display_name="node-x")
276      tok = s.ensure_token()
277      req = protocol.make_request("ping", tok, {})
278      resp = asyncio.run(s._handle_request(req))
279      assert resp["type"] == "pong"
280      assert resp["id"] == req["id"]
281      assert resp["payload"]["display_name"] == "node-x"
282  
283  
284  def test_server_handle_request_status_dispatches_to_pm(tmp_path, monkeypatch):
285      from plugins.google_meet.node.server import NodeServer
286      from plugins.google_meet.node import protocol
287      from plugins.google_meet import process_manager as pm
288  
289      monkeypatch.setattr(pm, "status",
290                          lambda: {"ok": True, "alive": True, "meetingId": "abc"})
291  
292      s = NodeServer(token_path=tmp_path / "t.json")
293      tok = s.ensure_token()
294      req = protocol.make_request("status", tok, {})
295      resp = asyncio.run(s._handle_request(req))
296      assert resp["type"] == "response"
297      assert resp["id"] == req["id"]
298      assert resp["payload"] == {"ok": True, "alive": True, "meetingId": "abc"}
299  
300  
301  def test_server_handle_request_start_bot_dispatches(tmp_path, monkeypatch):
302      from plugins.google_meet.node.server import NodeServer
303      from plugins.google_meet.node import protocol
304      from plugins.google_meet import process_manager as pm
305  
306      captured = {}
307  
308      def fake_start(**kwargs):
309          captured.update(kwargs)
310          return {"ok": True, "pid": 42, "meeting_id": "abc-defg-hij"}
311  
312      monkeypatch.setattr(pm, "start", fake_start)
313  
314      s = NodeServer(token_path=tmp_path / "t.json")
315      tok = s.ensure_token()
316      req = protocol.make_request("start_bot", tok, {
317          "url": "https://meet.google.com/abc-defg-hij",
318          "guest_name": "Bot",
319          "duration": "30m",
320      })
321      resp = asyncio.run(s._handle_request(req))
322      assert resp["type"] == "response"
323      assert resp["payload"]["ok"] is True
324      assert captured["url"] == "https://meet.google.com/abc-defg-hij"
325      assert captured["guest_name"] == "Bot"
326      assert captured["duration"] == "30m"
327  
328  
329  def test_server_handle_request_start_bot_missing_url(tmp_path):
330      from plugins.google_meet.node.server import NodeServer
331      from plugins.google_meet.node import protocol
332  
333      s = NodeServer(token_path=tmp_path / "t.json")
334      tok = s.ensure_token()
335      req = protocol.make_request("start_bot", tok, {"guest_name": "x"})
336      resp = asyncio.run(s._handle_request(req))
337      assert resp["type"] == "error"
338      assert "url" in resp["error"]
339  
340  
341  def test_server_handle_request_stop_dispatches(tmp_path, monkeypatch):
342      from plugins.google_meet.node.server import NodeServer
343      from plugins.google_meet.node import protocol
344      from plugins.google_meet import process_manager as pm
345  
346      got = {}
347  
348      def fake_stop(*, reason="requested"):
349          got["reason"] = reason
350          return {"ok": True, "reason": reason}
351  
352      monkeypatch.setattr(pm, "stop", fake_stop)
353  
354      s = NodeServer(token_path=tmp_path / "t.json")
355      tok = s.ensure_token()
356      req = protocol.make_request("stop", tok, {"reason": "user-cancel"})
357      resp = asyncio.run(s._handle_request(req))
358      assert resp["type"] == "response"
359      assert got["reason"] == "user-cancel"
360  
361  
362  def test_server_handle_request_transcript(tmp_path, monkeypatch):
363      from plugins.google_meet.node.server import NodeServer
364      from plugins.google_meet.node import protocol
365      from plugins.google_meet import process_manager as pm
366  
367      got = {}
368  
369      def fake_transcript(last=None):
370          got["last"] = last
371          return {"ok": True, "lines": ["a", "b"], "total": 2}
372  
373      monkeypatch.setattr(pm, "transcript", fake_transcript)
374  
375      s = NodeServer(token_path=tmp_path / "t.json")
376      tok = s.ensure_token()
377      req = protocol.make_request("transcript", tok, {"last": 5})
378      resp = asyncio.run(s._handle_request(req))
379      assert resp["type"] == "response"
380      assert resp["payload"]["lines"] == ["a", "b"]
381      assert got["last"] == 5
382  
383  
384  def test_server_handle_request_say_enqueues_when_active(tmp_path, monkeypatch):
385      from plugins.google_meet.node.server import NodeServer
386      from plugins.google_meet.node import protocol
387      from plugins.google_meet import process_manager as pm
388  
389      out = tmp_path / "meet-out"
390      out.mkdir()
391      monkeypatch.setattr(pm, "_read_active",
392                          lambda: {"pid": 1, "meeting_id": "m", "out_dir": str(out)})
393  
394      s = NodeServer(token_path=tmp_path / "t.json")
395      tok = s.ensure_token()
396      req = protocol.make_request("say", tok, {"text": "hello"})
397      resp = asyncio.run(s._handle_request(req))
398      assert resp["type"] == "response"
399      assert resp["payload"]["ok"] is True
400      assert resp["payload"]["enqueued"] is True
401      q = (out / "say_queue.jsonl").read_text(encoding="utf-8").strip().splitlines()
402      assert len(q) == 1
403      assert json.loads(q[0])["text"] == "hello"
404  
405  
406  def test_server_handle_request_say_without_active_still_ok(tmp_path, monkeypatch):
407      from plugins.google_meet.node.server import NodeServer
408      from plugins.google_meet.node import protocol
409      from plugins.google_meet import process_manager as pm
410  
411      monkeypatch.setattr(pm, "_read_active", lambda: None)
412  
413      s = NodeServer(token_path=tmp_path / "t.json")
414      tok = s.ensure_token()
415      req = protocol.make_request("say", tok, {"text": "hi"})
416      resp = asyncio.run(s._handle_request(req))
417      assert resp["type"] == "response"
418      assert resp["payload"]["ok"] is True
419      assert resp["payload"]["enqueued"] is False
420  
421  
422  def test_server_handle_request_wraps_pm_exceptions(tmp_path, monkeypatch):
423      from plugins.google_meet.node.server import NodeServer
424      from plugins.google_meet.node import protocol
425      from plugins.google_meet import process_manager as pm
426  
427      def boom():
428          raise ValueError("kaboom")
429  
430      monkeypatch.setattr(pm, "status", boom)
431  
432      s = NodeServer(token_path=tmp_path / "t.json")
433      tok = s.ensure_token()
434      req = protocol.make_request("status", tok, {})
435      resp = asyncio.run(s._handle_request(req))
436      assert resp["type"] == "error"
437      assert "kaboom" in resp["error"]
438  
439  
440  # ---------------------------------------------------------------------------
441  # client.py
442  # ---------------------------------------------------------------------------
443  
444  class _FakeWS:
445      """Minimal context-manager stand-in for websockets.sync.client.connect."""
446  
447      def __init__(self, reply_builder):
448          self._reply_builder = reply_builder
449          self.sent = []
450  
451      def __enter__(self):
452          return self
453  
454      def __exit__(self, exc_type, exc, tb):
455          return False
456  
457      def send(self, raw):
458          self.sent.append(raw)
459  
460      def recv(self, timeout=None):
461          return self._reply_builder(self.sent[-1])
462  
463  
464  def _install_fake_ws(monkeypatch, reply_builder):
465      fake_ws_holder = {}
466  
467      def _connect(url, **kwargs):
468          ws = _FakeWS(reply_builder)
469          fake_ws_holder["ws"] = ws
470          fake_ws_holder["url"] = url
471          fake_ws_holder["kwargs"] = kwargs
472          return ws
473  
474      # Patch the concrete import site inside client._rpc
475      import websockets.sync.client as wsc  # type: ignore
476      monkeypatch.setattr(wsc, "connect", _connect)
477      return fake_ws_holder
478  
479  
480  def test_client_rpc_sends_correct_envelope_and_parses_response(monkeypatch):
481      from plugins.google_meet.node.client import NodeClient
482      from plugins.google_meet.node import protocol
483  
484      def reply(raw_out):
485          req = protocol.decode(raw_out)
486          return protocol.encode(protocol.make_response(req["id"], {"ok": True, "echo": req["type"]}))
487  
488      holder = _install_fake_ws(monkeypatch, reply)
489  
490      c = NodeClient("ws://remote:1", "tok123")
491      out = c._rpc("ping", {"hello": 1})
492      assert out == {"ok": True, "echo": "ping"}
493  
494      sent = json.loads(holder["ws"].sent[0])
495      assert sent["type"] == "ping"
496      assert sent["token"] == "tok123"
497      assert sent["payload"] == {"hello": 1}
498      assert sent["id"]  # non-empty
499      assert holder["url"] == "ws://remote:1"
500  
501  
502  def test_client_rpc_raises_on_error_envelope(monkeypatch):
503      from plugins.google_meet.node.client import NodeClient
504      from plugins.google_meet.node import protocol
505  
506      def reply(raw_out):
507          req = protocol.decode(raw_out)
508          return protocol.encode(protocol.make_error(req["id"], "nope"))
509  
510      _install_fake_ws(monkeypatch, reply)
511  
512      c = NodeClient("ws://x", "t")
513      with pytest.raises(RuntimeError, match="nope"):
514          c._rpc("ping", {})
515  
516  
517  def test_client_rpc_raises_on_id_mismatch(monkeypatch):
518      from plugins.google_meet.node.client import NodeClient
519      from plugins.google_meet.node import protocol
520  
521      def reply(raw_out):
522          return protocol.encode(protocol.make_response("different-id", {"ok": True}))
523  
524      _install_fake_ws(monkeypatch, reply)
525  
526      c = NodeClient("ws://x", "t")
527      with pytest.raises(RuntimeError, match="mismatch"):
528          c._rpc("ping", {})
529  
530  
531  def test_client_convenience_methods_hit_correct_types(monkeypatch):
532      from plugins.google_meet.node.client import NodeClient
533      from plugins.google_meet.node import protocol
534  
535      seen = []
536  
537      def reply(raw_out):
538          req = protocol.decode(raw_out)
539          seen.append((req["type"], req["payload"]))
540          return protocol.encode(protocol.make_response(req["id"], {"ok": True}))
541  
542      _install_fake_ws(monkeypatch, reply)
543  
544      c = NodeClient("ws://x", "t")
545      c.start_bot("https://meet.google.com/a-b-c", guest_name="G", duration="10m")
546      c.stop()
547      c.status()
548      c.transcript(last=3)
549      c.say("hi")
550      c.ping()
551  
552      types = [t for t, _ in seen]
553      assert types == ["start_bot", "stop", "status", "transcript", "say", "ping"]
554      # Check specific payload routing
555      assert seen[0][1]["url"] == "https://meet.google.com/a-b-c"
556      assert seen[0][1]["guest_name"] == "G"
557      assert seen[0][1]["duration"] == "10m"
558      assert seen[3][1]["last"] == 3
559      assert seen[4][1]["text"] == "hi"
560  
561  
562  def test_client_init_rejects_bad_args():
563      from plugins.google_meet.node.client import NodeClient
564  
565      with pytest.raises(ValueError):
566          NodeClient("", "t")
567      with pytest.raises(ValueError):
568          NodeClient("ws://x", "")
569  
570  
571  # ---------------------------------------------------------------------------
572  # cli.py
573  # ---------------------------------------------------------------------------
574  
575  def _build_parser():
576      from plugins.google_meet.node.cli import register_cli
577  
578      parser = argparse.ArgumentParser(prog="meet-node-test")
579      register_cli(parser)
580      return parser
581  
582  
583  def test_cli_approve_list_remove(capsys):
584      from plugins.google_meet.node.registry import NodeRegistry
585  
586      p = _build_parser()
587  
588      args = p.parse_args(["approve", "mac", "ws://mac:1", "tok"])
589      rc = args.func(args)
590      assert rc == 0
591      assert NodeRegistry().get("mac") is not None
592  
593      args = p.parse_args(["list"])
594      rc = args.func(args)
595      assert rc == 0
596      out = capsys.readouterr().out
597      assert "mac" in out
598      assert "ws://mac:1" in out
599  
600      args = p.parse_args(["remove", "mac"])
601      rc = args.func(args)
602      assert rc == 0
603      assert NodeRegistry().get("mac") is None
604  
605  
606  def test_cli_list_empty(capsys):
607      p = _build_parser()
608      args = p.parse_args(["list"])
609      rc = args.func(args)
610      assert rc == 0
611      assert "no nodes" in capsys.readouterr().out
612  
613  
614  def test_cli_remove_missing_returns_nonzero():
615      p = _build_parser()
616      args = p.parse_args(["remove", "ghost"])
617      rc = args.func(args)
618      assert rc == 1
619  
620  
621  def test_cli_status_pings_via_node_client(capsys, monkeypatch):
622      from plugins.google_meet.node.registry import NodeRegistry
623      from plugins.google_meet.node import cli as node_cli
624  
625      NodeRegistry().add("mac", "ws://mac:1", "tok")
626  
627      class _FakeClient:
628          def __init__(self, url, token):
629              assert url == "ws://mac:1"
630              assert token == "tok"
631  
632          def ping(self):
633              return {"type": "pong", "display_name": "hermes-meet-node"}
634  
635      monkeypatch.setattr(node_cli, "NodeClient", _FakeClient)
636  
637      p = _build_parser()
638      args = p.parse_args(["status", "mac"])
639      rc = args.func(args)
640      assert rc == 0
641      out = capsys.readouterr().out.strip()
642      data = json.loads(out)
643      assert data["ok"] is True
644      assert data["node"] == "mac"
645  
646  
647  def test_cli_status_unknown_node_fails(capsys):
648      p = _build_parser()
649      args = p.parse_args(["status", "ghost"])
650      rc = args.func(args)
651      assert rc == 1
652  
653  
654  def test_cli_status_reports_client_error(capsys, monkeypatch):
655      from plugins.google_meet.node.registry import NodeRegistry
656      from plugins.google_meet.node import cli as node_cli
657  
658      NodeRegistry().add("mac", "ws://mac:1", "tok")
659  
660      class _FakeClient:
661          def __init__(self, url, token):
662              pass
663  
664          def ping(self):
665              raise RuntimeError("connection refused")
666  
667      monkeypatch.setattr(node_cli, "NodeClient", _FakeClient)
668  
669      p = _build_parser()
670      args = p.parse_args(["status", "mac"])
671      rc = args.func(args)
672      assert rc == 1
673      data = json.loads(capsys.readouterr().out.strip())
674      assert data["ok"] is False
675      assert "connection refused" in data["error"]