"""Two-Claire convergence test. Stands up two separate Claire apps (each with its own tmp DB + machine_id), makes writes on each, then runs `sync_peer` and verifies both DBs end up with identical projection state. The two servers are TestClient instances; we monkey-patch sync.py's httpx calls to use the TestClient transport rather than real network. """ from __future__ import annotations import os from pathlib import Path from unittest.mock import patch import httpx import pytest from fastapi.testclient import TestClient def _build_app(tmp_dir: Path) -> TestClient: """Build an isolated Claire with its own config + DB.""" from claire.web.app import create_app cfg_path = tmp_dir / "claire.toml" db_path = tmp_dir / "claire.db" # Pin sync_secret=None so this test stays in legacy unauth mode; the # signed-path coverage lives in test_sync_auth.py. return TestClient( create_app(config_path=cfg_path, db_path=db_path, sync_secret=None) ) def _swap_httpx_to_clients( clients_by_base: dict[str, TestClient] ): """Return a context manager that routes httpx.{get,post} calls to the TestClient whose base_url matches.""" def fake_get(url: str, **kwargs): for base, client in clients_by_base.items(): if url.startswith(base): path = url[len(base):] return client.get( path, params=kwargs.get("params"), timeout=kwargs.get("timeout"), headers=kwargs.get("headers"), ) raise RuntimeError(f"no TestClient registered for {url}") def fake_post(url: str, **kwargs): for base, client in clients_by_base.items(): if url.startswith(base): path = url[len(base):] # Forward either `json=` or `content=` (raw bytes used for HMAC). if "content" in kwargs: return client.post( path, content=kwargs.get("content"), timeout=kwargs.get("timeout"), headers=kwargs.get("headers"), ) return client.post( path, json=kwargs.get("json"), timeout=kwargs.get("timeout"), headers=kwargs.get("headers"), ) raise RuntimeError(f"no TestClient registered for {url}") def fake_request(method: str, url: str, **kwargs): if method.upper() == "GET": return fake_get(url, **kwargs) if method.upper() == "POST": return fake_post(url, **kwargs) raise RuntimeError(f"unsupported method {method}") return patch.multiple( httpx, get=fake_get, post=fake_post, request=fake_request, ) def test_two_claires_converge(tmp_path: Path) -> None: # Build two independent Claire instances. a_dir = tmp_path / "machine-a" b_dir = tmp_path / "machine-b" a_dir.mkdir() b_dir.mkdir() a = _build_app(a_dir) b = _build_app(b_dir) # Write on A. a.post("/api/v1/projects", json={"name": "alpha", "goal": "from A"}) a_task_id = a.post( "/api/v1/tasks", json={"project": "alpha", "title": "task on A"} ).json()["id"] # Write on B. b.post("/api/v1/projects", json={"name": "beta"}) b.post("/api/v1/tasks", json={"project": "beta", "title": "task on B"}) # Before sync, each knows only its own data. assert {p["name"] for p in a.get("/api/v1/projects").json()["projects"]} == {"alpha"} assert {p["name"] for p in b.get("/api/v1/projects").json()["projects"]} == {"beta"} # Run sync from A's perspective, with B as the peer. BASE_A = "http://machine-a" BASE_B = "http://machine-b" with _swap_httpx_to_clients({BASE_A: a, BASE_B: b}): from claire.sync import sync_peer result = sync_peer(BASE_B, local_base=BASE_A) assert result.pulled > 0 assert result.pushed > 0 # After sync, both A and B should see both projects. a_names = {p["name"] for p in a.get("/api/v1/projects").json()["projects"]} b_names = {p["name"] for p in b.get("/api/v1/projects").json()["projects"]} assert a_names == {"alpha", "beta"} assert b_names == {"alpha", "beta"} # And both tasks. a_titles = {t["title"] for t in a.get("/api/v1/tasks").json()["tasks"]} b_titles = {t["title"] for t in b.get("/api/v1/tasks").json()["tasks"]} assert a_titles == {"task on A", "task on B"} assert b_titles == {"task on A", "task on B"} # Re-running sync is a no-op (dedup by event uuid). with _swap_httpx_to_clients({BASE_A: a, BASE_B: b}): from claire.sync import sync_peer result2 = sync_peer(BASE_B, local_base=BASE_A) assert result2.pulled == 0 assert result2.pushed == 0