179 lines
6.1 KiB
Python
179 lines
6.1 KiB
Python
"""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
|