From 137a89025622e394f8c43b62ff3279faa748e10f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 21 May 2026 10:23:39 +1000 Subject: [PATCH] Add test suite for the Bonsai Viewer Autodesk connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce pytest coverage for the previously untested connector — rpc, cache, settings, autodesk (auth + APS client) and connector handlers — 94 tests, runnable via the new `test` optional-dependency extra. To make HTTP, time and the OAuth redirect testable without a network or real sockets, add dependency-injection seams to autodesk.py: AuthSessionService and ApsClient accept an optional httpx transport; AuthSessionService accepts an injectable clock and callback_waiter; and _wait_for_callback is extracted to the module-level wait_for_oauth_callback. All seams default to the previous behaviour. Remove the APS_CLIENT_ID environment-variable override: the client id now comes solely from settings.json, collapsing settings.load_client_id and simplifying the settings dialog. CI: the build-bonsaiviewer-autodesk workflow gains a `test` job (Python 3.11 + 3.13) that gates the build matrix. Co-Authored-By: Claude Opus 4.7 --- .../workflows/build-bonsaiviewer-autodesk.yml | 33 + .../bonsaiviewer_autodesk/autodesk.py | 104 +-- .../bonsaiviewer_autodesk/settings.py | 14 +- .../bonsaiviewer_autodesk/ui.py | 22 +- src/bonsaiviewer-autodesk/pyproject.toml | 5 + src/bonsaiviewer-autodesk/tests/conftest.py | 72 ++ .../tests/test_autodesk.py | 676 ++++++++++++++++++ src/bonsaiviewer-autodesk/tests/test_cache.py | 83 +++ .../tests/test_connector.py | 305 ++++++++ src/bonsaiviewer-autodesk/tests/test_rpc.py | 123 ++++ .../tests/test_settings.py | 85 +++ src/bonsaiviewer/docs/connectors/autodesk.rst | 10 +- 12 files changed, 1452 insertions(+), 80 deletions(-) create mode 100644 src/bonsaiviewer-autodesk/tests/conftest.py create mode 100644 src/bonsaiviewer-autodesk/tests/test_autodesk.py create mode 100644 src/bonsaiviewer-autodesk/tests/test_cache.py create mode 100644 src/bonsaiviewer-autodesk/tests/test_connector.py create mode 100644 src/bonsaiviewer-autodesk/tests/test_rpc.py create mode 100644 src/bonsaiviewer-autodesk/tests/test_settings.py 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.