From a75b26a1cb3d81086976eb3a36276419a3b9f1be Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Tue, 28 Apr 2026 01:05:59 +0300 Subject: [PATCH] test(05-01): add restart reconciliation regression coverage - add startup reconciliation tests for recovery, idempotence, and startup ordering - extend restart persistence coverage for legacy platform_chat_id backfill --- tests/adapter/matrix/test_reconciliation.py | 203 ++++++++++++++++++ .../matrix/test_restart_persistence.py | 60 +++++- 2 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 tests/adapter/matrix/test_reconciliation.py diff --git a/tests/adapter/matrix/test_reconciliation.py b/tests/adapter/matrix/test_reconciliation.py new file mode 100644 index 0000000..3732bbc --- /dev/null +++ b/tests/adapter/matrix/test_reconciliation.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import importlib +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from adapter.matrix.bot import MatrixBot, build_runtime +from adapter.matrix.reconciliation import reconcile_startup_state +from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta +from sdk.mock import MockPlatformClient + + +def _room( + room_id: str, + name: str, + members: list[str], + *, + parents: tuple[str, ...] = (), +): + return SimpleNamespace( + room_id=room_id, + name=name, + display_name=name, + users={user_id: SimpleNamespace(user_id=user_id) for user_id in members}, + space_parents=set(parents), + ) + + +async def test_reconcile_startup_state_restores_space_room_and_chat_bindings(): + runtime = build_runtime(platform=MockPlatformClient()) + client = SimpleNamespace( + user_id="@bot:example.org", + rooms={ + "!space:example.org": _room( + "!space:example.org", + "Lambda - Alice", + ["@bot:example.org", "@alice:example.org"], + ), + "!chat3:example.org": _room( + "!chat3:example.org", + "Чат 3", + ["@bot:example.org", "@alice:example.org"], + parents=("!space:example.org",), + ), + }, + ) + + await reconcile_startup_state(client, runtime) + + user_meta = await get_user_meta(runtime.store, "@alice:example.org") + assert user_meta is not None + assert user_meta["space_id"] == "!space:example.org" + assert user_meta["next_chat_index"] == 4 + + room_meta = await get_room_meta(runtime.store, "!chat3:example.org") + assert room_meta is not None + assert room_meta["room_type"] == "chat" + assert room_meta["chat_id"] == "C3" + assert room_meta["space_id"] == "!space:example.org" + assert room_meta["matrix_user_id"] == "@alice:example.org" + assert room_meta["platform_chat_id"] == "1" + + chats = await runtime.chat_mgr.list_active("@alice:example.org") + assert [chat.chat_id for chat in chats] == ["C3"] + assert [chat.surface_ref for chat in chats] == ["!chat3:example.org"] + + +async def test_reconcile_startup_state_is_idempotent_with_existing_local_state(): + runtime = build_runtime(platform=MockPlatformClient()) + client = SimpleNamespace( + user_id="@bot:example.org", + rooms={ + "!space:example.org": _room( + "!space:example.org", + "Lambda - Alice", + ["@bot:example.org", "@alice:example.org"], + ), + "!chat3:example.org": _room( + "!chat3:example.org", + "Чат 3", + ["@bot:example.org", "@alice:example.org"], + parents=("!space:example.org",), + ), + }, + ) + await set_user_meta( + runtime.store, + "@alice:example.org", + {"space_id": "!space:example.org", "next_chat_index": 8}, + ) + await set_room_meta( + runtime.store, + "!chat3:example.org", + { + "room_type": "chat", + "chat_id": "C3", + "display_name": "Existing name", + "matrix_user_id": "@alice:example.org", + "space_id": "!space:example.org", + "platform_chat_id": "42", + }, + ) + await runtime.chat_mgr.get_or_create( + user_id="@alice:example.org", + chat_id="C3", + platform="matrix", + surface_ref="!chat3:example.org", + name="Existing name", + ) + + await reconcile_startup_state(client, runtime) + await reconcile_startup_state(client, runtime) + + user_meta = await get_user_meta(runtime.store, "@alice:example.org") + assert user_meta == {"space_id": "!space:example.org", "next_chat_index": 8} + + room_meta = await get_room_meta(runtime.store, "!chat3:example.org") + assert room_meta is not None + assert room_meta["display_name"] == "Existing name" + assert room_meta["platform_chat_id"] == "42" + + chats = await runtime.chat_mgr.list_active("@alice:example.org") + assert len(chats) == 1 + assert chats[0].chat_id == "C3" + + +async def test_reconciliation_prevents_lazy_bootstrap_for_existing_room(): + runtime = build_runtime(platform=MockPlatformClient()) + client = SimpleNamespace( + user_id="@bot:example.org", + rooms={ + "!space:example.org": _room( + "!space:example.org", + "Lambda - Alice", + ["@bot:example.org", "@alice:example.org"], + ), + "!chat3:example.org": _room( + "!chat3:example.org", + "Чат 3", + ["@bot:example.org", "@alice:example.org"], + parents=("!space:example.org",), + ), + }, + room_send=AsyncMock(), + ) + bot = MatrixBot(client=client, runtime=runtime) + bot._bootstrap_unregistered_room = AsyncMock() + runtime.dispatcher.dispatch = AsyncMock(return_value=[]) + + await reconcile_startup_state(client, runtime) + await bot.on_room_message( + SimpleNamespace(room_id="!chat3:example.org"), + SimpleNamespace(sender="@alice:example.org", body="hello"), + ) + + bot._bootstrap_unregistered_room.assert_not_awaited() + runtime.dispatcher.dispatch.assert_awaited_once() + + +async def test_matrix_main_runs_reconciliation_before_sync_forever(monkeypatch): + bot_module = importlib.import_module("adapter.matrix.bot") + + runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock())) + call_order: list[str] = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + self.access_token = None + self.callbacks = [] + self.close = AsyncMock() + self.sync_forever = AsyncMock(side_effect=self._sync_forever) + + async def _sync_forever(self, *args, **kwargs): + call_order.append("sync_forever") + + async def login(self, *args, **kwargs): + raise AssertionError("login should not be called when access token is provided") + + def add_event_callback(self, callback, event_type): + self.callbacks.append((callback, event_type)) + + async def fake_prepare_live_sync(client): + call_order.append("prepare_live_sync") + return "s123" + + async def fake_reconcile_startup_state(client, runtime): + call_order.append("reconcile_startup_state") + + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org") + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token") + monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient) + monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime) + monkeypatch.setattr(bot_module, "prepare_live_sync", fake_prepare_live_sync) + monkeypatch.setattr(bot_module, "reconcile_startup_state", fake_reconcile_startup_state) + + await bot_module.main() + + assert call_order == [ + "prepare_live_sync", + "reconcile_startup_state", + "sync_forever", + ] diff --git a/tests/adapter/matrix/test_restart_persistence.py b/tests/adapter/matrix/test_restart_persistence.py index 492a94a..e2a1f96 100644 --- a/tests/adapter/matrix/test_restart_persistence.py +++ b/tests/adapter/matrix/test_restart_persistence.py @@ -1,16 +1,18 @@ from __future__ import annotations -import pytest +from types import SimpleNamespace -from core.store import SQLiteStore +from adapter.matrix.bot import build_runtime +from adapter.matrix.reconciliation import reconcile_startup_state from adapter.matrix.store import ( - PLATFORM_CHAT_SEQ_KEY, get_room_meta, get_selected_agent_id, next_platform_chat_id, set_room_meta, set_selected_agent_id, ) +from core.store import SQLiteStore +from sdk.mock import MockPlatformClient async def test_selected_agent_id_survives_restart(tmp_path): @@ -73,3 +75,55 @@ async def test_missing_durable_store_starts_clean(tmp_path): store = SQLiteStore(db) assert await get_selected_agent_id(store, "@nobody:example.org") is None assert await get_room_meta(store, "!nonexistent:example.org") is None + + +async def test_startup_reconciliation_backfills_legacy_platform_chat_id_before_restart_routes( + tmp_path, +): + db = str(tmp_path / "state.db") + store = SQLiteStore(db) + await set_room_meta( + store, + "!chat2:example.org", + { + "room_type": "chat", + "chat_id": "C2", + "display_name": "Чат 2", + "matrix_user_id": "@alice:example.org", + "space_id": "!space:example.org", + }, + ) + + runtime = build_runtime(platform=MockPlatformClient(), store=store) + client = SimpleNamespace( + user_id="@bot:example.org", + rooms={ + "!space:example.org": SimpleNamespace( + room_id="!space:example.org", + name="Lambda - Alice", + display_name="Lambda - Alice", + users={ + "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"), + "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"), + }, + space_parents=set(), + ), + "!chat2:example.org": SimpleNamespace( + room_id="!chat2:example.org", + name="Чат 2", + display_name="Чат 2", + users={ + "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"), + "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"), + }, + space_parents={"!space:example.org"}, + ), + }, + ) + + await reconcile_startup_state(client, runtime) + + store2 = SQLiteStore(db) + room_meta = await get_room_meta(store2, "!chat2:example.org") + assert room_meta is not None + assert room_meta["platform_chat_id"] == "1"