Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.modules.sync import repository as repo
|
||||
from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise
|
||||
|
||||
|
||||
def test_hub_conflicts_via_hub_auth(client: TestClient):
|
||||
enterprise_id = make_enterprise(client)
|
||||
harness = OrchestratorDualHubHarness(client, enterprise_id)
|
||||
hub_a, hub_b = harness.pair_hubs()
|
||||
record_id = "hub-auth-conflict-1"
|
||||
harness.push_from(
|
||||
hub_a,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "hub-h1",
|
||||
"payload": {"name": "A", "type": "g", "dry_matter": 1, "id": record_id, "version": 1, "content_hash": "hub-h1"},
|
||||
"emitted_at": "2026-07-15T12:00:00Z",
|
||||
"origin_site_id": hub_a.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
harness.push_from(
|
||||
hub_b,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "hub-h2",
|
||||
"payload": {"name": "B", "type": "g", "dry_matter": 2, "id": record_id, "version": 1, "content_hash": "hub-h2"},
|
||||
"emitted_at": "2026-07-15T12:01:00Z",
|
||||
"origin_site_id": hub_b.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
listed = client.get("/api/v1/sync/hub/conflicts", headers=hub_a.auth_header())
|
||||
assert listed.status_code == 200
|
||||
conflicts = listed.json()
|
||||
assert len(conflicts) >= 1
|
||||
|
||||
|
||||
def test_multiserver_list_and_resolve(client: TestClient):
|
||||
enterprise_id = make_enterprise(client)
|
||||
harness = OrchestratorDualHubHarness(client, enterprise_id)
|
||||
hub_a, hub_b = harness.pair_hubs()
|
||||
record_id = "ms-conflict-1"
|
||||
harness.push_from(
|
||||
hub_a,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "ms-h1",
|
||||
"payload": {"name": "A", "type": "g", "dry_matter": 1, "id": record_id, "version": 1, "content_hash": "ms-h1"},
|
||||
"emitted_at": "2026-07-15T12:00:00Z",
|
||||
"origin_site_id": hub_a.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
harness.push_from(
|
||||
hub_b,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "ms-h2",
|
||||
"payload": {"name": "B", "type": "g", "dry_matter": 2, "id": record_id, "version": 1, "content_hash": "ms-h2"},
|
||||
"emitted_at": "2026-07-15T12:01:00Z",
|
||||
"origin_site_id": hub_b.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
|
||||
token = login.json()["access_token"]
|
||||
listed = client.get(
|
||||
f"/api/v1/sync/conflicts?enterprise_id={enterprise_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
conflicts = listed.json()
|
||||
assert len(conflicts) >= 1
|
||||
cid = conflicts[0]["id"]
|
||||
detail = client.get(
|
||||
f"/api/v1/sync/conflicts/{cid}?enterprise_id={enterprise_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
resolved = client.post(
|
||||
f"/api/v1/sync/conflicts/{cid}/resolve?enterprise_id={enterprise_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"resolution": "keep_orchestrator"},
|
||||
)
|
||||
assert resolved.status_code == 200
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.modules.sync import repository as repo
|
||||
from app.modules.sync.engine import SyncEngine
|
||||
from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def chaos_harness(client: TestClient):
|
||||
enterprise_id = make_enterprise(client)
|
||||
harness = OrchestratorDualHubHarness(client, enterprise_id)
|
||||
hub_a, hub_b = harness.pair_hubs()
|
||||
return harness, hub_a, hub_b
|
||||
|
||||
|
||||
def test_duplicate_ack_safe(client: TestClient, chaos_harness):
|
||||
harness, hub_a, _hub_b = chaos_harness
|
||||
record_id = "chaos-ack-1"
|
||||
event = harness.make_component_event(
|
||||
hub_a, record_id, name="DupAck", version=1, content_hash="da1", event_id="evt-dup-ack"
|
||||
)
|
||||
harness.push_from(hub_a, [event])
|
||||
events = harness.pull_for(hub_a) # hub should not see own events
|
||||
assert events == []
|
||||
resp = client.post(
|
||||
"/api/v1/sync/changes/ack",
|
||||
headers=hub_a.auth_header(),
|
||||
json={"event_ids": ["evt-dup-ack"], "direction": "inbound"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_out_of_order_seq_replay_idempotent(client: TestClient, chaos_harness):
|
||||
harness, hub_a, hub_b = chaos_harness
|
||||
record_id = "chaos-seq-1"
|
||||
e2 = harness.make_component_event(
|
||||
hub_a, record_id, name="Second", version=2, content_hash="s2", event_id="evt-seq-2"
|
||||
)
|
||||
e1 = harness.make_component_event(
|
||||
hub_a, record_id, name="First", version=1, content_hash="s1", event_id="evt-seq-1"
|
||||
)
|
||||
harness.push_from(hub_a, [e2])
|
||||
harness.push_from(hub_a, [e1])
|
||||
harness.drain()
|
||||
row = harness.get_catalog(hub_b, "component", record_id)
|
||||
assert row is not None
|
||||
assert row["content_hash"] in {"s1", "s2"}
|
||||
|
||||
|
||||
def test_resolve_keep_orchestrator_fanout(client: TestClient, chaos_harness):
|
||||
harness, hub_a, hub_b = chaos_harness
|
||||
record_id = "chaos-resolve-1"
|
||||
harness.push_from(
|
||||
hub_a,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "orch-hash",
|
||||
"payload": {
|
||||
"name": "Orch",
|
||||
"type": "grain",
|
||||
"dry_matter": 50.0,
|
||||
"id": record_id,
|
||||
"version": 1,
|
||||
"content_hash": "orch-hash",
|
||||
},
|
||||
"emitted_at": datetime.now(UTC).isoformat(),
|
||||
"origin_site_id": hub_a.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
harness.push_from(
|
||||
hub_b,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "hub-hash",
|
||||
"payload": {
|
||||
"name": "HubB",
|
||||
"type": "grain",
|
||||
"dry_matter": 60.0,
|
||||
"id": record_id,
|
||||
"version": 1,
|
||||
"content_hash": "hub-hash",
|
||||
},
|
||||
"emitted_at": datetime.now(UTC).isoformat(),
|
||||
"origin_site_id": hub_b.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
conflicts = repo.list_conflicts(harness.enterprise_id, "pending")
|
||||
assert len(conflicts) == 1
|
||||
engine = SyncEngine(harness.enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID)
|
||||
engine.resolve_conflict(conflicts[0].id, "admin", "keep_orchestrator")
|
||||
harness.drain()
|
||||
row = harness.get_orchestrator_catalog("component", record_id)
|
||||
assert row is not None
|
||||
assert row["content_hash"] == "orch-hash"
|
||||
|
||||
|
||||
def test_resolve_keep_hub_fanout(client: TestClient, chaos_harness):
|
||||
harness, hub_a, hub_b = chaos_harness
|
||||
record_id = "chaos-resolve-hub-1"
|
||||
harness.push_from(
|
||||
hub_a,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "orch-hash2",
|
||||
"payload": {
|
||||
"name": "Orch",
|
||||
"type": "grain",
|
||||
"dry_matter": 50.0,
|
||||
"id": record_id,
|
||||
"version": 1,
|
||||
"content_hash": "orch-hash2",
|
||||
},
|
||||
"emitted_at": "2026-07-15T10:00:00Z",
|
||||
"origin_site_id": hub_a.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
harness.push_from(
|
||||
hub_b,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "hub-b-hash2",
|
||||
"payload": {
|
||||
"name": "Hub B wins",
|
||||
"type": "grain",
|
||||
"dry_matter": 77.0,
|
||||
"id": record_id,
|
||||
"version": 1,
|
||||
"content_hash": "hub-b-hash2",
|
||||
},
|
||||
"emitted_at": "2026-07-15T10:01:00Z",
|
||||
"origin_site_id": hub_b.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
conflicts = repo.list_conflicts(harness.enterprise_id, "pending")
|
||||
assert len(conflicts) == 1
|
||||
engine = SyncEngine(harness.enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID)
|
||||
engine.resolve_conflict(conflicts[0].id, "admin", "keep_hub")
|
||||
harness.drain()
|
||||
row = harness.get_orchestrator_catalog("component", record_id)
|
||||
assert row is not None
|
||||
assert row["content_hash"] == "hub-b-hash2"
|
||||
assert row["name"] == "Hub B wins"
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dual_harness(client: TestClient):
|
||||
enterprise_id = make_enterprise(client)
|
||||
harness = OrchestratorDualHubHarness(client, enterprise_id)
|
||||
hub_a, hub_b = harness.pair_hubs()
|
||||
return harness, hub_a, hub_b
|
||||
|
||||
|
||||
def test_hub_a_push_reaches_hub_b(client: TestClient, dual_harness):
|
||||
harness, hub_a, hub_b = dual_harness
|
||||
record_id = "comp-dual-001"
|
||||
event = harness.make_component_event(
|
||||
hub_a, record_id, name="Corn A", version=1, content_hash="hash-a1"
|
||||
)
|
||||
harness.push_from(hub_a, [event])
|
||||
harness.drain()
|
||||
row = harness.get_catalog(hub_b, "component", record_id)
|
||||
assert row is not None
|
||||
assert row["name"] == "Corn A"
|
||||
assert row["content_hash"] == "hash-a1"
|
||||
|
||||
|
||||
def test_hub_b_push_back_to_hub_a(client: TestClient, dual_harness):
|
||||
harness, hub_a, hub_b = dual_harness
|
||||
record_id = "comp-dual-002"
|
||||
harness.push_from(
|
||||
hub_a,
|
||||
[harness.make_component_event(hub_a, record_id, name="V1", version=1, content_hash="h1")],
|
||||
)
|
||||
harness.drain()
|
||||
harness.push_from(
|
||||
hub_b,
|
||||
[harness.make_component_event(hub_b, record_id, name="V2 from B", version=2, content_hash="h2")],
|
||||
)
|
||||
harness.drain()
|
||||
row_a = harness.get_catalog(hub_a, "component", record_id)
|
||||
assert row_a is not None
|
||||
assert row_a["content_hash"] == "h2"
|
||||
assert row_a["name"] == "V2 from B"
|
||||
|
||||
|
||||
def test_idempotent_replay(client: TestClient, dual_harness):
|
||||
harness, hub_a, hub_b = dual_harness
|
||||
record_id = "comp-dual-003"
|
||||
event = harness.make_component_event(
|
||||
hub_a, record_id, name="Once", version=1, content_hash="once1", event_id="evt-fixed-003"
|
||||
)
|
||||
harness.push_from(hub_a, [event])
|
||||
harness.push_from(hub_a, [event])
|
||||
harness.drain()
|
||||
orch = harness.get_orchestrator_catalog("component", record_id)
|
||||
assert orch is not None
|
||||
assert orch["name"] == "Once"
|
||||
row_b = harness.get_catalog(hub_b, "component", record_id)
|
||||
assert row_b is not None
|
||||
assert row_b["content_hash"] == "once1"
|
||||
|
||||
|
||||
def test_ack_cursor_no_double_apply(client: TestClient, dual_harness):
|
||||
harness, hub_a, hub_b = dual_harness
|
||||
record_id = "comp-dual-004"
|
||||
event = harness.make_component_event(
|
||||
hub_a, record_id, name="Ack safe", version=1, content_hash="ack1", event_id="evt-ack-004"
|
||||
)
|
||||
harness.push_from(hub_a, [event])
|
||||
events1 = harness.pull_for(hub_b)
|
||||
assert len(events1) == 1
|
||||
events2 = harness.pull_for(hub_b)
|
||||
assert len(events2) == 0
|
||||
row = harness.get_catalog(hub_b, "component", record_id)
|
||||
assert row is not None
|
||||
assert row["name"] == "Ack safe"
|
||||
|
||||
|
||||
def test_no_conflict_single_editor(client: TestClient, dual_harness):
|
||||
harness, hub_a, hub_b = dual_harness
|
||||
record_id = "comp-dual-005"
|
||||
harness.push_from(
|
||||
hub_a,
|
||||
[harness.make_component_event(hub_a, record_id, name="Solo", version=1, content_hash="solo1")],
|
||||
)
|
||||
harness.drain()
|
||||
from app.modules.sync import repository as repo
|
||||
|
||||
conflicts = repo.list_conflicts(harness.enterprise_id, "pending")
|
||||
assert conflicts == []
|
||||
row = harness.get_orchestrator_catalog("component", record_id)
|
||||
assert row is not None
|
||||
assert row["name"] == "Solo"
|
||||
|
||||
|
||||
def test_conflict_dual_edit(client: TestClient, dual_harness):
|
||||
harness, hub_a, hub_b = dual_harness
|
||||
record_id = "comp-dual-006"
|
||||
base = {"name": "Base", "type": "grain", "dry_matter": 50.0}
|
||||
harness.push_from(
|
||||
hub_a,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "base-hash",
|
||||
"payload": {**base, "id": record_id, "version": 1, "content_hash": "base-hash"},
|
||||
"emitted_at": "2026-07-15T10:00:00Z",
|
||||
"origin_site_id": hub_a.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
harness.drain()
|
||||
harness.push_from(
|
||||
hub_b,
|
||||
[
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "b-conflict-hash",
|
||||
"payload": {
|
||||
"name": "B edit",
|
||||
"type": "grain",
|
||||
"dry_matter": 60.0,
|
||||
"id": record_id,
|
||||
"version": 1,
|
||||
"content_hash": "b-conflict-hash",
|
||||
},
|
||||
"emitted_at": "2026-07-15T10:01:00Z",
|
||||
"origin_site_id": hub_b.hub_site_id,
|
||||
}
|
||||
],
|
||||
)
|
||||
from app.modules.sync import repository as repo
|
||||
|
||||
conflicts = repo.list_conflicts(harness.enterprise_id, "pending")
|
||||
assert len(conflicts) == 1
|
||||
orch = harness.get_orchestrator_catalog("component", record_id)
|
||||
assert orch is not None
|
||||
assert orch["content_hash"] == "base-hash"
|
||||
|
||||
|
||||
def test_period_recipes_composite_record_id(client: TestClient, dual_harness):
|
||||
harness, hub_a, hub_b = dual_harness
|
||||
period_id = "737a78a5-492a-4a84-94f3-18b4d7f21666"
|
||||
recipe_id = "07fd17cf-22c3-44fe-92db-46363b24cee0"
|
||||
record_id = f"{period_id}:{recipe_id}"
|
||||
event = {
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "period_recipes",
|
||||
"record_id": record_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "pr1",
|
||||
"payload": {
|
||||
"period_id": period_id,
|
||||
"recipe_id": recipe_id,
|
||||
"order": 1,
|
||||
"version": 1,
|
||||
"content_hash": "pr1",
|
||||
},
|
||||
"emitted_at": "2026-07-15T12:00:00Z",
|
||||
"origin_site_id": hub_a.hub_site_id,
|
||||
}
|
||||
harness.push_from(hub_a, [event])
|
||||
harness.drain()
|
||||
row = harness.get_catalog(hub_b, "period_recipes", record_id)
|
||||
assert row is not None
|
||||
assert row.get("order") == 1
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise
|
||||
|
||||
|
||||
def test_recipe_ingredient_tree_roundtrip(client: TestClient):
|
||||
enterprise_id = make_enterprise(client)
|
||||
harness = OrchestratorDualHubHarness(client, enterprise_id)
|
||||
hub_a, hub_b = harness.pair_hubs()
|
||||
recipe_id = "recipe-tree-1"
|
||||
ing_id = "ing-tree-1"
|
||||
comp_id = "comp-tree-1"
|
||||
harness.push_from(
|
||||
hub_a,
|
||||
[
|
||||
harness.make_component_event(hub_a, comp_id, name="Barley", version=1, content_hash="c1"),
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 2,
|
||||
"domain": "global",
|
||||
"table": "recipe",
|
||||
"record_id": recipe_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "r1",
|
||||
"payload": {
|
||||
"id": recipe_id,
|
||||
"name": "Mix A",
|
||||
"heads_per_trip": 100,
|
||||
"ingredients": [{"id": ing_id, "component_id": comp_id, "name": "Barley", "amount": 10}],
|
||||
"version": 1,
|
||||
"content_hash": "r1",
|
||||
},
|
||||
"emitted_at": "2026-07-15T12:00:00Z",
|
||||
"origin_site_id": hub_a.hub_site_id,
|
||||
},
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"seq": 3,
|
||||
"domain": "global",
|
||||
"table": "ingredient",
|
||||
"record_id": ing_id,
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "i1",
|
||||
"payload": {
|
||||
"id": ing_id,
|
||||
"recipe_id": recipe_id,
|
||||
"component_id": comp_id,
|
||||
"name": "Barley",
|
||||
"amount": 10.0,
|
||||
"version": 1,
|
||||
"content_hash": "i1",
|
||||
},
|
||||
"emitted_at": "2026-07-15T12:00:01Z",
|
||||
"origin_site_id": hub_a.hub_site_id,
|
||||
},
|
||||
],
|
||||
)
|
||||
harness.drain([hub_b])
|
||||
recipe = harness.get_catalog(hub_b, "recipe", recipe_id)
|
||||
assert recipe is not None
|
||||
assert recipe.get("name") == "Mix A"
|
||||
ing = harness.get_catalog(hub_b, "ingredient", ing_id)
|
||||
assert ing is not None
|
||||
assert ing.get("component_id") == comp_id
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.sync import repository as repo
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"postgresql" not in os.environ.get("DATABASE_URL", ""),
|
||||
reason="RLS tests require PostgreSQL",
|
||||
)
|
||||
def test_rls_cross_enterprise():
|
||||
ent_a = repo.create_enterprise("Farm A", f"farm-a-{os.getpid()}")
|
||||
ent_b = repo.create_enterprise("Farm B", f"farm-b-{os.getpid()}")
|
||||
assert ent_a.id != ent_b.id
|
||||
hub_a = repo.create_farm_hub(ent_a.id, "Hub A", f"hub-a-{os.getpid()}", None)
|
||||
hubs_b = repo.list_farm_hubs(ent_b.id)
|
||||
assert all(h.id != hub_a.id for h in hubs_b)
|
||||
|
||||
|
||||
def test_viewer_farm_scope(client):
|
||||
from uuid import uuid4
|
||||
|
||||
from app.modules.users.repository import get_user_by_email
|
||||
|
||||
slug = f"abac-{uuid4().hex[:8]}"
|
||||
ent = repo.create_enterprise("ABAC Farm", slug)
|
||||
admin = get_user_by_email("admin@compton.example")
|
||||
assert admin
|
||||
repo.add_member(admin.id, ent.id, "admin")
|
||||
hub1 = repo.create_farm_hub(ent.id, "H1", f"h1-{uuid4().hex[:8]}", None)
|
||||
hub2 = repo.create_farm_hub(ent.id, "H2", f"h2-{uuid4().hex[:8]}", None)
|
||||
viewer_id = admin.id
|
||||
repo.grant_farm_access(viewer_id, hub1.id)
|
||||
allowed = repo.list_farm_access(viewer_id, ent.id)
|
||||
assert hub1.id in allowed
|
||||
assert hub2.id not in allowed
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.crypto import hash_opaque_token
|
||||
from app.modules.sync import repository as repo
|
||||
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def enterprise(client: TestClient):
|
||||
slug = f"test-farm-{uuid4().hex[:8]}"
|
||||
ent = repo.create_enterprise("Test Farm Co", slug)
|
||||
admin = repo.get_member.__module__ # noqa: ensure import path
|
||||
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
|
||||
|
||||
|
||||
def test_sync_capabilities(client: TestClient):
|
||||
resp = client.get("/api/v1/sync/capabilities")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["protocol_version"] == "1.0"
|
||||
assert "global" in data["domains"]
|
||||
|
||||
|
||||
def test_pairing_flow(client: TestClient, enterprise):
|
||||
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
|
||||
token = login.json()["access_token"]
|
||||
start = client.post(
|
||||
"/api/v1/enterprise/pair/start",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"enterprise_id": enterprise.id, "farm_name": "Farm A"},
|
||||
)
|
||||
assert start.status_code == 200
|
||||
code = start.json()["code"]
|
||||
confirm = client.post(
|
||||
"/api/v1/enterprise/pair/confirm",
|
||||
json={"code": code, "hub_site_id": "hub-site-001", "hub_name": "Farm A Hub"},
|
||||
)
|
||||
assert confirm.status_code == 200
|
||||
body = confirm.json()
|
||||
assert body["hub_site_id"] == "hub-site-001"
|
||||
assert body["api_key"]
|
||||
|
||||
|
||||
def test_hub_push_idempotent(client: TestClient, enterprise):
|
||||
login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"})
|
||||
token = login.json()["access_token"]
|
||||
start = client.post(
|
||||
"/api/v1/enterprise/pair/start",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"enterprise_id": enterprise.id, "farm_name": "Farm B"},
|
||||
)
|
||||
code = start.json()["code"]
|
||||
confirm = client.post(
|
||||
"/api/v1/enterprise/pair/confirm",
|
||||
json={"code": code, "hub_site_id": "hub-site-002", "hub_name": "Farm B Hub"},
|
||||
)
|
||||
api_key = confirm.json()["api_key"]
|
||||
hub_auth = f"Hub hub-site-002:{api_key}"
|
||||
from datetime import UTC, datetime
|
||||
|
||||
event = {
|
||||
"event_id": "evt-001",
|
||||
"seq": 1,
|
||||
"domain": "global",
|
||||
"table": "component",
|
||||
"record_id": "comp-001",
|
||||
"action": "upsert",
|
||||
"version": 1,
|
||||
"content_hash": "hash1",
|
||||
"payload": {"name": "Corn", "type": "grain", "dry_matter": 88.0},
|
||||
"emitted_at": datetime.now(UTC).isoformat(),
|
||||
"origin_site_id": "hub-site-002",
|
||||
}
|
||||
push1 = client.post(
|
||||
"/api/v1/sync/changes/push",
|
||||
headers={"Authorization": hub_auth},
|
||||
json={"events": [event]},
|
||||
)
|
||||
assert push1.status_code == 200
|
||||
assert "evt-001" in push1.json()["applied_event_ids"]
|
||||
push2 = client.post(
|
||||
"/api/v1/sync/changes/push",
|
||||
headers={"Authorization": hub_auth},
|
||||
json={"events": [event]},
|
||||
)
|
||||
assert push2.status_code == 200
|
||||
assert "evt-001" in push2.json()["applied_event_ids"]
|
||||
Reference in New Issue
Block a user