diff --git a/.github/workflows/build-bonsaiviewer-autodesk.yml b/.github/workflows/build-bonsaiviewer-autodesk.yml
index d137e89da5..1e51eb724a 100644
--- a/.github/workflows/build-bonsaiviewer-autodesk.yml
+++ b/.github/workflows/build-bonsaiviewer-autodesk.yml
@@ -12,8 +12,41 @@ on:
- '.github/workflows/build-bonsaiviewer-autodesk.yml'
jobs:
+ test:
+ name: test-py${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ # Exercise the floor and a current version of the supported range
+ # (pyproject requires-python = ">=3.11").
+ python-version: ['3.11', '3.13']
+
+ defaults:
+ run:
+ working-directory: src/bonsaiviewer-autodesk
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ # The connector imports tkinter/customtkinter (via the test suite's
+ # connector coverage), so fail loudly here if Tk is missing.
+ - name: Verify tkinter is available
+ run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
+
+ - name: Install package and test deps
+ run: python -m pip install ".[test]"
+
+ - name: Run pytest
+ run: python -m pytest -q
+
build:
name: ${{ matrix.os_label }}-${{ matrix.arch }}
+ needs: test
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py
index f4bd421fcf..38a609b134 100644
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py
+++ b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py
@@ -22,6 +22,13 @@ from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
Progress = Callable[[str, str, "int | None"], None]
+# (host, port, path, expected_state) -> authorization code
+CallbackWaiter = Callable[[str, int, str, str], str]
+
+
+def _utcnow() -> dt.datetime:
+ return dt.datetime.now(dt.timezone.utc)
+
def _no_keyring_error() -> RpcError:
return RpcError(
@@ -93,6 +100,48 @@ def _noop_progress(_phase: str, _message: str, _percent: int | None = None) -> N
return
+def wait_for_oauth_callback(host: str, port: int, path: str, expected_state: str) -> str:
+ """Block on a single OAuth redirect to ``http://host:port/path`` and return
+ the authorization code. Raises ``RpcError`` on an OAuth error, a ``state``
+ mismatch, or a missing code. This is the default ``callback_waiter`` for
+ :class:`AuthSessionService`; tests inject a stub instead."""
+ result: dict[str, str] = {}
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None:
+ parsed = urllib.parse.urlparse(self.path)
+ if parsed.path != path:
+ self.send_response(404)
+ self.end_headers()
+ return
+ query = urllib.parse.parse_qs(parsed.query)
+ result["state"] = query.get("state", [""])[0]
+ result["code"] = query.get("code", [""])[0]
+ result["error"] = query.get("error", [""])[0]
+ body = b"
Authentication complete. You can close this window.
"
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, format: str, *args: object) -> None:
+ return
+
+ server = HTTPServer((host, port), Handler)
+ server.handle_request()
+ server.server_close()
+
+ if result.get("error"):
+ raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
+ if result.get("state") != expected_state:
+ raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
+ code = result.get("code", "")
+ if not code:
+ raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
+ return code
+
+
class AuthSessionService:
authorize_endpoint = "https://developer.api.autodesk.com/authentication/v2/authorize"
token_endpoint = "https://developer.api.autodesk.com/authentication/v2/token"
@@ -104,12 +153,17 @@ class AuthSessionService:
callback_url: str,
scope: str,
token_store: KeyringTokenStore,
+ transport: httpx.BaseTransport | None = None,
+ now: Callable[[], dt.datetime] | None = None,
+ callback_waiter: CallbackWaiter | None = None,
) -> None:
self.client_id = client_id
self.callback_url = callback_url
self.scope = scope
self.token_store = token_store
- self.http = httpx.Client(timeout=60)
+ self.http = httpx.Client(timeout=60, transport=transport)
+ self._now = now or _utcnow
+ self._callback_waiter = callback_waiter or wait_for_oauth_callback
def get_token(self) -> StoredToken | None:
raw = self.token_store.load()
@@ -117,7 +171,7 @@ class AuthSessionService:
def ensure_access_token(self, progress: Progress = _noop_progress) -> str:
token = self.get_token()
- now = dt.datetime.now(dt.timezone.utc)
+ now = self._now()
if token and token.access_token_expires_at > now + dt.timedelta(minutes=1):
return token.access_token
if token and token.refresh_token_expires_at > now + dt.timedelta(minutes=1):
@@ -148,7 +202,7 @@ class AuthSessionService:
progress("auth", "Opening browser for Autodesk sign-in", None)
webbrowser.open(authorize_url)
- code = self._wait_for_callback(
+ code = self._callback_waiter(
callback.hostname or "127.0.0.1",
callback.port or 80,
callback.path or "/",
@@ -192,7 +246,7 @@ class AuthSessionService:
return refreshed
def _token_from_payload(self, payload: dict[str, Any]) -> StoredToken:
- now = dt.datetime.now(dt.timezone.utc)
+ now = self._now()
refresh_ttl = int(payload.get("refresh_token_expires_in", 15 * 24 * 60 * 60))
return StoredToken(
client_id=self.client_id,
@@ -203,48 +257,10 @@ class AuthSessionService:
scope=self.scope,
)
- def _wait_for_callback(self, host: str, port: int, path: str, expected_state: str) -> str:
- result: dict[str, str] = {}
-
- class Handler(BaseHTTPRequestHandler):
- def do_GET(self) -> None:
- parsed = urllib.parse.urlparse(self.path)
- if parsed.path != path:
- self.send_response(404)
- self.end_headers()
- return
- query = urllib.parse.parse_qs(parsed.query)
- result["state"] = query.get("state", [""])[0]
- result["code"] = query.get("code", [""])[0]
- result["error"] = query.get("error", [""])[0]
- body = b"Authentication complete. You can close this window.
"
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def log_message(self, format: str, *args: object) -> None:
- return
-
- server = HTTPServer((host, port), Handler)
- server.handle_request()
- server.server_close()
-
- if result.get("error"):
- raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
- if result.get("state") != expected_state:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
- code = result.get("code", "")
- if not code:
- raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
- return code
-
-
class ApsClient:
- def __init__(self, auth: AuthSessionService) -> None:
+ def __init__(self, auth: AuthSessionService, *, transport: httpx.BaseTransport | None = None) -> None:
self.auth = auth
- self.http = httpx.Client(timeout=120)
+ self.http = httpx.Client(timeout=120, transport=transport)
# Browsing -----------------------------------------------------------------
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/settings.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/settings.py
index 2e3b8b43f6..276fa02aa8 100644
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/settings.py
+++ b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/settings.py
@@ -43,19 +43,9 @@ def _write(data: dict[str, Any]) -> None:
_settings_path().write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
-def stored_client_id() -> str:
- """Whatever is persisted in settings.json — ignores the env var."""
- return str(_read().get("client_id", "")).strip()
-
-
-def env_client_id() -> str:
- """Whatever APS_CLIENT_ID currently has — ignores settings.json."""
- return os.environ.get("APS_CLIENT_ID", "").strip()
-
-
def load_client_id() -> str:
- """The effective value: env var wins, so dev overrides keep working."""
- return env_client_id() or stored_client_id()
+ """The client id persisted in settings.json, or "" if none is set."""
+ return str(_read().get("client_id", "")).strip()
def save_client_id(client_id: str) -> None:
diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py
index c3d3382a33..7a58e616c8 100644
--- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py
+++ b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py
@@ -613,9 +613,7 @@ class SettingsDialog(_BaseDialog):
super().__init__("Autodesk Connector Settings", size=(520, 400), resizable=False)
self.connector = connector
- env_override = settings.env_client_id()
- stored = settings.stored_client_id()
- effective = env_override or stored
+ client_id = settings.load_client_id()
callback_port = settings.stored_callback_port()
body = ctk.CTkFrame(self, fg_color="transparent")
@@ -638,17 +636,7 @@ class SettingsDialog(_BaseDialog):
ctk.CTkLabel(body, text="APS client id", anchor="w").pack(fill="x")
self.client_id_entry = ctk.CTkEntry(body, placeholder_text="Paste your APS client id")
self.client_id_entry.pack(fill="x", pady=(6, 8))
- self.client_id_entry.insert(0, stored)
-
- if env_override:
- ctk.CTkLabel(
- body,
- text=f"APS_CLIENT_ID environment variable is set ({env_override}) and overrides the saved value.",
- anchor="w",
- wraplength=460,
- justify="left",
- text_color=("#b45309", "#f59e0b"),
- ).pack(fill="x", pady=(0, 8))
+ self.client_id_entry.insert(0, client_id)
ctk.CTkLabel(body, text="OAuth callback port", anchor="w").pack(fill="x")
self.callback_port_entry = ctk.CTkEntry(body, placeholder_text=str(settings.DEFAULT_CALLBACK_PORT))
@@ -657,7 +645,7 @@ class SettingsDialog(_BaseDialog):
self.status_label = ctk.CTkLabel(
body,
- text=f"Signed in as {effective}" if effective else "No client id configured.",
+ text=f"Signed in as {client_id}" if client_id else "No client id configured.",
anchor="w",
wraplength=460,
justify="left",
@@ -676,7 +664,7 @@ class SettingsDialog(_BaseDialog):
border_width=1,
)
self.signout_button.grid(row=0, column=0, sticky="w")
- if not effective:
+ if not client_id:
self.signout_button.configure(state="disabled")
ctk.CTkButton(
@@ -705,7 +693,7 @@ class SettingsDialog(_BaseDialog):
self._on_close()
def _sign_out(self) -> None:
- client_id = settings.env_client_id() or settings.stored_client_id()
+ client_id = settings.load_client_id()
if not client_id:
return
if not confirm(
diff --git a/src/bonsaiviewer-autodesk/pyproject.toml b/src/bonsaiviewer-autodesk/pyproject.toml
index 988580b8f4..5631553e47 100644
--- a/src/bonsaiviewer-autodesk/pyproject.toml
+++ b/src/bonsaiviewer-autodesk/pyproject.toml
@@ -16,12 +16,17 @@ dependencies = [
[project.optional-dependencies]
build = ["pyinstaller>=6.0"]
+test = ["pytest>=8"]
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
+include = ["bonsaiviewer_autodesk*"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
[project.scripts]
bonsaiviewer-autodesk = "bonsaiviewer_autodesk.__main__:main"
diff --git a/src/bonsaiviewer-autodesk/tests/conftest.py b/src/bonsaiviewer-autodesk/tests/conftest.py
new file mode 100644
index 0000000000..f5c4f7780b
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/tests/conftest.py
@@ -0,0 +1,72 @@
+"""Shared fixtures for the bonsaiviewer-autodesk test suite."""
+
+from __future__ import annotations
+
+import keyring
+import keyring.backend
+import keyring.errors
+import pytest
+
+from bonsaiviewer_autodesk import cache as cache_module
+from bonsaiviewer_autodesk import settings as settings_module
+
+
+@pytest.fixture
+def cache_dir(tmp_path, monkeypatch):
+ """Redirect ``bonsaiviewer_autodesk.cache`` at an isolated tmp directory.
+
+ Patches ``cache_root`` itself rather than ``XDG_CACHE_HOME`` so the
+ redirect holds on every platform — on macOS/Windows ``cache_root`` ignores
+ the XDG variables. Returns the directory ``cache_root()`` now resolves to.
+ """
+ root = tmp_path / "cache" / "bonsaiviewer-autodesk"
+ root.mkdir(parents=True, exist_ok=True)
+ monkeypatch.setattr(cache_module, "cache_root", lambda: root)
+ return root
+
+
+@pytest.fixture
+def config_dir(tmp_path, monkeypatch):
+ """Redirect ``bonsaiviewer_autodesk.settings`` at an isolated tmp directory.
+
+ Patches ``config_root`` directly (platform-independent). Returns the
+ directory ``config_root()`` now resolves to.
+ """
+ root = tmp_path / "config" / "bonsaiviewer-autodesk"
+ root.mkdir(parents=True, exist_ok=True)
+ monkeypatch.setattr(settings_module, "config_root", lambda: root)
+ return root
+
+
+class InMemoryKeyring(keyring.backend.KeyringBackend):
+ """A keyring backend that keeps secrets in a dict — never touches the OS."""
+
+ priority = 1 # type: ignore[assignment]
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._store: dict[tuple[str, str], str] = {}
+
+ def get_password(self, service: str, username: str) -> str | None:
+ return self._store.get((service, username))
+
+ def set_password(self, service: str, username: str, password: str) -> None:
+ self._store[(service, username)] = password
+
+ def delete_password(self, service: str, username: str) -> None:
+ try:
+ del self._store[(service, username)]
+ except KeyError as exc:
+ raise keyring.errors.PasswordDeleteError("not found") from exc
+
+
+@pytest.fixture
+def memory_keyring():
+ """Swap in the in-memory keyring backend for the duration of a test."""
+ backend = InMemoryKeyring()
+ previous = keyring.get_keyring()
+ keyring.set_keyring(backend)
+ try:
+ yield backend
+ finally:
+ keyring.set_keyring(previous)
diff --git a/src/bonsaiviewer-autodesk/tests/test_autodesk.py b/src/bonsaiviewer-autodesk/tests/test_autodesk.py
new file mode 100644
index 0000000000..ef31832b9c
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/tests/test_autodesk.py
@@ -0,0 +1,676 @@
+"""Tests for the Autodesk auth + APS client (``bonsaiviewer_autodesk.autodesk``).
+
+HTTP is mocked through the injectable ``transport`` seam using
+``httpx.MockTransport``; time through the injectable ``now`` clock; and the
+OAuth redirect through the injectable ``callback_waiter``.
+"""
+
+from __future__ import annotations
+
+import datetime as dt
+import socket
+import threading
+import time
+from collections import deque
+
+import httpx
+import keyring.errors
+import pytest
+
+from bonsaiviewer_autodesk import autodesk
+from bonsaiviewer_autodesk.rpc import RpcError
+
+
+# --- HTTP routing ------------------------------------------------------------
+
+
+def _build_response(spec: dict) -> httpx.Response:
+ kwargs = {key: spec[key] for key in ("json", "text", "content", "headers") if key in spec}
+ return httpx.Response(spec.get("status", 200), **kwargs)
+
+
+class Router:
+ """A tiny httpx.MockTransport router.
+
+ Routes match on HTTP method plus a substring of the request URL, in
+ declaration order. Pass multiple specs to a route to return them in turn
+ (the last one repeats); every request is recorded on ``requests``.
+ """
+
+ def __init__(self) -> None:
+ self._routes: list[tuple[str, str, deque]] = []
+ self.requests: list[httpx.Request] = []
+
+ def add(self, method: str, contains: str, *specs: dict) -> "Router":
+ self._routes.append((method, contains, deque(specs or ({},))))
+ return self
+
+ def _handle(self, request: httpx.Request) -> httpx.Response:
+ self.requests.append(request)
+ for method, contains, specs in self._routes:
+ if request.method == method and contains in str(request.url):
+ spec = specs[0] if len(specs) == 1 else specs.popleft()
+ return _build_response(spec)
+ return httpx.Response(404, text=f"unrouted {request.method} {request.url}")
+
+ @property
+ def transport(self) -> httpx.MockTransport:
+ return httpx.MockTransport(self._handle)
+
+ def count(self, method: str, contains: str) -> int:
+ return sum(
+ 1 for r in self.requests if r.method == method and contains in str(r.url)
+ )
+
+
+# --- fakes -------------------------------------------------------------------
+
+
+class FakeTokenStore:
+ """In-memory stand-in for KeyringTokenStore."""
+
+ def __init__(self, initial: dict | None = None) -> None:
+ self.value = initial
+
+ def load(self) -> dict | None:
+ return self.value
+
+ def save(self, value: dict) -> None:
+ self.value = value
+
+ def delete(self) -> None:
+ self.value = None
+
+
+class FakeAuth:
+ """Minimal AuthSessionService stand-in for ApsClient tests."""
+
+ def ensure_access_token(self) -> str:
+ return "fake-token"
+
+
+FIXED_NOW = dt.datetime(2026, 1, 1, 12, 0, 0, tzinfo=dt.timezone.utc)
+
+
+def iso(offset_seconds: int) -> str:
+ return (FIXED_NOW + dt.timedelta(seconds=offset_seconds)).isoformat()
+
+
+def stored_token_dict(*, access_offset: int, refresh_offset: int) -> dict:
+ return {
+ "client_id": "cid",
+ "access_token": "current-access",
+ "refresh_token": "current-refresh",
+ "access_token_expires_at_utc": iso(access_offset),
+ "refresh_token_expires_at_utc": iso(refresh_offset),
+ "scope": "data:read",
+ }
+
+
+def make_auth(
+ *,
+ router: Router | None = None,
+ token_store: FakeTokenStore | None = None,
+ callback_waiter=None,
+ callback_url: str = "http://localhost:8080/",
+) -> autodesk.AuthSessionService:
+ return autodesk.AuthSessionService(
+ client_id="cid",
+ callback_url=callback_url,
+ scope="data:read",
+ token_store=token_store or FakeTokenStore(),
+ transport=(router or Router()).transport,
+ now=lambda: FIXED_NOW,
+ callback_waiter=callback_waiter,
+ )
+
+
+def make_client(router: Router) -> autodesk.ApsClient:
+ return autodesk.ApsClient(FakeAuth(), transport=router.transport)
+
+
+# --- pure helpers ------------------------------------------------------------
+
+
+def test_base64url_strips_padding():
+ assert autodesk._base64url(b"\x00") == "AA"
+ assert "=" not in autodesk._base64url(b"\x00\x00")
+
+
+def test_generate_code_verifier_is_url_safe_and_unique():
+ verifier = autodesk.generate_code_verifier()
+ assert not set(verifier) & set("=+/")
+ assert autodesk.generate_code_verifier() != autodesk.generate_code_verifier()
+
+
+def test_generate_code_challenge_matches_rfc7636_vector():
+ # RFC 7636 Appendix B test vector.
+ verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ assert (
+ autodesk.generate_code_challenge(verifier)
+ == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
+ )
+
+
+def test_parse_storage_id_splits_bucket_and_object():
+ bucket, obj = autodesk.ApsClient._parse_storage_id(
+ "urn:adsk.objects:os.object:wip.dm.prod/abc-123.ifc"
+ )
+ assert bucket == "wip.dm.prod"
+ assert obj == "abc-123.ifc"
+
+
+@pytest.mark.parametrize(
+ "bad",
+ [
+ "not-a-urn",
+ "urn:adsk.objects:os.object:bucketonly",
+ "urn:adsk.objects:os.object:/object",
+ "urn:adsk.objects:os.object:bucket/",
+ ],
+)
+def test_parse_storage_id_rejects_malformed(bad):
+ with pytest.raises(RpcError):
+ autodesk.ApsClient._parse_storage_id(bad)
+
+
+def test_entry_extracts_fields():
+ item = {
+ "id": "x",
+ "type": "items",
+ "attributes": {
+ "displayName": "Model.ifc",
+ "extension": {"type": "items:autodesk.bim360:File"},
+ },
+ }
+ assert autodesk.ApsClient._entry(item) == {
+ "id": "x",
+ "type": "items",
+ "display_name": "Model.ifc",
+ "name": None,
+ "extension_type": "items:autodesk.bim360:File",
+ }
+
+
+def test_relationship_id_handles_dict_list_and_missing():
+ data = {
+ "relationships": {
+ "parent": {"data": {"id": "p1"}},
+ "files": {"data": [{"id": "f1"}, {"id": "f2"}]},
+ }
+ }
+ assert autodesk.ApsClient._relationship_id(data, "parent") == "p1"
+ assert autodesk.ApsClient._relationship_id(data, "files") == "f1"
+ assert autodesk.ApsClient._relationship_id(data, "missing") is None
+
+
+def test_entry_name_matches_is_case_insensitive():
+ assert autodesk.ApsClient._entry_name_matches({"display_name": "Model.IFC"}, "model.ifc")
+ assert autodesk.ApsClient._entry_name_matches({"name": "Model.IFC"}, "model.ifc")
+ assert not autodesk.ApsClient._entry_name_matches({"display_name": "other.ifc"}, "model.ifc")
+
+
+# --- KeyringTokenStore -------------------------------------------------------
+
+
+def test_keyring_token_store_round_trip(memory_keyring):
+ store = autodesk.KeyringTokenStore(service_name="svc", username="user")
+ assert store.load() is None
+ store.save({"access_token": "abc"})
+ assert store.load() == {"access_token": "abc"}
+ store.delete()
+ assert store.load() is None
+
+
+def test_keyring_token_store_delete_missing_is_noop(memory_keyring):
+ store = autodesk.KeyringTokenStore(service_name="svc", username="user")
+ store.delete() # no entry to delete — must not raise
+
+
+def test_keyring_missing_backend_surfaces_friendly_error(monkeypatch):
+ def boom(*_args, **_kwargs):
+ raise keyring.errors.NoKeyringError("no backend")
+
+ monkeypatch.setattr(autodesk.keyring, "get_password", boom)
+ store = autodesk.KeyringTokenStore(service_name="svc", username="user")
+ with pytest.raises(RpcError) as excinfo:
+ store.load()
+ assert "keyring" in excinfo.value.message.lower()
+
+
+# --- token lifecycle / injected clock ---------------------------------------
+
+
+def test_ensure_access_token_returns_unexpired_token_without_http():
+ store = FakeTokenStore(stored_token_dict(access_offset=3600, refresh_offset=100_000))
+ router = Router()
+ auth = make_auth(router=router, token_store=store)
+ assert auth.ensure_access_token() == "current-access"
+ assert router.requests == []
+
+
+def test_ensure_access_token_refreshes_when_access_expired():
+ store = FakeTokenStore(stored_token_dict(access_offset=-100, refresh_offset=100_000))
+ router = Router().add(
+ "POST",
+ "/authentication/v2/token",
+ {
+ "json": {
+ "access_token": "refreshed-access",
+ "refresh_token": "refreshed-refresh",
+ "expires_in": 3600,
+ "refresh_token_expires_in": 200_000,
+ }
+ },
+ )
+ auth = make_auth(router=router, token_store=store)
+ assert auth.ensure_access_token() == "refreshed-access"
+ assert store.value["access_token"] == "refreshed-access"
+ assert router.count("POST", "/authentication/v2/token") == 1
+
+
+def test_ensure_access_token_logs_in_when_both_tokens_expired(monkeypatch):
+ monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None)
+ store = FakeTokenStore() # nothing stored at all
+ router = Router().add(
+ "POST",
+ "/authentication/v2/token",
+ {
+ "json": {
+ "access_token": "logged-in-access",
+ "refresh_token": "logged-in-refresh",
+ "expires_in": 3600,
+ }
+ },
+ )
+ auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "auth-code")
+ assert auth.ensure_access_token() == "logged-in-access"
+ assert store.value["access_token"] == "logged-in-access"
+
+
+def test_token_from_payload_uses_injected_clock():
+ auth = make_auth()
+ token = auth._token_from_payload(
+ {"access_token": "a", "refresh_token": "r", "expires_in": 3600}
+ )
+ assert token.access_token_expires_at_utc == iso(3600 - 30)
+ # Default refresh TTL is 15 days, minus the same 30s safety margin.
+ assert token.refresh_token_expires_at_utc == iso(15 * 24 * 60 * 60 - 30)
+
+
+def test_login_interactive_rejects_non_local_callback():
+ auth = make_auth(callback_url="https://example.com/callback")
+ with pytest.raises(RpcError, match="Callback URL"):
+ auth.login_interactive()
+
+
+def test_login_interactive_exchanges_code_for_token(monkeypatch):
+ opened: list[str] = []
+ monkeypatch.setattr(autodesk.webbrowser, "open", lambda url: opened.append(url))
+ store = FakeTokenStore()
+ router = Router().add(
+ "POST",
+ "/authentication/v2/token",
+ {
+ "json": {
+ "access_token": "fresh-access",
+ "refresh_token": "fresh-refresh",
+ "expires_in": 3600,
+ }
+ },
+ )
+ auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "the-code")
+ token = auth.login_interactive()
+ assert token.access_token == "fresh-access"
+ assert store.value["access_token"] == "fresh-access"
+ assert opened and opened[0].startswith(autodesk.AuthSessionService.authorize_endpoint)
+
+
+def test_token_exchange_failure_raises_rpc_error(monkeypatch):
+ monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None)
+ router = Router().add(
+ "POST", "/authentication/v2/token", {"status": 400, "text": "invalid_grant"}
+ )
+ auth = make_auth(router=router, callback_waiter=lambda *_a: "the-code")
+ with pytest.raises(RpcError, match="invalid_grant"):
+ auth.login_interactive()
+
+
+# --- wait_for_oauth_callback (real loopback socket) --------------------------
+
+
+def _free_port() -> int:
+ with socket.socket() as probe:
+ probe.bind(("127.0.0.1", 0))
+ return probe.getsockname()[1]
+
+
+def _get_with_retry(url: str, params: dict, timeout: float = 5.0) -> None:
+ """Fire one GET, retrying only while the server has not yet bound."""
+ deadline = time.time() + timeout
+ while True:
+ try:
+ httpx.get(url, params=params)
+ return
+ except httpx.ConnectError:
+ if time.time() > deadline:
+ raise
+ time.sleep(0.02)
+
+
+def drive_callback(expected_state: str, query: dict, path: str = "/cb") -> dict:
+ """Run ``wait_for_oauth_callback`` in a thread and fire one redirect at it."""
+ port = _free_port()
+ outcome: dict = {}
+
+ def server() -> None:
+ try:
+ outcome["code"] = autodesk.wait_for_oauth_callback(
+ "127.0.0.1", port, path, expected_state
+ )
+ except BaseException as exc: # noqa: BLE001 - re-raised to the test
+ outcome["error"] = exc
+
+ thread = threading.Thread(target=server, daemon=True)
+ thread.start()
+ _get_with_retry(f"http://127.0.0.1:{port}{path}", query)
+ thread.join(timeout=5)
+ return outcome
+
+
+def test_wait_for_oauth_callback_returns_authorization_code():
+ outcome = drive_callback("state-123", {"state": "state-123", "code": "the-code"})
+ assert outcome.get("code") == "the-code"
+
+
+def test_wait_for_oauth_callback_rejects_state_mismatch():
+ outcome = drive_callback("expected-state", {"state": "tampered", "code": "c"})
+ assert isinstance(outcome.get("error"), RpcError)
+ assert "state mismatch" in outcome["error"].message.lower()
+
+
+def test_wait_for_oauth_callback_reports_oauth_error():
+ outcome = drive_callback("state-123", {"state": "state-123", "error": "access_denied"})
+ assert isinstance(outcome.get("error"), RpcError)
+ assert "access_denied" in outcome["error"].message
+
+
+def test_wait_for_oauth_callback_requires_a_code():
+ outcome = drive_callback("state-123", {"state": "state-123"})
+ assert isinstance(outcome.get("error"), RpcError)
+ assert "authorization code" in outcome["error"].message.lower()
+
+
+# --- ApsClient browsing ------------------------------------------------------
+
+
+def _hub(hub_id: str, name: str) -> dict:
+ return {
+ "id": hub_id,
+ "attributes": {"name": name, "extension": {"type": "hubs:autodesk.core:Hub"}},
+ }
+
+
+def _project(project_id: str, name: str) -> dict:
+ return {
+ "id": project_id,
+ "attributes": {
+ "name": name,
+ "extension": {"type": "projects:autodesk.bim360:Project"},
+ },
+ "relationships": {"rootFolder": {"data": {"id": f"root-{project_id}"}}},
+ }
+
+
+def test_list_hubs_sorts_case_insensitively():
+ router = Router().add(
+ "GET",
+ "/project/v1/hubs",
+ {"json": {"data": [_hub("h2", "Beta"), _hub("h1", "alpha")]}},
+ )
+ hubs = make_client(router).list_hubs()
+ assert [h["name"] for h in hubs] == ["alpha", "Beta"]
+ assert hubs[0]["id"] == "h1"
+
+
+def test_list_projects_follows_pagination():
+ router = Router()
+ router.add(
+ "GET",
+ "page=2",
+ {"json": {"data": [_project("p2", "Zeta")], "links": {}}},
+ )
+ router.add(
+ "GET",
+ "/hubs/h/projects",
+ {
+ "json": {
+ "data": [_project("p1", "Alpha")],
+ "links": {
+ "next": {
+ "href": "https://developer.api.autodesk.com/project/v1/hubs/h/projects?page=2"
+ }
+ },
+ }
+ },
+ )
+ projects = make_client(router).list_projects("h")
+ assert [p["id"] for p in projects] == ["p1", "p2"]
+ assert projects[0]["root_folder_id"] == "root-p1"
+
+
+def test_get_item_returns_tip_details():
+ router = Router().add(
+ "GET",
+ "/items/",
+ {
+ "json": {
+ "data": {
+ "id": "item-1",
+ "attributes": {"displayName": "Model.ifcfed", "hidden": False},
+ "relationships": {
+ "parent": {"data": {"id": "folder-1"}},
+ "tip": {"data": {"id": "v3"}},
+ },
+ },
+ "included": [
+ {
+ "type": "versions",
+ "id": "v3",
+ "attributes": {
+ "versionNumber": 3,
+ "lastModifiedTime": "2026-01-01T00:00:00Z",
+ "lastModifiedUserName": "Dion",
+ },
+ "relationships": {
+ "storage": {
+ "data": {"id": "urn:adsk.objects:os.object:b/o"}
+ }
+ },
+ }
+ ],
+ }
+ },
+ )
+ item = make_client(router).get_item("proj-1", "item-1")
+ assert item["hidden"] is False
+ assert item["version_id"] == "v3"
+ assert item["storage_id"] == "urn:adsk.objects:os.object:b/o"
+ assert item["version_number"] == 3
+ assert item["parent_folder_id"] == "folder-1"
+ assert item["last_modified_user_name"] == "Dion"
+
+
+def test_get_item_without_tip_is_treated_as_hidden():
+ router = Router().add(
+ "GET",
+ "/items/",
+ {"json": {"data": {"id": "item-1", "attributes": {}, "relationships": {}}}},
+ )
+ item = make_client(router).get_item("proj-1", "item-1")
+ assert item["hidden"] is True
+ assert item["storage_id"] is None
+ assert item["version_id"] is None
+
+
+def test_list_folder_contents_applies_extension_filter():
+ router = Router().add(
+ "GET",
+ "/contents",
+ {
+ "json": {
+ "data": [
+ {
+ "id": "f1",
+ "type": "folders",
+ "attributes": {"name": "Sub", "extension": {"type": "t"}},
+ },
+ {
+ "id": "i1",
+ "type": "items",
+ "attributes": {"displayName": "keep.ifcfed", "extension": {}},
+ },
+ {
+ "id": "i2",
+ "type": "items",
+ "attributes": {"displayName": "skip.txt", "extension": {}},
+ },
+ ],
+ "links": {},
+ }
+ },
+ )
+ entries = make_client(router).list_folder_contents(
+ "proj",
+ "folder",
+ extension_filter=lambda e: (e["display_name"] or "").endswith(".ifcfed"),
+ )
+ ids = {e["id"] for e in entries}
+ assert ids == {"f1", "i1"} # folders kept, non-.ifcfed item dropped
+
+
+def test_get_json_maps_http_error_to_rpc_error():
+ router = Router().add(
+ "GET", "/project/v1/hubs", {"status": 403, "text": "Forbidden: bad token"}
+ )
+ with pytest.raises(RpcError, match="Forbidden"):
+ make_client(router).list_hubs()
+
+
+# --- download / upload -------------------------------------------------------
+
+
+def test_download_storage_to_file_writes_content_and_reports_progress(tmp_path):
+ router = Router()
+ router.add(
+ "GET",
+ "/signeds3download",
+ {"json": {"url": "https://signed.example/blob"}},
+ )
+ router.add("GET", "signed.example/blob", {"content": b"hello world"})
+ dest = tmp_path / "out.bin"
+ seen: list = []
+ make_client(router).download_storage_to_file(
+ "urn:adsk.objects:os.object:bucket/object",
+ dest,
+ progress=lambda name, pct, done, total: seen.append((name, pct, done, total)),
+ )
+ assert dest.read_bytes() == b"hello world"
+ assert seen[-1][1] == 100 # final progress callback reports 100%
+
+
+def test_signed_download_url_missing_raises():
+ router = Router().add("GET", "/signeds3download", {"json": {}})
+ with pytest.raises(RpcError, match="URL"):
+ make_client(router).download_storage_to_file(
+ "urn:adsk.objects:os.object:bucket/object", "/tmp/ignored"
+ )
+
+
+def _upload_router() -> Router:
+ router = Router()
+ router.add(
+ "POST", "/storage", {"json": {"data": {"id": "urn:adsk.objects:os.object:bk/obj"}}}
+ )
+ router.add(
+ "GET",
+ "/signeds3upload",
+ {"json": {"uploadKey": "ukey", "urls": ["https://up.example/part1"]}},
+ )
+ router.add("PUT", "up.example/part1", {"status": 200})
+ router.add("POST", "/signeds3upload", {"status": 200, "json": {}})
+ return router
+
+
+def test_upload_file_creates_new_item_when_folder_is_empty(tmp_path):
+ local = tmp_path / "model.ifc"
+ local.write_bytes(b"x" * 1024)
+ router = _upload_router()
+ router.add("GET", "/contents", {"json": {"data": [], "links": {}}})
+ router.add(
+ "POST",
+ "/items",
+ {
+ "json": {
+ "data": {"id": "new-item"},
+ "included": [
+ {
+ "type": "versions",
+ "id": "v1",
+ "attributes": {
+ "versionNumber": 1,
+ "lastModifiedTime": "2026-01-01T00:00:00Z",
+ "lastModifiedUserName": "Dion",
+ },
+ }
+ ],
+ }
+ },
+ )
+ result = make_client(router).upload_file_to_folder(
+ "proj", "folder", local, display_name="model.ifc"
+ )
+ assert result["item_id"] == "new-item"
+ assert result["version_id"] == "v1"
+ assert result["version_number"] == 1
+ assert router.count("PUT", "up.example/part1") == 1
+
+
+def test_upload_file_creates_a_version_when_item_exists(tmp_path):
+ local = tmp_path / "model.ifc"
+ local.write_bytes(b"x" * 1024)
+ router = _upload_router()
+ router.add(
+ "GET",
+ "/contents",
+ {
+ "json": {
+ "data": [
+ {
+ "id": "existing-item",
+ "type": "items",
+ "attributes": {"displayName": "model.ifc", "extension": {}},
+ }
+ ],
+ "links": {},
+ }
+ },
+ )
+ router.add(
+ "POST",
+ "/versions",
+ {"json": {"data": {"id": "v7", "attributes": {"versionNumber": 7}}}},
+ )
+ result = make_client(router).upload_file_to_folder(
+ "proj", "folder", local, display_name="model.ifc"
+ )
+ assert result["item_id"] == "existing-item"
+ assert result["version_id"] == "v7"
+ assert result["version_number"] == 7
+
+
+def test_upload_rejects_missing_local_file(tmp_path):
+ with pytest.raises(RpcError, match="does not exist"):
+ make_client(Router()).upload_file_to_folder(
+ "proj", "folder", tmp_path / "missing.ifc"
+ )
diff --git a/src/bonsaiviewer-autodesk/tests/test_cache.py b/src/bonsaiviewer-autodesk/tests/test_cache.py
new file mode 100644
index 0000000000..184ebe884d
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/tests/test_cache.py
@@ -0,0 +1,83 @@
+"""Tests for the on-disk cache layout (``bonsaiviewer_autodesk.cache``)."""
+
+from __future__ import annotations
+
+from bonsaiviewer_autodesk import cache
+
+
+def test_cache_root_uses_xdg_on_linux(tmp_path, monkeypatch):
+ monkeypatch.setattr(cache.platform, "system", lambda: "Linux")
+ monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
+ root = cache.cache_root()
+ assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk"
+ assert root.is_dir()
+
+
+def test_cache_root_uses_localappdata_on_windows(tmp_path, monkeypatch):
+ monkeypatch.setattr(cache.platform, "system", lambda: "Windows")
+ monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "appdata"))
+ root = cache.cache_root()
+ assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk" / "Cache"
+ assert root.is_dir()
+
+
+def test_cache_root_uses_library_caches_on_macos(tmp_path, monkeypatch):
+ monkeypatch.setattr(cache.platform, "system", lambda: "Darwin")
+ monkeypatch.setattr(cache.Path, "home", lambda: tmp_path / "home")
+ root = cache.cache_root()
+ assert root == tmp_path / "home" / "Library" / "Caches" / "bonsaiviewer-autodesk"
+ assert root.is_dir()
+
+
+def test_ifcfed_dir_is_deterministic(cache_dir):
+ first = cache.ifcfed_dir("project-1", "item-1")
+ second = cache.ifcfed_dir("project-1", "item-1")
+ assert first == second
+
+
+def test_ifcfed_dir_varies_with_inputs(cache_dir):
+ assert cache.ifcfed_dir("p", "item-1") != cache.ifcfed_dir("p", "item-2")
+ assert cache.ifcfed_dir("p1", "item") != cache.ifcfed_dir("p2", "item")
+
+
+def test_model_dir_is_per_version(cache_dir):
+ v1 = cache.model_dir("p", "item", "v1")
+ v2 = cache.model_dir("p", "item", "v2")
+ assert v1 != v2
+ assert cache.model_dir("p", "item", "v1") == v1
+
+
+def test_prepare_sole_child_dir_wipes_existing_contents(cache_dir):
+ directory = cache.ifcfed_dir("p", "item")
+ directory.mkdir(parents=True)
+ (directory / "stale.txt").write_text("old")
+
+ result = cache.prepare_sole_child_dir(directory)
+
+ assert result == directory
+ assert directory.is_dir()
+ assert list(directory.iterdir()) == []
+
+
+def test_prepare_sole_child_dir_creates_when_absent(cache_dir):
+ directory = cache.model_dir("p", "item", "v1")
+ assert not directory.exists()
+ cache.prepare_sole_child_dir(directory)
+ assert directory.is_dir()
+
+
+def test_manifest_round_trips(cache_dir):
+ directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item"))
+ ifcfed_path = directory / "model.ifcfed"
+ ifcfed_path.write_text("data")
+ manifest = {"connector": "autodesk", "item_id": "item", "hub_id": "h"}
+
+ manifest_path = cache.write_manifest(ifcfed_path, manifest)
+
+ assert manifest_path.name == "model.ifcfed.manifest"
+ assert cache.read_manifest(ifcfed_path) == manifest
+
+
+def test_read_manifest_returns_none_when_absent(cache_dir):
+ directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item"))
+ assert cache.read_manifest(directory / "model.ifcfed") is None
diff --git a/src/bonsaiviewer-autodesk/tests/test_connector.py b/src/bonsaiviewer-autodesk/tests/test_connector.py
new file mode 100644
index 0000000000..c6f1394453
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/tests/test_connector.py
@@ -0,0 +1,305 @@
+"""Tests for the RPC handlers (``bonsaiviewer_autodesk.connector``).
+
+The non-interactive handlers are exercised against a fake ``ApsClient`` so no
+network or GUI is touched; ``progress_dialog`` is stubbed out.
+"""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from pathlib import Path
+
+import pytest
+
+from bonsaiviewer_autodesk import cache, connector, settings
+from bonsaiviewer_autodesk.rpc import RpcError
+
+
+# --- fakes / fixtures --------------------------------------------------------
+
+
+@contextmanager
+def _fake_progress(_message):
+ yield lambda *_args, **_kwargs: None
+
+
+class FakeAps:
+ """Stand-in for ApsClient covering only what the handlers call."""
+
+ def __init__(self, *, item: dict | None = None, items: dict | None = None) -> None:
+ self._item = item
+ self._items = items or {}
+ self.downloaded: list[tuple] = []
+ self.uploaded: list[tuple] = []
+
+ def get_item(self, _project_id: str, item_id: str) -> dict:
+ if item_id in self._items:
+ return dict(self._items[item_id])
+ assert self._item is not None, f"no canned item for {item_id!r}"
+ return dict(self._item)
+
+ def download_storage_to_file(self, storage_id, destination_path, *, progress=None) -> None:
+ Path(destination_path).write_bytes(b"")
+ self.downloaded.append((storage_id, Path(destination_path)))
+
+ def upload_file_to_folder(
+ self, project_id, folder_id, local_path, *, display_name=None, progress=None
+ ) -> dict:
+ self.uploaded.append((project_id, folder_id, Path(local_path), display_name))
+ return {
+ "item_id": "uploaded-item",
+ "version_id": "v1",
+ "version_number": 1,
+ "last_modified_time_utc": "2026-01-01T00:00:00Z",
+ "last_modified_user_name": "Dion",
+ }
+
+
+@pytest.fixture
+def make_connector(config_dir, cache_dir, monkeypatch):
+ """Build an AutodeskConnector wired to a fake ApsClient."""
+ monkeypatch.setattr(connector, "progress_dialog", _fake_progress)
+
+ def _make(aps: FakeAps) -> connector.AutodeskConnector:
+ conn = connector.AutodeskConnector()
+ conn.aps = aps
+ conn.auth = object() # only identity matters to _require_aps
+ return conn
+
+ return _make
+
+
+def _ifcfed_item(**overrides) -> dict:
+ item = {
+ "id": "item-1",
+ "hidden": False,
+ "storage_id": "urn:adsk.objects:os.object:bucket/object",
+ "display_name": "Project.ifcfed",
+ "version_id": "v1",
+ "version_number": 1,
+ "last_modified_time_utc": None,
+ "last_modified_user_name": None,
+ "parent_folder_id": "folder-1",
+ }
+ item.update(overrides)
+ return item
+
+
+# --- pure helpers ------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "value,expected",
+ [
+ (0, "0 B"),
+ (512, "512 B"),
+ (1024, "1.0 KB"),
+ (1536, "1.5 KB"),
+ (5 * 1024 * 1024, "5.0 MB"),
+ ],
+)
+def test_format_bytes(value, expected):
+ assert connector._format_bytes(value) == expected
+
+
+def test_progress_detail_combines_percent_and_bytes():
+ detail = connector._progress_detail(45, 4_500_000, 10_000_000)
+ assert detail.startswith("45%, ")
+ assert " / " in detail
+
+
+def test_progress_detail_percent_only():
+ assert connector._progress_detail(50, None, None) == "50%"
+
+
+def test_progress_detail_bytes_without_total():
+ assert connector._progress_detail(None, 2048, None) == "2.0 KB"
+
+
+def test_progress_detail_empty_when_nothing_known():
+ assert connector._progress_detail(None, None, None) == ""
+
+
+def test_build_metadata_maps_all_fields():
+ metadata = connector._build_metadata(
+ {
+ "version_number": 3,
+ "last_modified_time_utc": "2026-01-01T00:00:00Z",
+ "last_modified_user_name": "Dion",
+ }
+ )
+ assert metadata == {
+ "revision": "v3",
+ "date": "2026-01-01T00:00:00Z",
+ "author": "Dion",
+ }
+
+
+def test_build_metadata_omits_missing_fields():
+ assert connector._build_metadata({}) == {}
+ assert connector._build_metadata({"version_number": None}) == {}
+
+
+def test_require_string_rejects_missing_or_blank():
+ assert connector._require_string({"k": "v"}, "k") == "v"
+ with pytest.raises(RpcError):
+ connector._require_string({"k": " "}, "k")
+ with pytest.raises(RpcError):
+ connector._require_string({}, "k")
+
+
+def test_require_object_and_array_type_checks():
+ assert connector._require_object({"a": 1}, "p") == {"a": 1}
+ assert connector._require_array([1, 2], "p") == [1, 2]
+ with pytest.raises(RpcError):
+ connector._require_object([], "p")
+ with pytest.raises(RpcError):
+ connector._require_array({}, "p")
+
+
+# --- credential wiring -------------------------------------------------------
+
+
+def test_reload_credentials_without_client_id_leaves_aps_unset(config_dir):
+ conn = connector.AutodeskConnector()
+ assert conn.aps is None
+ assert conn.auth is None
+ with pytest.raises(RpcError, match="client id"):
+ conn._require_aps()
+
+
+def test_reload_credentials_with_client_id_builds_aps(config_dir):
+ settings.save_client_id("my-client-id")
+ conn = connector.AutodeskConnector()
+ assert conn.aps is not None
+ assert conn.auth is not None
+
+
+# --- pull_ifcfed -------------------------------------------------------------
+
+
+def test_pull_ifcfed_downloads_and_writes_manifest(make_connector):
+ aps = FakeAps(item=_ifcfed_item())
+ conn = make_connector(aps)
+
+ result = conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
+
+ path = Path(result["path"])
+ assert path.exists()
+ assert path.name == "Project.ifcfed"
+ assert aps.downloaded # the fake actually got asked to download
+
+ manifest = cache.read_manifest(path)
+ assert manifest["connector"] == "autodesk"
+ assert manifest["item_id"] == "item-1"
+ assert manifest["hub_id"] == "h"
+
+
+def test_pull_ifcfed_rejects_non_ifcfed_file(make_connector):
+ aps = FakeAps(item=_ifcfed_item(display_name="model.ifc"))
+ conn = make_connector(aps)
+ with pytest.raises(RpcError, match="ifcfed"):
+ conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
+
+
+def test_pull_ifcfed_rejects_deleted_item(make_connector):
+ aps = FakeAps(item=_ifcfed_item(hidden=True))
+ conn = make_connector(aps)
+ with pytest.raises(RpcError, match="deleted"):
+ conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
+
+
+def test_pull_ifcfed_requires_string_fields(make_connector):
+ conn = make_connector(FakeAps(item=_ifcfed_item()))
+ with pytest.raises(RpcError, match="item_id"):
+ conn.pull_ifcfed({"hub_id": "h", "project_id": "p"})
+
+
+# --- pull_models -------------------------------------------------------------
+
+
+def test_pull_models_skips_failures_with_none(make_connector):
+ aps = FakeAps(
+ items={
+ "good": _ifcfed_item(id="good", display_name="good.ifc"),
+ "gone": _ifcfed_item(id="gone", hidden=True),
+ }
+ )
+ conn = make_connector(aps)
+ models = [
+ {"source": {"connector": "autodesk", "project_id": "p", "item_id": "good"}},
+ {"source": {"connector": "autodesk", "project_id": "p", "item_id": "gone"}},
+ {"source": {"connector": "other", "project_id": "p", "item_id": "good"}},
+ ]
+
+ results = conn.pull_models(models)
+
+ assert len(results) == 3
+ assert results[0] is not None and Path(results[0]["path"]).exists()
+ assert results[1] is None # hidden/deleted item
+ assert results[2] is None # wrong connector -> RpcError, swallowed
+
+
+def test_pull_models_requires_a_json_array(make_connector):
+ conn = make_connector(FakeAps(item=_ifcfed_item()))
+ with pytest.raises(RpcError, match="array"):
+ conn.pull_models({"not": "an array"})
+
+
+# --- push_ifcfed -------------------------------------------------------------
+
+
+def test_push_ifcfed_uploads_and_caches_with_manifest(make_connector, tmp_path):
+ local = tmp_path / "local.ifcfed"
+ local.write_bytes(b"ifcfed bytes")
+ aps = FakeAps(item=_ifcfed_item())
+ conn = make_connector(aps)
+
+ result = conn.push_ifcfed(
+ {
+ "path": str(local),
+ "manifest": {
+ "connector": "autodesk",
+ "hub_id": "h",
+ "project_id": "p",
+ "item_id": "item-1",
+ },
+ }
+ )
+
+ assert aps.uploaded
+ cached = Path(result["path"])
+ assert cached.exists()
+ assert cache.read_manifest(cached)["item_id"] == "uploaded-item"
+
+
+def test_push_ifcfed_rejects_missing_local_file(make_connector):
+ conn = make_connector(FakeAps(item=_ifcfed_item()))
+ with pytest.raises(RpcError, match="does not exist"):
+ conn.push_ifcfed(
+ {
+ "path": "/no/such/file.ifcfed",
+ "manifest": {
+ "connector": "autodesk",
+ "hub_id": "h",
+ "project_id": "p",
+ "item_id": "item-1",
+ },
+ }
+ )
+
+
+def test_push_ifcfed_rejects_non_ifcfed_extension(make_connector, tmp_path):
+ local = tmp_path / "local.ifc"
+ local.write_bytes(b"data")
+ conn = make_connector(FakeAps(item=_ifcfed_item()))
+ with pytest.raises(RpcError, match="ifcfed"):
+ conn.push_ifcfed({"path": str(local), "manifest": {"connector": "autodesk"}})
+
+
+def test_push_ifcfed_rejects_foreign_connector_manifest(make_connector, tmp_path):
+ local = tmp_path / "local.ifcfed"
+ local.write_bytes(b"data")
+ conn = make_connector(FakeAps(item=_ifcfed_item()))
+ with pytest.raises(RpcError, match="connector"):
+ conn.push_ifcfed({"path": str(local), "manifest": {"connector": "other"}})
diff --git a/src/bonsaiviewer-autodesk/tests/test_rpc.py b/src/bonsaiviewer-autodesk/tests/test_rpc.py
new file mode 100644
index 0000000000..5dcd904ab8
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/tests/test_rpc.py
@@ -0,0 +1,123 @@
+"""Tests for the JSON-RPC host (``bonsaiviewer_autodesk.rpc``)."""
+
+from __future__ import annotations
+
+import io
+import json
+
+from bonsaiviewer_autodesk.rpc import (
+ JSONRPC_INTERNAL_ERROR,
+ JSONRPC_INVALID_PARAMS,
+ JSONRPC_INVALID_REQUEST,
+ JSONRPC_METHOD_NOT_FOUND,
+ JSONRPC_PARSE_ERROR,
+ JsonRpcHost,
+ RpcError,
+)
+
+
+def run(line: str, handlers: dict | None = None) -> tuple[list[dict], str]:
+ """Drive a ``JsonRpcHost`` over in-memory streams.
+
+ Returns ``(responses, stderr_text)`` where ``responses`` is the parsed
+ JSON written to stdout, one element per line.
+ """
+ out = io.StringIO()
+ err = io.StringIO()
+ JsonRpcHost(
+ handlers or {},
+ stdin=io.StringIO(line),
+ stdout=out,
+ stderr=err,
+ ).run()
+ responses = [json.loads(piece) for piece in out.getvalue().splitlines() if piece]
+ return responses, err.getvalue()
+
+
+def test_parse_error_for_invalid_json():
+ responses, _ = run("this is not json\n")
+ assert responses[0]["error"]["code"] == JSONRPC_PARSE_ERROR
+ assert responses[0]["id"] is None
+
+
+def test_request_must_be_a_json_object():
+ responses, _ = run("[1, 2, 3]\n")
+ assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
+
+
+def test_wrong_jsonrpc_version_keeps_id():
+ responses, _ = run('{"jsonrpc": "1.0", "id": 7, "method": "go"}\n')
+ assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
+ assert responses[0]["id"] == 7
+
+
+def test_missing_method_string():
+ responses, _ = run('{"jsonrpc": "2.0", "id": 1}\n')
+ assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
+
+
+def test_params_must_be_object_or_array():
+ responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "go", "params": 5}\n')
+ assert responses[0]["error"]["code"] == JSONRPC_INVALID_PARAMS
+
+
+def test_unknown_method():
+ responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "nope"}\n')
+ assert responses[0]["error"]["code"] == JSONRPC_METHOD_NOT_FOUND
+
+
+def test_successful_result_round_trip():
+ responses, _ = run(
+ '{"jsonrpc": "2.0", "id": 42, "method": "echo", "params": {"x": 1}}\n',
+ {"echo": lambda params: params},
+ )
+ assert responses == [{"jsonrpc": "2.0", "id": 42, "result": {"x": 1}}]
+
+
+def test_rpc_error_is_forwarded_with_code_and_data():
+ def handler(_params):
+ raise RpcError(JSONRPC_INTERNAL_ERROR, "boom", data={"detail": "x"})
+
+ responses, _ = run(
+ '{"jsonrpc": "2.0", "id": 1, "method": "go"}\n',
+ {"go": handler},
+ )
+ assert responses[0]["error"] == {
+ "code": JSONRPC_INTERNAL_ERROR,
+ "message": "boom",
+ "data": {"detail": "x"},
+ }
+
+
+def test_unexpected_exception_becomes_internal_error():
+ def handler(_params):
+ raise ValueError("kaboom")
+
+ responses, stderr = run(
+ '{"jsonrpc": "2.0", "id": 1, "method": "go"}\n',
+ {"go": handler},
+ )
+ assert responses[0]["error"]["code"] == JSONRPC_INTERNAL_ERROR
+ assert responses[0]["error"]["message"] == "kaboom"
+ assert "Traceback" in stderr
+
+
+def test_notification_runs_handler_but_writes_no_response():
+ calls: list[int] = []
+ responses, _ = run(
+ '{"jsonrpc": "2.0", "method": "go"}\n',
+ {"go": lambda _params: calls.append(1)},
+ )
+ assert calls == [1]
+ assert responses == []
+
+
+def test_blank_lines_skipped_and_requests_processed_in_order():
+ line = (
+ '{"jsonrpc": "2.0", "id": 1, "method": "go"}\n'
+ "\n"
+ " \n"
+ '{"jsonrpc": "2.0", "id": 2, "method": "go"}\n'
+ )
+ responses, _ = run(line, {"go": lambda _params: "ok"})
+ assert [r["id"] for r in responses] == [1, 2]
diff --git a/src/bonsaiviewer-autodesk/tests/test_settings.py b/src/bonsaiviewer-autodesk/tests/test_settings.py
new file mode 100644
index 0000000000..3f2a905417
--- /dev/null
+++ b/src/bonsaiviewer-autodesk/tests/test_settings.py
@@ -0,0 +1,85 @@
+"""Tests for settings persistence (``bonsaiviewer_autodesk.settings``)."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from bonsaiviewer_autodesk import settings
+
+
+def _write_settings_json(config_dir, data: dict) -> None:
+ (config_dir / "settings.json").write_text(json.dumps(data), encoding="utf-8")
+
+
+def test_config_root_uses_xdg_on_linux(tmp_path, monkeypatch):
+ monkeypatch.setattr(settings.platform, "system", lambda: "Linux")
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
+ root = settings.config_root()
+ assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk"
+ assert root.is_dir()
+
+
+def test_config_root_uses_appdata_on_windows(tmp_path, monkeypatch):
+ monkeypatch.setattr(settings.platform, "system", lambda: "Windows")
+ monkeypatch.setenv("APPDATA", str(tmp_path / "appdata"))
+ root = settings.config_root()
+ assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk"
+ assert root.is_dir()
+
+
+def test_config_root_uses_application_support_on_macos(tmp_path, monkeypatch):
+ monkeypatch.setattr(settings.platform, "system", lambda: "Darwin")
+ monkeypatch.setattr(settings.Path, "home", lambda: tmp_path / "home")
+ root = settings.config_root()
+ assert root == tmp_path / "home" / "Library" / "Application Support" / "bonsaiviewer-autodesk"
+ assert root.is_dir()
+
+
+def test_save_and_load_client_id_strips_whitespace(config_dir):
+ settings.save_client_id(" abc123 ")
+ assert settings.load_client_id() == "abc123"
+
+
+def test_load_client_id_empty_when_nothing_configured(config_dir):
+ assert settings.load_client_id() == ""
+
+
+def test_callback_port_round_trips(config_dir):
+ settings.save_callback_port(9001)
+ assert settings.stored_callback_port() == 9001
+
+
+def test_callback_port_defaults_when_unset(config_dir):
+ assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
+
+
+def test_callback_port_defaults_on_non_numeric_value(config_dir):
+ _write_settings_json(config_dir, {"callback_port": "not-a-number"})
+ assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
+
+
+def test_callback_port_defaults_on_out_of_range_value(config_dir):
+ _write_settings_json(config_dir, {"callback_port": 70000})
+ assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
+
+
+def test_save_callback_port_rejects_out_of_range(config_dir):
+ with pytest.raises(ValueError):
+ settings.save_callback_port(0)
+ with pytest.raises(ValueError):
+ settings.save_callback_port(70000)
+
+
+def test_corrupt_settings_file_is_treated_as_empty(config_dir):
+ (config_dir / "settings.json").write_text("{ not valid json", encoding="utf-8")
+ assert settings.load_client_id() == ""
+ assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
+
+
+def test_save_client_id_preserves_other_keys(config_dir):
+ settings.save_callback_port(9001)
+ settings.save_client_id("abc")
+ assert settings.stored_callback_port() == 9001
+ assert settings.load_client_id() == "abc"
diff --git a/src/bonsaiviewer/docs/connectors/autodesk.rst b/src/bonsaiviewer/docs/connectors/autodesk.rst
index 62ba42e2b4..987dd1e1d6 100644
--- a/src/bonsaiviewer/docs/connectors/autodesk.rst
+++ b/src/bonsaiviewer/docs/connectors/autodesk.rst
@@ -30,8 +30,7 @@ Run
bonsaiviewer-autodesk
The connector launches without configuration. On first run, open the settings
-dialog, or set the ``APS_CLIENT_ID`` environment variable, to configure the
-Autodesk client ID and OAuth callback port.
+dialog to configure the Autodesk client ID and OAuth callback port.
For direct protocol testing, send newline-delimited JSON-RPC 2.0 requests on
standard input:
@@ -51,11 +50,8 @@ exit.
Configuration
-------------
-The connector reads the Autodesk client ID from these places, in order:
-
-1. ``APS_CLIENT_ID`` environment variable.
-2. ``settings.json`` in the connector config directory, written by the settings
- dialog.
+The connector reads the Autodesk client ID from ``settings.json`` in the
+connector config directory, written by the settings dialog.
The OAuth callback host is always ``localhost``. The callback port defaults to
``8080`` and can be changed in the settings dialog.