Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ jobs:
env:
SKIP_DB_MIGRATIONS: "1"
working-directory: backend
run: pytest -q tests/unit/
# addopts in pyproject.toml already enables --cov=app; the floor here
# locks in current coverage and is meant to be ratcheted upward. It sits
# below the lightweight-CI coverage (the ML-stack trainer tests skip
# here, so CI measures lower than a full local run) to avoid flaky reds.
run: pytest -q tests/unit/ --cov-fail-under=60

frontend:
name: Frontend (lint + build)
Expand All @@ -66,6 +70,9 @@ jobs:
- name: ESLint
run: npm run lint

- name: Unit tests (vitest)
run: npm run test:unit

- name: Build
run: npm run build

Expand Down
81 changes: 81 additions & 0 deletions agent/tests/test_discover.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Unit tests for the agent hardware/OS discovery module.

These exercise the parsing helpers and the assembled ``discover()`` payload
shape. They are hermetic: GPU vendor libraries are optional and absent in CI,
so ``detect_gpus`` falls back to the CPU vendor.
"""

from __future__ import annotations

from vf_agent import discover


def test_to_int_parses_and_defaults():
assert discover._to_int("42") == 42
assert discover._to_int(7) == 7
assert discover._to_int(None) == 0
assert discover._to_int("not-a-number") == 0


def test_to_float_strips_percent_and_defaults():
assert discover._to_float("55.5%") == 55.5
assert discover._to_float("12") == 12.0
assert discover._to_float(None) == 0.0
assert discover._to_float("bad") == 0.0


def test_os_info_has_expected_keys():
info = discover._os_info()
assert {"name", "release", "version", "arch", "python"} <= set(info)
assert isinstance(info["name"], str)


def test_detect_gpus_falls_back_to_cpu(monkeypatch):
# Force both probes to report nothing so we land on the CPU vendor.
monkeypatch.setattr(discover, "_nvidia_gpus", lambda: None)
monkeypatch.setattr(discover, "_rocm_gpus", lambda: None)
vendor, gpus = discover.detect_gpus()
assert vendor == "cpu"
assert gpus == []


def test_discover_payload_shape(monkeypatch):
monkeypatch.setattr(discover, "_nvidia_gpus", lambda: None)
monkeypatch.setattr(discover, "_rocm_gpus", lambda: None)
snap = discover.discover()

required = {
"cpu_cores",
"cpu_usage_pct",
"ram_total_mb",
"ram_used_mb",
"disk_total_gb",
"disk_used_gb",
"gpu_vendor",
"gpu_count",
"gpu_model",
"gpu_memory_mb",
"gpu_usage_pct",
"gpus",
"os",
}
assert required <= set(snap)
assert snap["gpu_vendor"] == "cpu"
assert snap["gpu_count"] == len(snap["gpus"]) == 0
assert isinstance(snap["cpu_cores"], int)


def test_discover_summarizes_gpu_list(monkeypatch):
fake_gpus = [
{"index": 0, "name": "RTX 4090", "memory_mb": 24576, "util_pct": 30.0},
{"index": 1, "name": "RTX 4090", "memory_mb": 24576, "util_pct": 50.0},
]
monkeypatch.setattr(discover, "_nvidia_gpus", lambda: fake_gpus)
monkeypatch.setattr(discover, "_rocm_gpus", lambda: None)
snap = discover.discover()
assert snap["gpu_vendor"] == "nvidia"
assert snap["gpu_count"] == 2
assert snap["gpu_model"] == "RTX 4090"
assert snap["gpu_memory_mb"] == 24576
# Usage is the mean across GPUs.
assert snap["gpu_usage_pct"] == 40.0
66 changes: 66 additions & 0 deletions agent/tests/test_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Unit tests for the agent's persistent identity store.

``identity`` reads/writes a small JSON file granted at adoption. We point the
module-level path at a tmp file and verify the save/load round-trip, the
0600 permission bits, and graceful handling of missing/corrupt files.
"""

from __future__ import annotations

import json
import stat

from vf_agent import identity
from vf_agent.identity import Identity


def _redirect(monkeypatch, tmp_path):
path = tmp_path / "identity.json"
monkeypatch.setattr(identity, "IDENTITY_PATH", path)
return path


def test_load_returns_none_when_absent(monkeypatch, tmp_path):
_redirect(monkeypatch, tmp_path)
assert identity.load() is None


def test_save_then_load_round_trips(monkeypatch, tmp_path):
path = _redirect(monkeypatch, tmp_path)
ident = Identity(cluster_id="c1", register_token="tok", api_url="https://api")
identity.save(ident)

assert path.exists()
loaded = identity.load()
assert loaded == ident
assert loaded.cluster_id == "c1"
assert loaded.register_token == "tok"


def test_save_sets_owner_only_permissions(monkeypatch, tmp_path):
path = _redirect(monkeypatch, tmp_path)
identity.save(Identity(cluster_id="c", register_token="t", api_url="u"))
mode = stat.S_IMODE(path.stat().st_mode)
assert mode == 0o600


def test_load_returns_none_on_corrupt_json(monkeypatch, tmp_path):
path = _redirect(monkeypatch, tmp_path)
path.write_text("{ not valid json")
assert identity.load() is None


def test_load_returns_none_on_missing_keys(monkeypatch, tmp_path):
path = _redirect(monkeypatch, tmp_path)
path.write_text(json.dumps({"cluster_id": "c"})) # missing register_token/api_url
assert identity.load() is None


def test_clear_removes_file(monkeypatch, tmp_path):
path = _redirect(monkeypatch, tmp_path)
identity.save(Identity(cluster_id="c", register_token="t", api_url="u"))
assert path.exists()
identity.clear()
assert not path.exists()
# Clearing again is a no-op, not an error.
identity.clear()
129 changes: 129 additions & 0 deletions backend/tests/unit/test_api_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Request-level tests for the assets router (``api/assets.py``).

The router previously had no direct tests. We seed datasets/versions/assets
through the test session and exercise the read endpoints, the neighbor cursor
logic, and the upload-confirm write path. Auth is satisfied with a fake user;
``MINIO_DISABLED`` keeps presign best-effort.
"""

from __future__ import annotations

import uuid
from datetime import datetime, timedelta, timezone

from app.db.deps import get_current_user
from app.main import app
from app.models.asset import Asset
from app.models.dataset import Dataset
from app.models.dataset_version import DatasetVersion
from app.models.user import User
from tests.conftest import TestingSessionLocal, client


def _fake_user() -> User:
return User(id="asset-tester", email="assets@example.com", name="T", password_hash="x")


app.dependency_overrides[get_current_user] = _fake_user


def _seed_assets(n: int) -> tuple[str, str, list[str]]:
db = TestingSessionLocal()
try:
ds = Dataset(id=str(uuid.uuid4()), project_id="p", name="ds")
db.add(ds)
db.commit()
ver = DatasetVersion(id=str(uuid.uuid4()), dataset_id=ds.id, version=1)
db.add(ver)
db.commit()
ids = []
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
for i in range(n):
a = Asset(
id=str(uuid.uuid4()),
dataset_id=ds.id,
version_id=ver.id,
uri=f"datasets/{ver.id}/img{i}.jpg",
mime_type="image/jpeg",
label_status="unlabelled" if i % 2 else "labeled",
# Distinct, increasing timestamps so the (created_at, id) cursor
# ordering in /neighbors is deterministic.
created_at=base + timedelta(seconds=i),
)
db.add(a)
ids.append(a.id)
db.commit()
return ds.id, ver.id, ids
finally:
db.close()


def test_get_asset_404_when_missing():
r = client.get("/api/assets/does-not-exist")
assert r.status_code == 404


def test_get_asset_returns_fields():
_, _, ids = _seed_assets(1)
r = client.get(f"/api/assets/{ids[0]}")
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == ids[0]
assert body["mime_type"] == "image/jpeg"
assert "download_url" in body


def test_list_dataset_assets_pagination_shape():
ds_id, ver_id, _ = _seed_assets(5)
r = client.get(f"/api/datasets/{ds_id}/assets?limit=2&offset=0")
assert r.status_code == 200
body = r.json()
assert body["total"] == 5
assert len(body["items"]) == 2
assert body["limit"] == 2 and body["offset"] == 0


def test_list_dataset_assets_label_status_filter():
ds_id, _, _ = _seed_assets(4) # 2 labeled, 2 unlabelled
r = client.get(f"/api/datasets/{ds_id}/assets?label_status=labeled")
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert all(item["label_status"] == "labeled" for item in body["items"])


def test_asset_neighbors_prev_next_index():
ds_id, ver_id, ids = _seed_assets(3)
# Ordering is (created_at, id), which need not match insertion order, so
# assert the invariants across all three: total is 3 everywhere, exactly one
# has no prev (the first) and exactly one has no next (the last).
bodies = []
for aid in ids:
r = client.get(f"/api/assets/{aid}/neighbors")
assert r.status_code == 200
bodies.append(r.json())

assert all(b["total"] == 3 for b in bodies)
assert sum(1 for b in bodies if b["prev"] is None) == 1
assert sum(1 for b in bodies if b["next"] is None) == 1
assert sorted(b["index"] for b in bodies) == [0, 1, 2]


def test_asset_neighbors_404_when_missing():
assert client.get("/api/assets/missing/neighbors").status_code == 404


def test_confirm_upload_creates_asset():
ds_id, ver_id, _ = _seed_assets(0)
r = client.post(
"/api/ingest/confirm",
json={
"dataset_id": ds_id,
"version_id": ver_id,
"storage_key": f"datasets/{ver_id}/new.jpg",
"filename": "new.jpg",
"content_type": "image/jpeg",
},
)
assert r.status_code == 201, r.text
assert r.json()["dataset_id"] == ds_id
Loading
Loading