"""Unit tests for the three new directory methods on RuntimeClient. Covers: - `create_directory` — PUT with `{"type": "directory"}` body. - `delete_directory` — DELETE, surfaces Jupyter's 409 on non-empty dirs. - `ensure_directory` — GET-first, falls back to `create_directory` on 404. Uses `respx` to mock httpx transport so we don't need a live Jupyter. The runtime descriptor (`get_workspace`) is patched to a synchronous return so `_ensure_workspace` short-circuits without hitting the Runtime service. """ from __future__ import annotations import httpx import pytest import respx from backend.runtime_client import RuntimeClient, RuntimeClientError WORKSPACE_ID = "01HWS0000000000000000000A" BASE_URL = "http://runtime" PORT = 34567 TOKEN = "test-token" JUPYTER_URL = f"{BASE_URL}:{PORT}/jupyter/{WORKSPACE_ID}/api/contents" def _running_descriptor() -> dict: return { "status": "running", "workspace_id": WORKSPACE_ID, "base_url": BASE_URL, "port": PORT, "token": TOKEN, } @pytest.fixture def client() -> httpx.AsyncClient: return httpx.AsyncClient(timeout=httpx.Timeout(5.0)) @pytest.fixture def runtime(client: httpx.AsyncClient) -> RuntimeClient: rt = RuntimeClient(client) # Bypass the real `_ensure_workspace` so tests don't have to mock the # Runtime service. The descriptor is otherwise identical to what the # production path returns. rt._ensure_workspace = _ensure_workspace_stub # type: ignore[assignment] return rt async def _ensure_workspace_stub(workspace_id: str) -> dict: return _running_descriptor() # --------------------------------------------------------------------------- # create_directory # --------------------------------------------------------------------------- async def test_create_directory_happy_path(runtime: RuntimeClient) -> None: with respx.mock(assert_all_called=True) as router: route = router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( return_value=httpx.Response( 201, json={ "name": "01DIRAAAAAAAAAAAAAAA", "type": "directory", "path": "01DIRAAAAAAAAAAAAAAA", }, ) ) async with httpx.AsyncClient() as transport: runtime.client = transport # type: ignore[assignment] result = await runtime.create_directory( WORKSPACE_ID, name="01DIRAAAAAAAAAAAAAAA" ) assert route.called assert result["type"] == "directory" # Verify request body shape (PUT contents/ directory). request = route.calls[0].request assert request.headers["Authorization"] == f"token {TOKEN}" assert request.headers["Content-Type"] == "application/json" assert request.content == b'{"type":"directory"}' async def test_create_directory_nested_path(runtime: RuntimeClient) -> None: """Nested path `{parent_ulid}/{dir_ulid}` lands on Jupyter correctly.""" nested = "01DIR_PARENT_ULID/01DIR_CHILD_ULID" with respx.mock(assert_all_called=True) as router: route = router.put(f"{JUPYTER_URL}/{nested}").mock( return_value=httpx.Response( 201, json={"name": "01DIR_CHILD_ULID", "type": "directory"} ) ) async with httpx.AsyncClient() as transport: runtime.client = transport # type: ignore[assignment] await runtime.create_directory(WORKSPACE_ID, name=nested) assert route.called async def test_create_directory_propagates_jupyter_4xx( runtime: RuntimeClient, ) -> None: with respx.mock() as router: put_route = router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( return_value=httpx.Response( 400, json={ "detail": { "code": "BAD_REQUEST", "message": "invalid name", } }, ) ) async with httpx.AsyncClient() as transport: runtime.client = transport # type: ignore[assignment] with pytest.raises(RuntimeClientError) as exc_info: await runtime.create_directory( WORKSPACE_ID, name="01DIRAAAAAAAAAAAAAAA" ) assert put_route.called assert exc_info.value.status_code == 400 # `_jupyter_request` surfaces the whole JSON body as `detail` — the # inner `detail` envelope is preserved verbatim. assert exc_info.value.detail == { "detail": { "code": "BAD_REQUEST", "message": "invalid name", } } # --------------------------------------------------------------------------- # delete_directory # --------------------------------------------------------------------------- async def test_delete_directory_happy_path(runtime: RuntimeClient) -> None: with respx.mock(assert_all_called=True) as router: route = router.delete(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( return_value=httpx.Response(204) ) async with httpx.AsyncClient() as transport: runtime.client = transport # type: ignore[assignment] result = await runtime.delete_directory( WORKSPACE_ID, name="01DIRAAAAAAAAAAAAAAA" ) assert route.called assert result is None async def test_delete_directory_non_empty_409(runtime: RuntimeClient) -> None: """Jupyter rejects non-empty directory deletes with 409; surface as-is.""" with respx.mock() as router: router.delete(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( return_value=httpx.Response( 409, json={ "detail": { "code": "DIRECTORY_NOT_EMPTY", "message": "Directory is not empty", } }, ) ) async with httpx.AsyncClient() as transport: runtime.client = transport # type: ignore[assignment] with pytest.raises(RuntimeClientError) as exc_info: await runtime.delete_directory( WORKSPACE_ID, name="01DIRAAAAAAAAAAAAAAA" ) assert exc_info.value.status_code == 409 # --------------------------------------------------------------------------- # ensure_directory # --------------------------------------------------------------------------- async def test_ensure_directory_already_exists(runtime: RuntimeClient) -> None: """GET succeeds → no PUT. The lazy-backfill is a no-op.""" put_called = False def _track_put(request: httpx.Request) -> httpx.Response: nonlocal put_called put_called = True return httpx.Response(201, json={}) with respx.mock(assert_all_called=False) as router: router.get(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( return_value=httpx.Response( 200, json={"name": "01DIRAAAAAAAAAAAAAAA", "type": "directory"}, ) ) router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( side_effect=_track_put ) async with httpx.AsyncClient() as transport: runtime.client = transport # type: ignore[assignment] await runtime.ensure_directory( WORKSPACE_ID, "01DIRAAAAAAAAAAAAAAA" ) assert not put_called, "PUT must not be issued when GET already shows the dir exists" async def test_ensure_directory_missing_creates_it( runtime: RuntimeClient, ) -> None: """GET 404 → PUT. The lazy-backfill creates the missing directory.""" with respx.mock(assert_all_called=True) as router: get_route = router.get(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( return_value=httpx.Response( 404, json={"detail": {"code": "NOT_FOUND"}} ) ) put_route = router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( return_value=httpx.Response( 201, json={"type": "directory"} ) ) async with httpx.AsyncClient() as transport: runtime.client = transport # type: ignore[assignment] await runtime.ensure_directory( WORKSPACE_ID, "01DIRAAAAAAAAAAAAAAA" ) assert get_route.called assert put_route.called async def test_ensure_directory_propagates_non_404_error( runtime: RuntimeClient, ) -> None: """GET 500 → propagate; do NOT fall through to PUT.""" put_called = False def _track_put(request: httpx.Request) -> httpx.Response: nonlocal put_called put_called = True return httpx.Response(201, json={}) with respx.mock(assert_all_called=False) as router: router.get(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( return_value=httpx.Response(500, text="internal error") ) router.put(f"{JUPYTER_URL}/01DIRAAAAAAAAAAAAAAA").mock( side_effect=_track_put ) async with httpx.AsyncClient() as transport: runtime.client = transport # type: ignore[assignment] with pytest.raises(RuntimeClientError) as exc_info: await runtime.ensure_directory( WORKSPACE_ID, "01DIRAAAAAAAAAAAAAAA" ) assert exc_info.value.status_code == 500 assert not put_called, "PUT must not be issued after a non-404 GET error"