TARGET_SPACE / tests /test_sky_sync.py
RDS777's picture
Upload 134 files
8f4b341 verified
Raw History Blame Contribute Delete
7.26 kB
"""Synchronisation de la file sky-trajectories avec le Hub : envoi, archivage, échecs sans perte, secrets protégés."""
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from mouh.geometry import sync
from mouh.geometry.api import make_router
from mouh.geometry.mesh import STAGING_NAME, export_trajectory
CUBE_V = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)]
CUBE_F = [(0, 2, 1), (0, 3, 2), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4),
(3, 6, 2), (3, 7, 6), (0, 4, 7), (0, 7, 3), (1, 2, 6), (1, 6, 5)]
HEADERS = {"x-admin-key": "secret"}
SECRET = "hf_TEST_SECRET_NE_PAS_AFFICHER"
class FakeApi:
def __init__(self, fail: Exception | None = None):
self.fail = fail
self.created = []
self.uploads = []
def create_repo(self, **kwargs):
self.created.append(kwargs)
def upload_file(self, **kwargs):
if self.fail is not None:
raise self.fail
self.uploads.append(kwargs)
def queue_with_one_geometry(tmp_path):
queue = tmp_path / "sky-trajectories"
rep = export_trajectory({"vertices": CUBE_V, "faces": CUBE_F, "instruction": "Cube unitaire"}, queue)
assert rep.ok and rep.stats["written"]
return queue
def test_empty_queue_sends_nothing(tmp_path):
api = FakeApi()
result = sync.push_pending(tmp_path / "vide", api, "RDS777/sky-trajectories")
assert result["sent"] == 0 and api.uploads == [] and api.created == []
def test_successful_push_creates_a_private_dataset_and_archives_the_lines(tmp_path):
queue = queue_with_one_geometry(tmp_path)
api = FakeApi()
result = sync.push_pending(queue, api, "RDS777/sky-trajectories")
assert result["sent"] == 1 and result["pending"] == 0
assert api.created[0]["private"] is True and api.created[0]["repo_type"] == "dataset"
assert api.created[0]["exist_ok"] is True
upload = api.uploads[0]
assert upload["path_in_repo"].startswith("geometry/") and upload["path_in_repo"].endswith(".jsonl")
sent_line = json.loads(upload["path_or_fileobj"].decode("utf-8").splitlines()[0])
assert sent_line["instruction"] == "Cube unitaire" and len(sent_line["id"]) == 64
assert (queue / STAGING_NAME).read_text(encoding="utf-8") == ""
assert sync.sent_batches(queue) == 1
def test_a_failed_upload_leaves_the_queue_untouched(tmp_path):
queue = queue_with_one_geometry(tmp_path)
before = (queue / STAGING_NAME).read_text(encoding="utf-8")
with pytest.raises(RuntimeError):
sync.push_pending(queue, FakeApi(fail=RuntimeError("réseau coupé")), "RDS777/sky-trajectories")
assert (queue / STAGING_NAME).read_text(encoding="utf-8") == before
assert sync.sent_batches(queue) == 0
def test_dataset_name_comes_from_the_environment_with_a_default(monkeypatch):
monkeypatch.delenv("SKY_DATASET_REPO", raising=False)
assert sync.dataset_repo() == "RDS777/sky-trajectories"
monkeypatch.setenv("SKY_DATASET_REPO", "autre/depot")
assert sync.dataset_repo() == "autre/depot"
def make_client(tmp_path, admin="secret", sync_fn=None):
app = FastAPI()
app.include_router(make_router(tmp_path / "q", lambda key: key == admin, sync=sync_fn))
return TestClient(app)
def test_sync_route_requires_the_admin_key(tmp_path):
client = make_client(tmp_path, sync_fn=lambda: {"sent": 0})
assert client.post("/api/symbioza/sync").status_code == 401
assert client.post("/api/symbioza/sync", headers={"x-admin-key": "faux"}).status_code == 401
def test_sync_route_reports_503_when_no_token_is_configured(tmp_path):
def unavailable():
raise sync.SyncUnavailable("HF_TOKEN absent : synchronisation désactivée")
client = make_client(tmp_path, sync_fn=unavailable)
r = client.post("/api/symbioza/sync", headers=HEADERS)
assert r.status_code == 503 and "HF_TOKEN absent" in r.json()["error"]
def test_sync_route_never_echoes_a_secret_from_an_upstream_error(tmp_path):
def leaky():
raise RuntimeError(f"401 Unauthorized pour {SECRET}")
client = make_client(tmp_path, sync_fn=leaky)
r = client.post("/api/symbioza/sync", headers=HEADERS)
assert r.status_code == 502
assert SECRET not in r.text
assert r.json()["type"] == "RuntimeError"
def test_sync_route_without_a_configured_callable_is_503(tmp_path):
client = make_client(tmp_path, sync_fn=None)
assert client.post("/api/symbioza/sync", headers=HEADERS).status_code == 503
def test_status_route_counts_without_exposing_content(tmp_path):
client = make_client(tmp_path)
r = client.get("/api/symbioza/status")
assert r.status_code == 200 and r.json() == {"pending": 0, "sent_batches": 0}
def scaled_cube(k):
return [(x * k, y * k, z * k) for x, y, z in CUBE_V]
def export_named(queue, k, name):
rep = export_trajectory({"vertices": scaled_cube(k), "faces": CUBE_F, "instruction": name}, queue)
assert rep.ok and rep.stats["written"]
return rep
def test_geometries_route_is_empty_on_an_empty_queue(tmp_path):
client = make_client(tmp_path)
assert client.get("/api/symbioza/geometries").json() == {"geometries": []}
def test_geometries_route_lists_newest_first_within_the_limit(tmp_path):
queue = tmp_path / "q"
for k, name in ((1, "A"), (2, "B"), (3, "C")):
export_named(queue, k, name)
client = make_client(tmp_path)
got = client.get("/api/symbioza/geometries", params={"limit": 2}).json()["geometries"]
assert [g["instruction"] for g in got] == ["C", "B"]
assert got[0]["checks"]["self_intersections"] == 0 and len(got[0]["vertices"]) == 8
def test_geometries_keep_listing_after_a_sync_from_the_archive(tmp_path):
queue = tmp_path / "q"
export_named(queue, 1, "A")
sync.push_pending(queue, FakeApi(), "RDS777/sky-trajectories")
export_named(queue, 2, "B")
assert [g["instruction"] for g in sync.recent_entries(queue)] == ["B", "A"]
def test_recent_entries_ignores_unreadable_lines(tmp_path):
queue = tmp_path / "q"
export_named(queue, 1, "A")
with (queue / STAGING_NAME).open("a", encoding="utf-8") as fh:
fh.write("ceci n'est pas du JSON\n")
assert [g["instruction"] for g in sync.recent_entries(queue)] == ["A"]
def test_recent_entries_stops_at_the_face_budget(tmp_path):
queue = tmp_path / "q"
for k, name in ((1, "A"), (2, "B")):
export_named(queue, k, name)
# Chaque cube compte 12 faces : un budget de 12 n'en laisse passer qu'une seule.
assert [g["instruction"] for g in sync.recent_entries(queue, limit=5, face_budget=12)] == ["B"]
# Un budget inférieur au plus petit maillage ne laisse rien passer.
assert sync.recent_entries(queue, limit=5, face_budget=11) == []
def test_geometries_route_caps_the_limit(tmp_path):
queue = tmp_path / "q"
for k in range(1, 6):
export_named(queue, k, f"G{k}")
client = make_client(tmp_path)
assert len(client.get("/api/symbioza/geometries", params={"limit": 3}).json()["geometries"]) == 3
assert len(client.get("/api/symbioza/geometries", params={"limit": 0}).json()["geometries"]) == 1
assert len(client.get("/api/symbioza/geometries", params={"limit": 999}).json()["geometries"]) == 5