Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""Orchestrator + two virtual hub clients for dual-hub sync E2E tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.modules.sync import repository as repo
|
||||
|
||||
|
||||
@dataclass
|
||||
class HubClient:
|
||||
hub_site_id: str
|
||||
farm_hub_id: str
|
||||
api_key: str
|
||||
pull_cursor: int = 0
|
||||
local_catalog: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict)
|
||||
|
||||
def auth_header(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Hub {self.hub_site_id}:{self.api_key}"}
|
||||
|
||||
|
||||
class OrchestratorDualHubHarness:
|
||||
def __init__(self, client: TestClient, enterprise_id: str) -> None:
|
||||
self.client = client
|
||||
self.enterprise_id = enterprise_id
|
||||
self.hub_a: HubClient | None = None
|
||||
self.hub_b: HubClient | None = None
|
||||
|
||||
def pair_hubs(self) -> tuple[HubClient, HubClient]:
|
||||
suffix = uuid4().hex[:8]
|
||||
code_a = self._start_pairing()
|
||||
hub_a = self._confirm_hub(code_a, f"hub-site-a-{suffix}", "Hub A")
|
||||
code_b = self._start_pairing()
|
||||
hub_b = self._confirm_hub(code_b, f"hub-site-b-{suffix}", "Hub B")
|
||||
self.hub_a = hub_a
|
||||
self.hub_b = hub_b
|
||||
return hub_a, hub_b
|
||||
|
||||
def _start_pairing(self) -> str:
|
||||
login = self.client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "admin@compton.example", "password": "Admin1234"},
|
||||
)
|
||||
token = login.json()["access_token"]
|
||||
start = self.client.post(
|
||||
"/api/v1/enterprise/pair/start",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"enterprise_id": self.enterprise_id, "farm_name": "Farm"},
|
||||
)
|
||||
return start.json()["code"]
|
||||
|
||||
def _confirm_hub(self, code: str, hub_site_id: str, hub_name: str) -> HubClient:
|
||||
confirm = self.client.post(
|
||||
"/api/v1/enterprise/pair/confirm",
|
||||
json={"code": code, "hub_site_id": hub_site_id, "hub_name": hub_name},
|
||||
)
|
||||
body = confirm.json()
|
||||
return HubClient(
|
||||
hub_site_id=body["hub_site_id"],
|
||||
farm_hub_id=body["farm_hub_id"],
|
||||
api_key=body["api_key"],
|
||||
)
|
||||
|
||||
def edit_local(self, hub: HubClient, table: str, record_id: str, payload: dict, *, version: int, content_hash: str) -> dict:
|
||||
row = copy.deepcopy(payload)
|
||||
row["id"] = record_id
|
||||
row["version"] = version
|
||||
row["content_hash"] = content_hash
|
||||
hub.local_catalog.setdefault(table, {})[record_id] = row
|
||||
return row
|
||||
|
||||
def push_from(self, hub: HubClient, events: list[dict]) -> dict:
|
||||
resp = self.client.post(
|
||||
"/api/v1/sync/changes/push",
|
||||
headers=hub.auth_header(),
|
||||
json={"events": events},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def pull_for(self, hub: HubClient) -> list[dict]:
|
||||
resp = self.client.post(
|
||||
"/api/v1/sync/changes/pull",
|
||||
headers=hub.auth_header(),
|
||||
json={"cursor": hub.pull_cursor, "limit": 100},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
events = body.get("events") or []
|
||||
ack_ids: list[str] = []
|
||||
for ev in events:
|
||||
self._apply_local(hub, ev)
|
||||
ack_ids.append(ev["event_id"])
|
||||
if ack_ids:
|
||||
self.client.post(
|
||||
"/api/v1/sync/changes/ack",
|
||||
headers=hub.auth_header(),
|
||||
json={"event_ids": ack_ids, "direction": "inbound"},
|
||||
)
|
||||
if body.get("next_cursor") is not None:
|
||||
hub.pull_cursor = int(body["next_cursor"])
|
||||
return events
|
||||
|
||||
def _apply_local(self, hub: HubClient, event: dict) -> None:
|
||||
table = event["table"]
|
||||
record_id = event["record_id"]
|
||||
hub.local_catalog.setdefault(table, {})[record_id] = {
|
||||
**(event.get("payload") or {}),
|
||||
"id": record_id,
|
||||
"version": event.get("version"),
|
||||
"content_hash": event.get("content_hash"),
|
||||
}
|
||||
|
||||
def drain(self, hubs: list[HubClient] | None = None, rounds: int = 10) -> None:
|
||||
targets = hubs or [h for h in (self.hub_a, self.hub_b) if h]
|
||||
for _ in range(rounds):
|
||||
for hub in targets:
|
||||
self.pull_for(hub)
|
||||
|
||||
def get_catalog(self, hub: HubClient, table: str, record_id: str) -> dict | None:
|
||||
return hub.local_catalog.get(table, {}).get(record_id)
|
||||
|
||||
def get_orchestrator_catalog(self, table: str, record_id: str) -> dict | None:
|
||||
from app.modules.zootech.catalog_apply import load_catalog_row
|
||||
|
||||
return load_catalog_row(self.enterprise_id, table, record_id)
|
||||
|
||||
def make_component_event(
|
||||
self,
|
||||
hub: HubClient,
|
||||
record_id: str,
|
||||
*,
|
||||
name: str,
|
||||
version: int,
|
||||
content_hash: str,
|
||||
event_id: str | None = None,
|
||||
dry_matter: float = 88.0,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"name": name,
|
||||
"type": "grain",
|
||||
"dry_matter": dry_matter,
|
||||
"protein": 8.0,
|
||||
"energy": 1.2,
|
||||
"price": 0.0,
|
||||
"is_active": True,
|
||||
}
|
||||
self.edit_local(hub, "component", record_id, payload, version=version, content_hash=content_hash)
|
||||
return {
|
||||
"event_id": event_id or str(uuid4()),
|
||||
"seq": version,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": version,
|
||||
"content_hash": content_hash,
|
||||
"payload": {**payload, "id": record_id, "version": version, "content_hash": content_hash},
|
||||
"emitted_at": datetime.now(UTC).isoformat(),
|
||||
"origin_site_id": hub.hub_site_id,
|
||||
}
|
||||
|
||||
|
||||
def make_enterprise(client: TestClient) -> str:
|
||||
slug = f"test-ent-{uuid4().hex[:8]}"
|
||||
ent = repo.create_enterprise("Test Enterprise", slug)
|
||||
from app.modules.users.repository import get_user_by_email
|
||||
|
||||
user = get_user_by_email("admin@compton.example")
|
||||
assert user
|
||||
repo.add_member(user.id, ent.id, "admin")
|
||||
return ent.id
|
||||
Reference in New Issue
Block a user