From dc926cc8da91600e670e5c18cc93d18c7745369e Mon Sep 17 00:00:00 2001 From: b3nw Date: Mon, 25 May 2026 00:36:54 +0000 Subject: [PATCH] feat: add binary file attachment and import tools for Outline LTM system (#1) Implement 3 new MCP tools: - attach_file_to_document(document_id, file_path) - upload_image_to_document(image_path, document_id, alt_text) - import_file_to_outline(file_path, collection_id, parent_document_id) Security: - Restrict file access to /tmp via _validate_file_path with realpath - 50MB max file size enforced client-side - Symlink traversal blocked Technical: - Extract shared _upload_attachment() helper - Stream files to presigned URLs instead of loading into memory - Add combined lifespan to close OutlineClient on shutdown - Update CI workflow with modern action versions and PR triggers Tests: - Add 28 tests covering path validation, size limits, upload flow, error handling, symlink traversal, and multipart imports --- .gitea/workflows/build.yaml | 25 ++- .gitignore | 5 + IMPLEMENTATION.md | 41 ++++ server.py | 248 ++++++++++++++++++++- tests/test_server.py | 434 ++++++++++++++++++++++++++++++++++++ 5 files changed, 749 insertions(+), 4 deletions(-) create mode 100644 .gitignore create mode 100644 tests/test_server.py diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 6421559..672865f 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -5,6 +5,10 @@ on: branches: - main - master + pull_request: + branches: + - main + - master jobs: build: @@ -13,17 +17,32 @@ jobs: image: catthehacker/ubuntu:act-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install uv + uv sync --extra dev + + - name: Run tests + run: uv run pytest tests/ -v - name: Login to Gitea Container Registry - uses: docker/login-action@v2 + if: github.event_name == 'push' + uses: docker/login-action@v3 with: registry: gitea.ext.ben.io username: ${{ gitea.actor }} password: ${{ secrets.CR_PAT }} - name: Build and Push - uses: docker/build-push-action@v4 + if: github.event_name == 'push' + uses: docker/build-push-action@v5 with: context: . push: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b60c2fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv/ +__pycache__/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 0159820..819896f 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -46,6 +46,27 @@ Params: method (required), params (JSON string) Returns: Raw API response as JSON string ``` +### attach_file_to_document +Upload a local file as an attachment to an existing Outline document. +``` +Params: document_id (required), file_path (required — must be under /tmp) +Returns: JSON with attachment_url, markdown embed string, name, size, content_type +``` + +### upload_image_to_document +Upload an image file as an attachment and return Markdown image embed syntax. +``` +Params: image_path (required — must be under /tmp), document_id (required), alt_text (optional) +Returns: JSON with attachment_url, markdown image embed, name, size, content_type +``` + +### import_file_to_outline +Import a local file (Markdown, text, JSON, CSV) as a new Outline document. +``` +Params: file_path (required — must be under /tmp), collection_id (required), parent_document_id (optional) +Returns: JSON with newly created document metadata +``` + --- ## Outline API Reference @@ -384,3 +405,23 @@ params: {"invites": [{"email": "user@example.com", "name": "User Name", "role": method: "collections.create" params: {"name": "Engineering", "description": "Engineering documentation", "permission": "read_write"} ``` + +### Attach a file to a document +``` +document_id: "document-uuid" +file_path: "/tmp/report.pdf" +``` + +### Upload an image to a document +``` +image_path: "/tmp/screenshot.png" +document_id: "document-uuid" +alt_text: "System architecture diagram" +``` + +### Import a Markdown file as a new document +``` +file_path: "/tmp/notes.md" +collection_id: "collection-uuid" +parent_document_id: "parent-document-uuid" # optional +``` diff --git a/server.py b/server.py index b2182b4..ff164a3 100644 --- a/server.py +++ b/server.py @@ -1,9 +1,11 @@ import os import json import logging +import mimetypes from typing import Any import httpx from fastmcp import FastMCP +from contextlib import asynccontextmanager from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route, Mount @@ -20,6 +22,29 @@ logger = logging.getLogger(__name__) OUTLINE_API_URL = os.getenv("OUTLINE_API_URL", "").rstrip("/") OUTLINE_API_TOKEN = os.getenv("OUTLINE_API_TOKEN", "") +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB +ALLOWED_PATH_PREFIX = "/tmp" + + +def _validate_file_path(file_path: str) -> str: + """Validate that file_path is within ALLOWED_PATH_PREFIX and exists.""" + abs_path = os.path.abspath(file_path) + real_path = os.path.realpath(abs_path) + allowed_real = os.path.realpath(ALLOWED_PATH_PREFIX) + if not (real_path == allowed_real or real_path.startswith(allowed_real + os.sep)): + raise ValueError(f"File path must be within {ALLOWED_PATH_PREFIX}") + if not os.path.exists(real_path): + raise ValueError(f"File not found: {file_path}") + if not os.path.isfile(real_path): + raise ValueError(f"Path is not a file: {file_path}") + return real_path + + +def _get_content_type(file_path: str) -> str | None: + """Guess content type from file extension.""" + content_type, _ = mimetypes.guess_type(file_path) + return content_type + class OutlineClient: """HTTP client for Outline API with authentication.""" @@ -87,6 +112,61 @@ class OutlineClient: except Exception as e: return False, str(e) + async def upload_to_presigned_url( + self, upload_url: str, file_path: str, content_type: str + ) -> dict: + """Upload file bytes to a presigned URL (e.g., S3).""" + try: + with open(file_path, "rb") as f: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.put( + upload_url, + content=f, + headers={"Content-Type": content_type}, + ) + response.raise_for_status() + return {"ok": True} + except httpx.HTTPStatusError as e: + return { + "ok": False, + "error": f"Upload failed: {e.response.text}", + "status": e.response.status_code, + } + except Exception as e: + return {"ok": False, "error": f"Upload failed: {str(e)}"} + + async def call_multipart( + self, method: str, file_path: str, fields: dict + ) -> dict: + """Execute a multipart Outline API call for file upload endpoints.""" + try: + filename = os.path.basename(file_path) + content_type = _get_content_type(file_path) or "application/octet-stream" + with open(file_path, "rb") as f: + files = {"file": (filename, f, content_type)} + response = await self.client.post( + f"/api/{method}", files=files, data=fields + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + error_body = e.response.text + try: + error_json = e.response.json() + return { + "ok": False, + "error": error_json.get("message", str(e)), + "status": e.response.status_code, + } + except Exception: + return { + "ok": False, + "error": error_body or str(e), + "status": e.response.status_code, + } + except Exception as e: + return {"ok": False, "error": f"Request failed: {str(e)}"} + async def close(self): if self._client and not self._client.is_closed: await self._client.aclose() @@ -224,6 +304,159 @@ async def outline_api_call(method: str, params: Any = None) -> str: return json.dumps(result, indent=2) +async def _upload_attachment(document_id: str, file_path: str) -> dict: + """Shared helper: validate file, create attachment, upload to presigned URL. + + Returns a dict with keys: ok, attachment_url, name, size, content_type + or ok=False, error. + """ + try: + validated_path = _validate_file_path(file_path) + except ValueError as e: + return {"ok": False, "error": str(e)} + + file_size = os.path.getsize(validated_path) + if file_size > MAX_FILE_SIZE: + return { + "ok": False, + "error": f"File exceeds 50MB limit ({file_size} bytes)", + } + + content_type = _get_content_type(validated_path) or "application/octet-stream" + filename = os.path.basename(validated_path) + + create_result = await outline_client.call( + "attachments.create", + { + "name": filename, + "documentId": document_id, + "size": file_size, + "contentType": content_type, + }, + ) + + if not create_result.get("ok"): + return create_result + + data = create_result.get("data", {}) + upload_url = data.get("uploadUrl") + attachment_url = data.get("url") or data.get("attachmentUrl") + + if not upload_url: + return { + "ok": False, + "error": "No uploadUrl returned from attachments.create", + } + + upload_result = await outline_client.upload_to_presigned_url( + upload_url, validated_path, content_type + ) + + if not upload_result.get("ok"): + return upload_result + + if not attachment_url: + return { + "ok": False, + "error": "Attachment created and uploaded, but no public URL was returned", + } + + return { + "ok": True, + "attachment_url": attachment_url, + "name": filename, + "size": file_size, + "content_type": content_type, + } + + +@mcp.tool() +async def attach_file_to_document(document_id: str, file_path: str) -> str: + """Upload a local file as an attachment to an existing Outline document. + + The file must be located under /tmp. Files larger than 50MB are rejected. + + Args: + document_id: The document UUID to attach the file to + file_path: Absolute path to the file (must be under /tmp) + + Returns: + JSON string with attachment URL and Markdown ready for insertion + """ + result = await _upload_attachment(document_id, file_path) + if not result.get("ok"): + return json.dumps(result, indent=2) + + result["markdown"] = f"[{result['name']}]({result['attachment_url']})" + return json.dumps(result, indent=2) + + +@mcp.tool() +async def upload_image_to_document( + image_path: str, document_id: str, alt_text: str = None +) -> str: + """Upload an image file as an attachment and return Markdown embed syntax. + + The file must be located under /tmp. Files larger than 50MB are rejected. + + Args: + image_path: Absolute path to the image (must be under /tmp) + document_id: The document UUID to attach the image to + alt_text: Optional alt text for the image (defaults to filename) + + Returns: + JSON string with image URL and Markdown embed ready for insertion + """ + result = await _upload_attachment(document_id, image_path) + if not result.get("ok"): + return json.dumps(result, indent=2) + + alt = alt_text or result["name"] + result["markdown"] = f"![{alt}]({result['attachment_url']})" + return json.dumps(result, indent=2) + + +@mcp.tool() +async def import_file_to_outline( + file_path: str, collection_id: str, parent_document_id: str = None +) -> str: + """Import a local file (Markdown, text, JSON, CSV) as a new Outline document. + + The file must be located under /tmp. Files larger than 50MB are rejected. + + Args: + file_path: Absolute path to the file (must be under /tmp) + collection_id: The collection UUID to create the document in + parent_document_id: Optional parent document UUID for nesting + + Returns: + JSON string with the newly created document metadata + """ + try: + validated_path = _validate_file_path(file_path) + except ValueError as e: + return json.dumps({"ok": False, "error": str(e)}, indent=2) + + file_size = os.path.getsize(validated_path) + if file_size > MAX_FILE_SIZE: + return json.dumps( + { + "ok": False, + "error": f"File exceeds 50MB limit ({file_size} bytes)", + }, + indent=2, + ) + + fields = {"collectionId": collection_id} + if parent_document_id: + fields["parentDocumentId"] = parent_document_id + + result = await outline_client.call_multipart( + "documents.import", validated_path, fields + ) + return json.dumps(result, indent=2) + + # --- API Reference Resource --- API_REFERENCE = """# Outline API Reference @@ -553,17 +786,30 @@ async def health(request): return JSONResponse(response) +@asynccontextmanager +async def app_lifespan(app): + """Manage application lifespan, closing the Outline client on shutdown.""" + yield + await outline_client.close() + + # --- ASGI Application --- def create_app(): """Create the ASGI application with health check and MCP routes.""" mcp_app = mcp.http_app() + @asynccontextmanager + async def combined_lifespan(app): + async with mcp_app.lifespan(app): + async with app_lifespan(app): + yield + routes = [ Route("/health", health, methods=["GET"]), Mount("/", app=mcp_app), ] - return Starlette(routes=routes, lifespan=mcp_app.lifespan) + return Starlette(routes=routes, lifespan=combined_lifespan) # Create the app instance for uvicorn diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..db22bf1 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,434 @@ +import os +import sys +import json +import pytest +import tempfile +from unittest.mock import AsyncMock, MagicMock, patch + +# Set env vars before importing server +os.environ["OUTLINE_API_URL"] = "https://test.example.com" +os.environ["OUTLINE_API_TOKEN"] = "test-token" + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from server import ( + _validate_file_path, + _get_content_type, + OutlineClient, + attach_file_to_document, + upload_image_to_document, + import_file_to_outline, + outline_client, + MAX_FILE_SIZE, +) + + +class TestValidateFilePath: + def test_valid_file_under_tmp(self, tmp_path): + test_file = tmp_path / "test.txt" + test_file.write_text("hello") + result = _validate_file_path(str(test_file)) + assert result == str(test_file) + + def test_path_outside_tmp_rejected(self): + with pytest.raises(ValueError, match="must be within"): + _validate_file_path("/etc/passwd") + + def test_path_traversal_rejected(self): + with pytest.raises(ValueError, match="must be within"): + _validate_file_path("/tmp/../../etc/passwd") + + def test_nonexistent_file_rejected(self, tmp_path): + with pytest.raises(ValueError, match="File not found"): + _validate_file_path(str(tmp_path / "nonexistent.txt")) + + def test_directory_rejected(self, tmp_path): + with pytest.raises(ValueError, match="not a file"): + _validate_file_path(str(tmp_path)) + + def test_symlink_traversal_rejected(self, tmp_path): + evil_link = tmp_path / "evil" + evil_link.symlink_to("/etc/passwd") + with pytest.raises(ValueError, match="must be within"): + _validate_file_path(str(evil_link)) + + +class TestGetContentType: + def test_known_image_types(self): + assert _get_content_type("test.png") == "image/png" + assert _get_content_type("test.jpg") == "image/jpeg" + assert _get_content_type("test.jpeg") == "image/jpeg" + + def test_known_document_types(self): + assert _get_content_type("test.pdf") == "application/pdf" + assert _get_content_type("test.md") == "text/markdown" + assert _get_content_type("test.txt") == "text/plain" + + def test_unknown_type_returns_none(self): + assert _get_content_type("test.unknownxyz") is None + + +@pytest.mark.asyncio +class TestOutlineClientUpload: + async def test_upload_to_presigned_url_success(self): + client = OutlineClient("https://api.example.com", "token") + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.put = AsyncMock(return_value=mock_response) + mock_client_class.return_value.__aenter__ = AsyncMock( + return_value=mock_client + ) + mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False + ) as f: + f.write("test content") + temp_path = f.name + + try: + result = await client.upload_to_presigned_url( + "https://upload.example.com", temp_path, "text/plain" + ) + assert result["ok"] is True + finally: + os.unlink(temp_path) + + async def test_upload_to_presigned_url_http_error(self): + client = OutlineClient("https://api.example.com", "token") + + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "Forbidden" + from httpx import HTTPStatusError + + mock_response.raise_for_status.side_effect = HTTPStatusError( + "Forbidden", request=MagicMock(), response=mock_response + ) + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.put = AsyncMock(return_value=mock_response) + mock_client_class.return_value.__aenter__ = AsyncMock( + return_value=mock_client + ) + mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False + ) as f: + f.write("test content") + temp_path = f.name + + try: + result = await client.upload_to_presigned_url( + "https://upload.example.com", temp_path, "text/plain" + ) + assert result["ok"] is False + assert result["status"] == 403 + finally: + os.unlink(temp_path) + + async def test_call_multipart_success(self): + client = OutlineClient("https://api.example.com", "token") + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json = MagicMock( + return_value={"ok": True, "data": {"id": "doc-123"}} + ) + mock_response.status_code = 200 + + client._client = MagicMock() + client._client.post = AsyncMock(return_value=mock_response) + client._client.is_closed = False + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False + ) as f: + f.write("test content") + temp_path = f.name + + try: + result = await client.call_multipart( + "documents.import", temp_path, {"collectionId": "col-123"} + ) + assert result["ok"] is True + assert result["data"]["id"] == "doc-123" + finally: + os.unlink(temp_path) + + async def test_call_multipart_http_error(self): + client = OutlineClient("https://api.example.com", "token") + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = '{"message": "Bad request"}' + mock_response.json = MagicMock(return_value={"message": "Bad request"}) + from httpx import HTTPStatusError + + mock_response.raise_for_status.side_effect = HTTPStatusError( + "Bad request", request=MagicMock(), response=mock_response + ) + + client._client = MagicMock() + client._client.post = AsyncMock(return_value=mock_response) + client._client.is_closed = False + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False + ) as f: + f.write("test content") + temp_path = f.name + + try: + result = await client.call_multipart( + "documents.import", temp_path, {"collectionId": "col-123"} + ) + assert result["ok"] is False + assert result["error"] == "Bad request" + assert result["status"] == 400 + finally: + os.unlink(temp_path) + + +@pytest.mark.asyncio +class TestAttachFileToDocument: + async def test_path_validation_failure(self): + result = await attach_file_to_document("doc-123", "/etc/passwd") + parsed = json.loads(result) + assert parsed["ok"] is False + assert "must be within" in parsed["error"] + + async def test_size_limit_failure(self, tmp_path, monkeypatch): + test_file = tmp_path / "large.bin" + test_file.write_bytes(b"x" * 100) + monkeypatch.setattr("server.MAX_FILE_SIZE", 50) + + result = await attach_file_to_document("doc-123", str(test_file)) + parsed = json.loads(result) + assert parsed["ok"] is False + assert "exceeds 50MB" in parsed["error"] + + async def test_successful_attachment(self, tmp_path, monkeypatch): + test_file = tmp_path / "test.txt" + test_file.write_text("hello world") + + async def mock_call(method, params): + assert method == "attachments.create" + return { + "ok": True, + "data": { + "uploadUrl": "https://presigned.example.com/upload", + "url": "https://cdn.example.com/attachment.txt", + }, + } + + async def mock_upload(url, path, ct): + assert url == "https://presigned.example.com/upload" + return {"ok": True} + + monkeypatch.setattr(outline_client, "call", mock_call) + monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload) + + result = await attach_file_to_document("doc-123", str(test_file)) + parsed = json.loads(result) + assert parsed["ok"] is True + assert parsed["name"] == "test.txt" + assert parsed["markdown"] == "[test.txt](https://cdn.example.com/attachment.txt)" + + async def test_attachment_create_failure(self, tmp_path, monkeypatch): + test_file = tmp_path / "test.txt" + test_file.write_text("hello") + + async def mock_call(method, params): + return {"ok": False, "error": "Document not found"} + + monkeypatch.setattr(outline_client, "call", mock_call) + + result = await attach_file_to_document("doc-123", str(test_file)) + parsed = json.loads(result) + assert parsed["ok"] is False + assert "Document not found" in parsed["error"] + + async def test_upload_failure(self, tmp_path, monkeypatch): + test_file = tmp_path / "test.txt" + test_file.write_text("hello") + + async def mock_call(method, params): + return { + "ok": True, + "data": {"uploadUrl": "https://presigned.example.com/upload"}, + } + + async def mock_upload(url, path, ct): + return {"ok": False, "error": "Upload rejected", "status": 413} + + monkeypatch.setattr(outline_client, "call", mock_call) + monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload) + + result = await attach_file_to_document("doc-123", str(test_file)) + parsed = json.loads(result) + assert parsed["ok"] is False + assert parsed["error"] == "Upload rejected" + + async def test_missing_attachment_url(self, tmp_path, monkeypatch): + """If Outline returns uploadUrl but no public url, we should error.""" + test_file = tmp_path / "test.txt" + test_file.write_text("hello") + + async def mock_call(method, params): + return { + "ok": True, + "data": {"uploadUrl": "https://presigned.example.com/upload"}, + } + + async def mock_upload(url, path, ct): + return {"ok": True} + + monkeypatch.setattr(outline_client, "call", mock_call) + monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload) + + result = await attach_file_to_document("doc-123", str(test_file)) + parsed = json.loads(result) + assert parsed["ok"] is False + assert "no public URL" in parsed["error"] + + +@pytest.mark.asyncio +class TestUploadImageToDocument: + async def test_path_validation_failure(self): + result = await upload_image_to_document("/etc/passwd", "doc-123") + parsed = json.loads(result) + assert parsed["ok"] is False + + async def test_size_limit_failure(self, tmp_path, monkeypatch): + test_file = tmp_path / "large.png" + test_file.write_bytes(b"x" * 100) + monkeypatch.setattr("server.MAX_FILE_SIZE", 50) + + result = await upload_image_to_document(str(test_file), "doc-123") + parsed = json.loads(result) + assert parsed["ok"] is False + assert "exceeds 50MB" in parsed["error"] + + async def test_successful_image_upload(self, tmp_path, monkeypatch): + test_file = tmp_path / "test.png" + test_file.write_text("fake png content") + + async def mock_call(method, params): + return { + "ok": True, + "data": { + "uploadUrl": "https://presigned.example.com/upload", + "url": "https://cdn.example.com/image.png", + }, + } + + async def mock_upload(url, path, ct): + return {"ok": True} + + monkeypatch.setattr(outline_client, "call", mock_call) + monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload) + + result = await upload_image_to_document(str(test_file), "doc-123", "My Alt") + parsed = json.loads(result) + assert parsed["ok"] is True + assert parsed["markdown"] == "![My Alt](https://cdn.example.com/image.png)" + + async def test_default_alt_text(self, tmp_path, monkeypatch): + test_file = tmp_path / "screenshot.png" + test_file.write_text("fake png content") + + async def mock_call(method, params): + return { + "ok": True, + "data": { + "uploadUrl": "https://presigned.example.com/upload", + "url": "https://cdn.example.com/image.png", + }, + } + + async def mock_upload(url, path, ct): + return {"ok": True} + + monkeypatch.setattr(outline_client, "call", mock_call) + monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload) + + result = await upload_image_to_document(str(test_file), "doc-123") + parsed = json.loads(result) + assert parsed["ok"] is True + assert parsed["markdown"] == "![screenshot.png](https://cdn.example.com/image.png)" + + +@pytest.mark.asyncio +class TestImportFileToOutline: + async def test_path_validation_failure(self): + result = await import_file_to_outline("/etc/passwd", "col-123") + parsed = json.loads(result) + assert parsed["ok"] is False + assert "must be within" in parsed["error"] + + async def test_size_limit_failure(self, tmp_path, monkeypatch): + test_file = tmp_path / "large.md" + test_file.write_bytes(b"x" * 100) + monkeypatch.setattr("server.MAX_FILE_SIZE", 50) + + result = await import_file_to_outline(str(test_file), "col-123") + parsed = json.loads(result) + assert parsed["ok"] is False + assert "exceeds 50MB" in parsed["error"] + + async def test_successful_import(self, tmp_path, monkeypatch): + test_file = tmp_path / "notes.md" + test_file.write_text("# My Notes\n\nHello world") + + async def mock_call_multipart(method, path, fields): + assert method == "documents.import" + assert fields["collectionId"] == "col-123" + return {"ok": True, "data": {"id": "doc-456", "url": "https://outline.example.com/doc/doc-456"}} + + monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart) + + result = await import_file_to_outline(str(test_file), "col-123") + parsed = json.loads(result) + assert parsed["ok"] is True + assert parsed["data"]["id"] == "doc-456" + + async def test_import_with_parent_document(self, tmp_path, monkeypatch): + test_file = tmp_path / "notes.md" + test_file.write_text("# Child Doc") + + captured_fields = {} + + async def mock_call_multipart(method, path, fields): + captured_fields.update(fields) + return {"ok": True, "data": {"id": "doc-789"}} + + monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart) + + result = await import_file_to_outline( + str(test_file), "col-123", parent_document_id="parent-999" + ) + parsed = json.loads(result) + assert parsed["ok"] is True + assert captured_fields["parentDocumentId"] == "parent-999" + + async def test_import_failure(self, tmp_path, monkeypatch): + test_file = tmp_path / "notes.md" + test_file.write_text("# Notes") + + async def mock_call_multipart(method, path, fields): + return {"ok": False, "error": "Collection not found"} + + monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart) + + result = await import_file_to_outline(str(test_file), "col-123") + parsed = json.loads(result) + assert parsed["ok"] is False + assert "Collection not found" in parsed["error"]