140 lines
4.8 KiB
Python
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
|