diff --git a/app/api/dropbox.py b/app/api/dropbox.py index 73cddec4..86f72a25 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -298,6 +298,91 @@ async def test_dropbox_token(request: Request): return {"status": "error", "message": f"Connection error: {str(e)}"} +@router.post("/dropbox/list-folders") +@require_login +async def list_dropbox_folders( + request: Request, + access_token: Annotated[str, Form(...)], + path: Annotated[str, Form()] = "", +): + """ + List folders in a Dropbox account for the directory selector. + + Accepts an OAuth access token (short-lived) and a path to list. + Returns a flat list of folder entries under the given path. + """ + try: + # Normalize path: Dropbox API uses "" for root, otherwise "/path" + folder_path = path.strip() + if folder_path == "/": + folder_path = "" + elif folder_path and not folder_path.startswith("/"): + folder_path = f"/{folder_path}" + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + payload = { + "path": folder_path, + "recursive": False, + "include_deleted": False, + "include_has_explicit_shared_members": False, + "include_mounted_folders": True, + } + + response = requests.post( + "https://api.dropboxapi.com/2/files/list_folder", + headers=headers, + json=payload, + timeout=settings.http_request_timeout, + ) + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Access token is invalid or expired. Please re-authorize.", + ) + + if response.status_code != 200: + logger.error(f"Dropbox list_folder failed: {response.status_code} {response.text}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to list Dropbox folders: {response.text}", + ) + + data = response.json() + folders = [] + for entry in data.get("entries", []): + if entry.get(".tag") == "folder": + folders.append( + { + "name": entry["name"], + "path": entry["path_display"], + "id": entry.get("id", ""), + } + ) + + # Sort folders alphabetically + folders.sort(key=lambda f: f["name"].lower()) + + return { + "folders": folders, + "path": folder_path or "/", + "has_more": data.get("has_more", False), + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Error listing Dropbox folders: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list folders: {str(e)}", + ) + + @router.post("/dropbox/save-settings") @require_login async def save_dropbox_settings( diff --git a/app/api/onedrive.py b/app/api/onedrive.py index 25429344..9e19c303 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -56,6 +56,7 @@ async def exchange_onedrive_token( # Return just what's needed by the frontend return { "refresh_token": token_data["refresh_token"], + "access_token": token_data.get("access_token", ""), "expires_in": token_data.get("expires_in", 3600), } @@ -183,6 +184,102 @@ async def test_onedrive_token(request: Request): return {"status": "error", "message": f"Connection error: {str(e)}"} +@router.post("/onedrive/list-folders") +@require_login +async def list_onedrive_folders( + request: Request, + access_token: Annotated[str, Form(...)], + path: Annotated[str, Form()] = "", +): + """ + List folders in a OneDrive account for the directory selector. + + Accepts an OAuth access token (short-lived) and a path to list. + Returns a flat list of folder entries under the given path. + """ + try: + folder_path = path.strip().strip("/") + + headers = { + "Authorization": f"Bearer {access_token}", + } + + # Build the Graph API URL for listing children + if not folder_path or folder_path == "root": + url = "https://graph.microsoft.com/v1.0/me/drive/root/children" + else: + url = f"https://graph.microsoft.com/v1.0/me/drive/root:/{folder_path}:/children" + + # Only request folders and minimal fields + params = { + "$filter": "folder ne null", + "$select": "name,id,parentReference,folder", + "$top": "200", + } + + response = requests.get( + url, + headers=headers, + params=params, + timeout=settings.http_request_timeout, + ) + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Access token is invalid or expired. Please re-authorize.", + ) + + if response.status_code != 200: + logger.error(f"OneDrive list children failed: {response.status_code} {response.text}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to list OneDrive folders: {response.text}", + ) + + data = response.json() + folders = [] + for item in data.get("value", []): + if "folder" in item: + parent_path = "" + if item.get("parentReference", {}).get("path"): + # parentReference.path looks like /drive/root:/some/path + raw_parent = item["parentReference"]["path"] + prefix = "/drive/root:" + if raw_parent.startswith(prefix): + parent_path = raw_parent[len(prefix) :] + elif raw_parent == "/drive/root": + parent_path = "" + + item_path = f"{parent_path}/{item['name']}" if parent_path else f"/{item['name']}" + + folders.append( + { + "name": item["name"], + "path": item_path, + "id": item.get("id", ""), + "child_count": item.get("folder", {}).get("childCount", 0), + } + ) + + # Sort folders alphabetically + folders.sort(key=lambda f: f["name"].lower()) + + return { + "folders": folders, + "path": f"/{folder_path}" if folder_path else "/", + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Error listing OneDrive folders: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list folders: {str(e)}", + ) + + def format_time_remaining(time_delta): """Format a timedelta into a human-readable string.""" if time_delta.total_seconds() <= 0: diff --git a/app/views/google_drive.py b/app/views/google_drive.py index f6bde9c0..ec70f92c 100644 --- a/app/views/google_drive.py +++ b/app/views/google_drive.py @@ -45,6 +45,9 @@ async def google_drive_setup_page( except (json.JSONDecodeError, TypeError): cfg = {} folder_id = cfg.get("folder_id", "") + # Provide system-wide OAuth credentials when available so users can + # authorize without registering their own Google Cloud app. + has_system_credentials = bool(settings.google_drive_client_id and settings.google_drive_client_secret) return templates.TemplateResponse( "google_drive.html", { @@ -58,10 +61,13 @@ async def google_drive_setup_page( "use_oauth": True, "oauth_configured": bool(integration.credentials), "sa_configured": False, - "client_id": False, - "client_id_value": "", - "client_secret": False, - "client_secret_value": "", + "has_system_credentials": has_system_credentials, + "client_id": bool(settings.google_drive_client_id) if has_system_credentials else False, + "client_id_value": (settings.google_drive_client_id or "" if has_system_credentials else ""), + "client_secret": bool(settings.google_drive_client_secret) if has_system_credentials else False, + "client_secret_value": ( + settings.google_drive_client_secret or "" if has_system_credentials else "" + ), "refresh_token": False, "refresh_token_value": "", "has_credentials_json": False, @@ -90,6 +96,7 @@ async def google_drive_setup_page( "use_oauth": use_oauth, "oauth_configured": oauth_configured, "sa_configured": sa_configured, + "has_system_credentials": bool(settings.google_drive_client_id and settings.google_drive_client_secret), "client_id": bool(settings.google_drive_client_id), "client_id_value": settings.google_drive_client_id or "", "client_secret": bool(settings.google_drive_client_secret), diff --git a/app/views/onedrive.py b/app/views/onedrive.py index 4b8b3763..d9fd5e6e 100644 --- a/app/views/onedrive.py +++ b/app/views/onedrive.py @@ -44,6 +44,9 @@ async def onedrive_setup_page( cfg = {} # Support both "folder_path" (WATCH_FOLDER / ONEDRIVE destination) folder_path = cfg.get("folder_path", cfg.get("folder", "")) + # Provide system-wide app credentials when available so users can + # authorize without registering their own Azure/OneDrive app. + has_system_credentials = bool(settings.onedrive_client_id and settings.onedrive_client_secret) return templates.TemplateResponse( "onedrive.html", { @@ -54,11 +57,12 @@ async def onedrive_setup_page( "integration_name": integration.name, "integration_type": integration.integration_type, "folder_path": folder_path, - "client_id": False, - "client_id_value": "", - "client_secret": False, - "client_secret_value": "", - "tenant_id": "common", + "has_system_credentials": has_system_credentials, + "client_id": bool(settings.onedrive_client_id) if has_system_credentials else False, + "client_id_value": settings.onedrive_client_id or "" if has_system_credentials else "", + "client_secret": bool(settings.onedrive_client_secret) if has_system_credentials else False, + "client_secret_value": (settings.onedrive_client_secret or "" if has_system_credentials else ""), + "tenant_id": settings.onedrive_tenant_id or "common", "refresh_token": False, "refresh_token_value": "", }, @@ -75,6 +79,7 @@ async def onedrive_setup_page( "request": request, "user_mode": False, "is_configured": is_configured, + "has_system_credentials": bool(settings.onedrive_client_id and settings.onedrive_client_secret), "client_id": bool(settings.onedrive_client_id), "client_id_value": settings.onedrive_client_id or "", "client_secret": bool(settings.onedrive_client_secret), diff --git a/docs/API.md b/docs/API.md index 775bd593..bd48ec75 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1404,6 +1404,55 @@ Get the current user's integration quota usage. } ``` +## Cloud Provider Folder Browser + +Browse folders in connected cloud storage providers. These endpoints are used by the OAuth callback pages to let users select a target folder after authorization. + +### POST /api/dropbox/list-folders + +List folders in a Dropbox account. Requires a short-lived OAuth access token obtained during the authorization flow. + +**Request (form-data):** + +| Field | Type | Required | Description | +|----------------|--------|----------|--------------------------------------| +| `access_token` | string | Yes | Dropbox OAuth access token | +| `path` | string | No | Folder path to list (default: root) | + +**Response (200):** +```json +{ + "folders": [ + { "name": "Documents", "path": "/Documents", "id": "id:abc123" }, + { "name": "Photos", "path": "/Photos", "id": "id:def456" } + ], + "path": "/", + "has_more": false +} +``` + +### POST /api/onedrive/list-folders + +List folders in a OneDrive account. Requires a short-lived OAuth access token obtained during the authorization flow. + +**Request (form-data):** + +| Field | Type | Required | Description | +|----------------|--------|----------|--------------------------------------| +| `access_token` | string | Yes | Microsoft Graph access token | +| `path` | string | No | Folder path to list (default: root) | + +**Response (200):** +```json +{ + "folders": [ + { "name": "Documents", "path": "/Documents", "id": "abc123", "child_count": 5 }, + { "name": "Pictures", "path": "/Pictures", "id": "def456", "child_count": 12 } + ], + "path": "/" +} +``` + ## Webhooks Manage webhook configurations for notifying external systems when document events occur. All webhook endpoints require admin access. diff --git a/docs/DropboxSetup.md b/docs/DropboxSetup.md index b0e44852..39989735 100644 --- a/docs/DropboxSetup.md +++ b/docs/DropboxSetup.md @@ -28,9 +28,10 @@ End users authorize their own Dropbox integration from the **Integrations** dash 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a Dropbox destination integration (or a Watch Folder with `source_type = dropbox`). 3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Dropbox App Key and App Secret in the wizard (or use the global admin credentials if pre-configured). +4. If the administrator has configured system-wide Dropbox app credentials (`DROPBOX_APP_KEY` / `DROPBOX_APP_SECRET`), the wizard defaults to using them — no need to register your own Dropbox app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow**, authorize access in Dropbox, and the refresh token is automatically saved to your personal integration record. -6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. +6. After authorization, an interactive **folder browser** lets you select the target folder directly from your Dropbox — no need to manually type folder paths. +7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. > **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple Dropbox integrations with independent tokens. diff --git a/docs/GoogleDriveSetup.md b/docs/GoogleDriveSetup.md index e85addcc..bd0d59d5 100644 --- a/docs/GoogleDriveSetup.md +++ b/docs/GoogleDriveSetup.md @@ -30,7 +30,7 @@ End users can authorize their own Google Drive integration directly from the **I 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a Google Drive destination integration (or a Watch Folder with `source_type = google_drive`). 3. Click the **Authorize** button — it opens the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Google OAuth Client ID and Client Secret in the wizard. +4. If the administrator has configured system-wide Google Drive app credentials (`GOOGLE_DRIVE_CLIENT_ID` / `GOOGLE_DRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Google Cloud app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow** and authorize access in Google. 6. Credentials are saved automatically to your personal integration record; the page redirects back to `/integrations`. 7. Re-authorization is available at any time via the **Re-Authorize** button. diff --git a/docs/OneDriveSetup.md b/docs/OneDriveSetup.md index e9a555f4..ba2fc873 100644 --- a/docs/OneDriveSetup.md +++ b/docs/OneDriveSetup.md @@ -29,9 +29,10 @@ End users authorize their own OneDrive integration from the **Integrations** das 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a OneDrive destination integration (or a Watch Folder with `source_type = onedrive`). 3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Azure AD Client ID and Client Secret in the wizard. +4. If the administrator has configured system-wide OneDrive app credentials (`ONEDRIVE_CLIENT_ID` / `ONEDRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Azure AD app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow**, authorize access via Microsoft, and the refresh token is automatically saved to your personal integration record. -6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. +6. After authorization, an interactive **folder browser** lets you select the target folder directly from your OneDrive — no need to manually type folder paths. +7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. > **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple OneDrive integrations with independent tokens. diff --git a/frontend/templates/dropbox_callback.html b/frontend/templates/dropbox_callback.html index 5b2efd3f..bedddc70 100644 --- a/frontend/templates/dropbox_callback.html +++ b/frontend/templates/dropbox_callback.html @@ -59,6 +59,40 @@ + + +

Configuration for Worker Nodes

@@ -254,18 +288,21 @@ document.addEventListener('DOMContentLoaded', function() { } return response.json(); }).then(() => { - // Clean up + // Clean up session storage sessionStorage.removeItem('dropbox_app_key'); sessionStorage.removeItem('dropbox_app_secret'); sessionStorage.removeItem('dropbox_folder_path'); sessionStorage.removeItem('oauth_integration_id'); + sessionStorage.removeItem('dropbox_use_system_creds'); - // Show brief success then redirect to integrations - document.getElementById('processing-message').innerHTML = - '

✓ Dropbox authorized successfully!

' + - '

Redirecting to Integrations...

'; + // Hide processing spinner, show success document.querySelector('.animate-spin').parentNode.classList.add('hidden'); - setTimeout(() => { window.location.href = '/integrations'; }, 2000); + document.getElementById('processing-message').innerHTML = + '

✓ Dropbox authorized successfully!

'; + document.getElementById('success-container').classList.remove('hidden'); + + // Show folder browser with the access token + initFolderBrowser(data.access_token, integrationId); }); } @@ -360,6 +397,128 @@ DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`; }); } } + + // ── Folder browser ──────────────────────────────────────────────── + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + function initFolderBrowser(accessToken, integrationId) { + const folderSelector = document.getElementById('folder-selector'); + if (!folderSelector || !integrationId) return; + + folderSelector.classList.remove('hidden'); + let currentPath = ''; + + const folderList = document.getElementById('folder-list'); + const breadcrumb = document.getElementById('folder-breadcrumb'); + const selectedInput = document.getElementById('selected-folder-path'); + const saveBtn = document.getElementById('save-folder-btn'); + const saveStatus = document.getElementById('folder-save-status'); + + function loadFolders(path) { + currentPath = path; + folderList.innerHTML = '

Loading folders…

'; + + const formData = new FormData(); + formData.append('access_token', accessToken); + formData.append('path', path); + + fetch('/api/dropbox/list-folders', { method: 'POST', body: formData }) + .then(r => r.json()) + .then(data => { + if (data.folders && data.folders.length > 0) { + folderList.innerHTML = data.folders.map(f => + `` + ).join(''); + + folderList.querySelectorAll('.folder-item').forEach(btn => { + btn.addEventListener('click', () => { + const p = btn.getAttribute('data-path'); + selectedInput.value = p; + loadFolders(p); + }); + }); + } else { + folderList.innerHTML = '
No subfolders found
'; + } + updateBreadcrumb(path); + }) + .catch(err => { + folderList.innerHTML = `
Failed to load folders: ${escapeHtml(err.message)}
`; + }); + } + + function updateBreadcrumb(path) { + const parts = path.split('/').filter(Boolean); + let html = ''; + let accumulated = ''; + for (const part of parts) { + accumulated += '/' + part; + html += `/`; + html += ``; + } + breadcrumb.innerHTML = html; + breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + const p = btn.getAttribute('data-path'); + selectedInput.value = p || '/'; + loadFolders(p); + }); + }); + } + + // Save selected folder to integration config + saveBtn.addEventListener('click', () => { + const folderPath = selectedInput.value.trim() || '/'; + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + + // Get the current integration config, update folder_path, then PUT back + fetch(`/api/integrations/${integrationId}`) + .then(r => r.json()) + .then(intg => { + const cfg = intg.config || {}; + // Update the correct folder key based on integration type + if (cfg.source_type) { + cfg.folder_path = folderPath; + } else { + cfg.folder = folderPath; + } + return fetch(`/api/integrations/${integrationId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: cfg }), + }); + }) + .then(r => { + if (!r.ok) throw new Error('Failed to save folder'); + return r.json(); + }) + .then(() => { + saveStatus.textContent = '✓ Folder saved! Redirecting…'; + saveStatus.className = 'mt-2 text-sm text-green-600'; + saveStatus.classList.remove('hidden'); + saveBtn.textContent = 'Saved ✓'; + setTimeout(() => { window.location.href = '/integrations'; }, 1500); + }) + .catch(err => { + saveStatus.textContent = 'Error: ' + err.message; + saveStatus.className = 'mt-2 text-sm text-red-600'; + saveStatus.classList.remove('hidden'); + saveBtn.disabled = false; + saveBtn.textContent = 'Save Folder'; + }); + }); + + // Load root folders initially + loadFolders(''); + } }); {% endblock %} diff --git a/frontend/templates/google_drive.html b/frontend/templates/google_drive.html index 10a9aca9..43b46d73 100644 --- a/frontend/templates/google_drive.html +++ b/frontend/templates/google_drive.html @@ -167,14 +167,31 @@

Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.

-
- - + {% if user_mode and has_system_credentials %} + +
+
+ {% endif %} -
- - +
+
+
+ + +
+ +
+ + +
+
@@ -430,6 +447,9 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }} {% endblock %} diff --git a/tests/test_api_dropbox.py b/tests/test_api_dropbox.py index 8a9c4456..71139d7b 100644 --- a/tests/test_api_dropbox.py +++ b/tests/test_api_dropbox.py @@ -419,6 +419,149 @@ class TestSaveDropboxSettings: @pytest.mark.unit +class TestListDropboxFolders: + """Tests for list_dropbox_folders endpoint.""" + + @patch("app.api.dropbox.requests.post") + def test_list_folders_success(self, mock_post, client): + """Test successful folder listing at root.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "entries": [ + {".tag": "folder", "name": "Documents", "path_display": "/Documents", "id": "id:1"}, + {".tag": "folder", "name": "Photos", "path_display": "/Photos", "id": "id:2"}, + {".tag": "file", "name": "readme.txt", "path_display": "/readme.txt", "id": "id:3"}, + ], + "has_more": False, + } + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["folders"]) == 2 + assert data["folders"][0]["name"] == "Documents" + assert data["folders"][1]["name"] == "Photos" + assert data["path"] == "/" + assert data["has_more"] is False + + @patch("app.api.dropbox.requests.post") + def test_list_folders_subfolder(self, mock_post, client): + """Test listing folders in a subfolder.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "entries": [ + {".tag": "folder", "name": "Invoices", "path_display": "/Documents/Invoices", "id": "id:4"}, + ], + "has_more": False, + } + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": "/Documents"}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["folders"]) == 1 + assert data["folders"][0]["path"] == "/Documents/Invoices" + + @patch("app.api.dropbox.requests.post") + def test_list_folders_empty(self, mock_post, client): + """Test listing folders in an empty directory.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"entries": [], "has_more": False} + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": "/EmptyFolder"}, + ) + + assert response.status_code == 200 + assert len(response.json()["folders"]) == 0 + + @patch("app.api.dropbox.requests.post") + def test_list_folders_unauthorized(self, mock_post, client): + """Test listing folders with invalid token returns 401.""" + mock_response = Mock() + mock_response.status_code = 401 + mock_response.text = "Invalid access token" + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "bad-token", "path": ""}, + ) + + assert response.status_code == 401 + + @patch("app.api.dropbox.requests.post") + def test_list_folders_api_error(self, mock_post, client): + """Test listing folders when Dropbox API returns an error.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal server error" + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 502 + + @patch("app.api.dropbox.requests.post") + def test_list_folders_root_path_normalization(self, mock_post, client): + """Test that '/' is normalized to empty string for Dropbox API.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"entries": [], "has_more": False} + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": "/"}, + ) + + assert response.status_code == 200 + # Check the actual API call used empty string for root + call_args = mock_post.call_args + assert call_args[1]["json"]["path"] == "" + + @patch("app.api.dropbox.requests.post") + def test_list_folders_sorted_alphabetically(self, mock_post, client): + """Test that folders are returned in alphabetical order.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "entries": [ + {".tag": "folder", "name": "Zebra", "path_display": "/Zebra", "id": "id:1"}, + {".tag": "folder", "name": "Alpha", "path_display": "/Alpha", "id": "id:2"}, + {".tag": "folder", "name": "middle", "path_display": "/middle", "id": "id:3"}, + ], + "has_more": False, + } + mock_post.return_value = mock_response + + response = client.post( + "/api/dropbox/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + names = [f["name"] for f in response.json()["folders"]] + assert names == ["Alpha", "middle", "Zebra"] + + class TestBuildDropboxRedirectUri: """Tests for the _build_dropbox_redirect_uri helper.""" diff --git a/tests/test_api_onedrive_comprehensive.py b/tests/test_api_onedrive_comprehensive.py index 1019031b..f46fb397 100644 --- a/tests/test_api_onedrive_comprehensive.py +++ b/tests/test_api_onedrive_comprehensive.py @@ -695,3 +695,182 @@ class TestOneDriveIntegration: # Verify env format is present (exact values may vary) assert "env_format" in config_data + + +@pytest.mark.unit +class TestListOneDriveFolders: + """Tests for list_onedrive_folders endpoint.""" + + @patch("app.api.onedrive.requests.get") + def test_list_folders_success(self, mock_get, client): + """Test successful folder listing at root.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "name": "Documents", + "id": "id:1", + "folder": {"childCount": 3}, + "parentReference": {"path": "/drive/root:"}, + }, + { + "name": "Pictures", + "id": "id:2", + "folder": {"childCount": 10}, + "parentReference": {"path": "/drive/root:"}, + }, + ], + } + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["folders"]) == 2 + assert data["folders"][0]["name"] == "Documents" + assert data["folders"][0]["path"] == "/Documents" + assert data["folders"][1]["name"] == "Pictures" + assert data["path"] == "/" + + @patch("app.api.onedrive.requests.get") + def test_list_folders_subfolder(self, mock_get, client): + """Test listing folders in a subfolder.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "name": "Invoices", + "id": "id:3", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root:/Documents"}, + }, + ], + } + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": "Documents"}, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["folders"]) == 1 + assert data["folders"][0]["path"] == "/Documents/Invoices" + assert data["path"] == "/Documents" + + @patch("app.api.onedrive.requests.get") + def test_list_folders_empty(self, mock_get, client): + """Test listing folders in an empty directory.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"value": []} + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": "EmptyFolder"}, + ) + + assert response.status_code == 200 + assert len(response.json()["folders"]) == 0 + + @patch("app.api.onedrive.requests.get") + def test_list_folders_unauthorized(self, mock_get, client): + """Test listing folders with invalid token returns 401.""" + mock_response = Mock() + mock_response.status_code = 401 + mock_response.text = "Invalid access token" + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "bad-token", "path": ""}, + ) + + assert response.status_code == 401 + + @patch("app.api.onedrive.requests.get") + def test_list_folders_api_error(self, mock_get, client): + """Test listing folders when Graph API returns an error.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal server error" + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 502 + + @patch("app.api.onedrive.requests.get") + def test_list_folders_sorted_alphabetically(self, mock_get, client): + """Test that folders are returned in alphabetical order.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "name": "Zebra", + "id": "id:1", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root:"}, + }, + { + "name": "Alpha", + "id": "id:2", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root:"}, + }, + { + "name": "middle", + "id": "id:3", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root:"}, + }, + ], + } + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + names = [f["name"] for f in response.json()["folders"]] + assert names == ["Alpha", "middle", "Zebra"] + + @patch("app.api.onedrive.requests.get") + def test_list_folders_root_drive_parent(self, mock_get, client): + """Test folder path construction when parentReference.path is /drive/root.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "value": [ + { + "name": "TopLevel", + "id": "id:1", + "folder": {"childCount": 0}, + "parentReference": {"path": "/drive/root"}, + }, + ], + } + mock_get.return_value = mock_response + + response = client.post( + "/api/onedrive/list-folders", + data={"access_token": "test-token", "path": ""}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["folders"][0]["path"] == "/TopLevel"