claire/tests/test_sync_convergence.py
autocommit 6d212b7dbe refactor(testing-test): ♻️ Update test imports to use claire instead of clare in package references
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-20 19:54:05 -07:00

140 lines
4.8 KiB
Python

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