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"]