Files
hermes-agent/tests/tui_gateway/test_groups_replication_methods.py
teknium1 0a62d4564e test: give bare profile-dir fixtures an identity marker (#113377 red-fix)
`named_profile_has_identity` now gates listing/serving/resolution, so a
`profiles/<name>` dir created with a bare mkdir is no longer a profile.
Production always publishes identity files (`create_profile` seeds
config.yaml/.env before the rename), so these fixtures were relying on a
contract that never existed in real installs. Each now writes a minimal
`config.yaml` marker next to the mkdir (config.yaml rather than SOUL.md
or auth.json so tests that own those files stay neutral). Where a file
had a shared fixture/helper it is fixed once.

Files:
  tests/gateway/test_multiplex_lifecycle.py
  tests/gateway/test_multiplex_session_db_profile_scope.py
  tests/gateway/test_status_command.py
  tests/hermes_cli/test_apply_profile_override.py
  tests/hermes_cli/test_dashboard_admin_endpoints.py
  tests/hermes_cli/test_gateway_multiplex_served_record.py
  tests/hermes_cli/test_kanban_per_profile_cap.py
  tests/hermes_cli/test_kanban_review_surfaces.py
  tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py
  tests/hermes_cli/test_profiles.py
  tests/hermes_cli/test_web_oauth_dispatch.py
  tests/hermes_cli/test_web_profile_soul_writes.py
  tests/hermes_cli/test_web_profiles_off_loop.py
  tests/hermes_cli/test_webhook_cli.py
  tests/tools/test_kanban_tools.py
  tests/tools/test_local_env_blocklist.py
  tests/tui_gateway/test_groups_methods.py
  tests/tui_gateway/test_groups_replication_methods.py
  tests/tui_gateway/test_hosted_room_settled_retry.py

Contract rewrite (old contract asserted a bare dir IS a profile):
  tests/gateway/test_multiplex_session_db_profile_scope.py::
  test_profile_home_is_not_memoized_before_the_profile_exists — after the
  runtime mkdir the test now asserts `_profile_home_for_key` still returns
  None (a bare dir is not a profile) and only resolves once the identity
  marker exists; the not-memoized-miss invariant is kept.
2026-09-16 16:48:42 -07:00

191 lines
5.7 KiB
Python

"""Tests for the ``groups.replicate`` / ``groups.promote`` / ``groups.demote``
JSON-RPC surface — cross-gateway room durability."""
from __future__ import annotations
import pytest
import tui_gateway.server as srv
from tui_gateway import methods_groups
MEMBERS = [{"kind": "bot", "id": "planner"}]
@pytest.fixture
def home(tmp_path, monkeypatch):
path = tmp_path / ".hermes"
path.mkdir()
(path / "profiles" / "ops").mkdir(parents=True)
(path / "profiles" / "ops" / "config.yaml").write_text("{}\n") # identity marker: local roster
monkeypatch.setenv("HERMES_HOME", str(path))
methods_groups.stop_hosted_room_service(timeout=1.0)
methods_groups.start_hosted_room_service()
yield path
methods_groups.stop_hosted_room_service(timeout=1.0)
def _result(envelope):
assert "error" not in envelope, envelope
return envelope["result"]
def _error(envelope):
assert "error" in envelope, envelope
return envelope["error"]
def _authority_page(tmp_path, gateway_id="install:" + "a" * 32, n=3):
"""Build a real room + log on a SEPARATE 'remote authority' DB and return
its replay page, as a replicating client would fetch via groups.log."""
from gateway import hosted_rooms as rooms
db = tmp_path / "remote-authority.db"
rooms.create_room(
db,
room_id="room-1",
name="Field Room",
members=MEMBERS,
authority_gateway_id=gateway_id,
)
for index in range(n):
rooms.append_event(
db,
room_id="room-1",
event_id=f"e{index}",
kind="message.user",
actor={"kind": "user", "id": "tek"},
payload={"text": f"msg {index}"},
authority_gateway_id=gateway_id,
authority_epoch=1,
)
return rooms.read_events(db, room_id="room-1", since_seq=0, limit=100)
def test_capabilities_advertise_replication(home):
result = _result(srv._methods["groups.capabilities"](1, {}))
assert "log_replication" in result["features"]
assert "authority_takeover" in result["features"]
for name in (
"groups.replicate",
"groups.replica_state",
"groups.promote",
"groups.demote",
):
assert name in result["methods"]
assert name in srv._LONG_HANDLERS
def test_replicate_then_state_roundtrip(home, tmp_path):
page = _authority_page(tmp_path)
result = _result(
srv._methods["groups.replicate"](
1,
{
"room_id": "room-1",
"room_name": "Field Room",
"members": MEMBERS,
"page": page,
},
)
)
assert result["ingested"] == 3
state = _result(srv._methods["groups.replica_state"](2, {"room_id": "room-1"}))
assert state["last_seq"] == 3
assert state["authority"] == page["authority"]
def test_promote_requires_confirm_and_takes_over(home, tmp_path):
page = _authority_page(tmp_path)
_result(
srv._methods["groups.replicate"](
1,
{
"room_id": "room-1",
"room_name": "Field Room",
"members": MEMBERS,
"page": page,
},
)
)
refused = _error(srv._methods["groups.promote"](2, {"room_id": "room-1"}))
assert refused["code"] == 4118
promoted = _result(
srv._methods["groups.promote"](3, {"room_id": "room-1", "confirm": True})
)
assert promoted["authority_epoch"] == 2
assert promoted["previous_gateway_id"] == page["authority"]["gateway_id"]
# The room is now hosted locally with full history + claim event.
log = _result(srv._methods["groups.log"](4, {"room_id": "room-1"}))
kinds = [event["kind"] for event in log["events"]]
assert kinds == ["message.user"] * 3 + ["authority.claimed"]
assert log["authority"]["epoch"] == 2
def test_demote_fences_local_room_against_newer_epoch(home):
from gateway.hosted_rooms import local_authority_gateway_id
_result(
srv._methods["groups.create"](
1,
{
"room_id": "room-1",
"name": "Local room",
"members": [
{
"member_id": "default",
"profile": "default",
"handle": "hermes",
},
{"member_id": "ops", "profile": "ops", "handle": "ops"},
],
},
)
)
observed_gateway = "install:" + "b" * 32
result = _result(
srv._methods["groups.demote"](
2,
{
"room_id": "room-1",
"observed_gateway_id": observed_gateway,
"observed_epoch": 2,
},
)
)
assert result["idempotent"] is False
assert result["authority_gateway_id"] == observed_gateway
# Local sends at the stale authority now fail.
envelope = srv._methods["groups.send"](
3,
{
"room_id": "room-1",
"event_id": "stale-send",
"actor": {"kind": "user", "id": "tek"},
"payload": {"text": "should fence"},
},
)
assert "error" in envelope
assert local_authority_gateway_id() != observed_gateway
def test_replicate_rejects_gapped_page(home, tmp_path):
from gateway import hosted_rooms as rooms
_authority_page(tmp_path, n=5)
db = tmp_path / "remote-authority.db"
gapped = rooms.read_events(db, room_id="room-1", since_seq=2, limit=100)
envelope = srv._methods["groups.replicate"](
1,
{
"room_id": "room-1",
"room_name": "Field Room",
"members": MEMBERS,
"page": gapped,
},
)
assert _error(envelope)["code"] == 4116