diff --git a/.github/workflows/rag-evaluation.yml b/.github/workflows/rag-evaluation.yml
index d24e494..ad03d06 100644
--- a/.github/workflows/rag-evaluation.yml
+++ b/.github/workflows/rag-evaluation.yml
@@ -3,6 +3,10 @@ name: RAG Evaluation
on:
workflow_dispatch:
inputs:
+ manual_path:
+ description: 'Path to Nextcloud User Manual PDF in Nextcloud'
+ required: false
+ default: 'Nextcloud Manual.pdf'
embedding_model:
description: 'OpenAI embedding model'
required: false
@@ -15,7 +19,7 @@ on:
jobs:
rag-evaluation:
runs-on: ubuntu-latest
- timeout-minutes: 45
+ timeout-minutes: 30
permissions:
models: read
@@ -24,33 +28,6 @@ jobs:
with:
submodules: 'true'
- - name: Clone Nextcloud documentation
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- with:
- repository: 'nextcloud/documentation'
- path: 'nextcloud-docs'
-
- - name: Install Sphinx and LaTeX dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- python3-sphinx \
- python3-pip \
- latexmk \
- texlive-latex-recommended \
- texlive-latex-extra \
- texlive-fonts-recommended \
- texlive-fonts-extra
-
- - name: Build User Manual PDF
- run: |
- cd nextcloud-docs/user_manual
- pip3 install -r ../requirements.txt
- make latexpdf
- ls -la _build/latex/
- cp _build/latex/NextcloudUserManual.pdf ../../Nextcloud_User_Manual.pdf
- echo "PDF built successfully"
-
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
@@ -113,149 +90,12 @@ jobs:
done
echo "MCP server is ready."
- - name: Upload User Manual PDF to Nextcloud
- run: |
- echo "Uploading Nextcloud_User_Manual.pdf to Nextcloud..."
- HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u admin:admin \
- -X PUT \
- -T Nextcloud_User_Manual.pdf \
- "http://localhost:8080/remote.php/dav/files/admin/Nextcloud_User_Manual.pdf")
-
- if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "204" ]; then
- echo "PDF uploaded successfully (HTTP $HTTP_CODE)"
- else
- echo "Failed to upload PDF (HTTP $HTTP_CODE)"
- exit 1
- fi
-
- - name: Create vector-index tag
- id: create_tag
- run: |
- # Create the tag using OCS API
- echo "Creating vector-index tag..."
- RESPONSE=$(curl -s -u admin:admin \
- -X POST \
- -H 'Content-Type: application/json' \
- -H 'OCS-APIRequest: true' \
- -d '{"name":"vector-index","userVisible":true,"userAssignable":true}' \
- "http://localhost:8080/ocs/v2.php/apps/systemtags/api/v1/tags")
-
- echo "Create tag response: $RESPONSE"
-
- # Get tag ID from response or lookup
- TAG_ID=$(echo "$RESPONSE" | grep -oP '(?<="id":)[0-9]+' | head -1 || echo "")
-
- if [ -z "$TAG_ID" ]; then
- echo "Tag may already exist, looking it up..."
- TAG_ID=$(curl -s -u admin:admin \
- -X PROPFIND \
- -H 'Content-Type: application/xml' \
- -d '' \
- http://localhost:8080/remote.php/dav/systemtags/ \
- | grep -B2 "vector-index" | grep -oP '(?<=)[0-9]+(?=)' | head -1 || echo "")
- fi
-
- if [ -z "$TAG_ID" ]; then
- echo "ERROR: Could not create or find vector-index tag"
- exit 1
- fi
-
- echo "Tag ID: $TAG_ID"
- echo "tag_id=$TAG_ID" >> $GITHUB_OUTPUT
-
- - name: Get file ID of uploaded PDF
- id: get_file_id
- run: |
- echo "Getting file ID for Nextcloud_User_Manual.pdf..."
-
- # Get file ID using PROPFIND
- FILE_ID=$(curl -s -u admin:admin \
- -X PROPFIND \
- -H 'Content-Type: application/xml' \
- -d '' \
- "http://localhost:8080/remote.php/dav/files/admin/Nextcloud_User_Manual.pdf" \
- | grep -oP '(?<=)[0-9]+(?=)' || echo "")
-
- if [ -z "$FILE_ID" ]; then
- echo "ERROR: Could not find file ID"
- exit 1
- fi
-
- echo "Found file ID: $FILE_ID"
- echo "file_id=$FILE_ID" >> $GITHUB_OUTPUT
-
- - name: Tag file with vector-index
- env:
- FILE_ID: ${{ steps.get_file_id.outputs.file_id }}
- TAG_ID: ${{ steps.create_tag.outputs.tag_id }}
- run: |
- echo "Tagging file $FILE_ID with tag $TAG_ID..."
-
- HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u admin:admin \
- -X PUT \
- -H 'Content-Type: application/json' \
- -H 'Content-Length: 0' \
- "http://localhost:8080/remote.php/dav/systemtags-relations/files/$FILE_ID/$TAG_ID")
-
- if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "409" ]; then
- echo "File tagged successfully (HTTP $HTTP_CODE)"
- else
- echo "Failed to tag file (HTTP $HTTP_CODE)"
- exit 1
- fi
-
- - name: Wait for vector sync to complete indexing
- env:
- NEXTCLOUD_HOST: "http://localhost:8080"
- NEXTCLOUD_USERNAME: "admin"
- NEXTCLOUD_PASSWORD: "admin"
- run: |
- echo "Waiting for vector sync to index the manual..."
- max_attempts=60
- attempt=0
-
- # Wait for initial scan to pick up the file
- sleep 10
-
- while [ $attempt -lt $max_attempts ]; do
- attempt=$((attempt + 1))
-
- # Check vector sync status via MCP
- STATUS=$(curl -s http://localhost:8000/health || echo "{}")
- echo "Attempt $attempt/$max_attempts: $STATUS"
-
- # Also check indexed count via semantic search
- # If we get results, indexing is done
- RESULT=$(curl -s -X POST http://localhost:8000/mcp \
- -H "Content-Type: application/json" \
- -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nc_get_vector_sync_status","arguments":{}}}' \
- 2>/dev/null || echo "{}")
-
- echo "Vector sync status: $RESULT"
-
- # Check if pending is 0 and indexed > 0
- INDEXED=$(echo "$RESULT" | jq -r '.result.structuredContent.indexed // 0' 2>/dev/null || echo "0")
- PENDING=$(echo "$RESULT" | jq -r '.result.structuredContent.pending // 1' 2>/dev/null || echo "1")
-
- echo "Indexed: $INDEXED, Pending: $PENDING"
-
- if [ "$INDEXED" -gt "0" ] && [ "$PENDING" -eq "0" ]; then
- echo "Indexing complete! $INDEXED documents indexed."
- break
- fi
-
- sleep 10
- done
-
- if [ $attempt -ge $max_attempts ]; then
- echo "WARNING: Indexing may not be complete, proceeding anyway..."
- fi
-
- name: Run RAG evaluation tests
env:
NEXTCLOUD_HOST: "http://localhost:8080"
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
+ RAG_MANUAL_PATH: ${{ inputs.manual_path }}
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: "https://models.github.ai/inference"
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
diff --git a/nextcloud_mcp_server/client/webdav.py b/nextcloud_mcp_server/client/webdav.py
index 05f27df..5a5f0cd 100644
--- a/nextcloud_mcp_server/client/webdav.py
+++ b/nextcloud_mcp_server/client/webdav.py
@@ -1295,3 +1295,233 @@ class WebDAVClient(BaseNextcloudClient):
logger.debug(f"Found {len(files)} files with tag ID {tag_id}")
return files
+
+ async def get_file_info(self, path: str) -> dict[str, Any] | None:
+ """Get file info including file ID via WebDAV PROPFIND.
+
+ Args:
+ path: Path to the file (relative to user's files directory)
+
+ Returns:
+ File info dictionary with id, name, size, content_type, etc.
+ Returns None if file not found.
+ """
+ webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
+
+ propfind_body = """
+
+
+
+
+
+
+
+
+
+
+"""
+
+ try:
+ response = await self._client.request(
+ "PROPFIND",
+ webdav_path,
+ headers={"Depth": "0"},
+ content=propfind_body,
+ )
+ response.raise_for_status()
+ except HTTPStatusError as e:
+ if e.response.status_code == 404:
+ logger.debug(f"File not found: {path}")
+ return None
+ raise
+
+ # Parse XML response
+ root = ET.fromstring(response.content)
+ ns = {
+ "d": "DAV:",
+ "oc": "http://owncloud.org/ns",
+ }
+
+ response_elem = root.find("d:response", ns)
+ if response_elem is None:
+ return None
+
+ propstat = response_elem.find("d:propstat", ns)
+ if propstat is None:
+ return None
+
+ prop = propstat.find("d:prop", ns)
+ if prop is None:
+ return None
+
+ # Extract properties
+ fileid_elem = prop.find("oc:fileid", ns)
+ displayname_elem = prop.find("d:displayname", ns)
+ contentlength_elem = prop.find("d:getcontentlength", ns)
+ contenttype_elem = prop.find("d:getcontenttype", ns)
+ lastmodified_elem = prop.find("d:getlastmodified", ns)
+ etag_elem = prop.find("d:getetag", ns)
+ resourcetype_elem = prop.find("d:resourcetype", ns)
+
+ is_directory = (
+ resourcetype_elem is not None
+ and resourcetype_elem.find("d:collection", ns) is not None
+ )
+
+ file_info = {
+ "id": int(fileid_elem.text) if fileid_elem is not None else None,
+ "path": path,
+ "name": displayname_elem.text
+ if displayname_elem is not None
+ else path.split("/")[-1],
+ "size": int(contentlength_elem.text)
+ if contentlength_elem is not None and contentlength_elem.text
+ else 0,
+ "content_type": contenttype_elem.text
+ if contenttype_elem is not None
+ else "",
+ "last_modified": lastmodified_elem.text
+ if lastmodified_elem is not None
+ else None,
+ "etag": etag_elem.text.strip('"')
+ if etag_elem is not None and etag_elem.text
+ else None,
+ "is_directory": is_directory,
+ }
+
+ logger.debug(f"Got file info for '{path}': id={file_info['id']}")
+ return file_info
+
+ async def create_tag(
+ self,
+ name: str,
+ user_visible: bool = True,
+ user_assignable: bool = True,
+ ) -> dict[str, Any]:
+ """Create a system tag via OCS API.
+
+ Args:
+ name: Name of the tag to create
+ user_visible: Whether the tag is visible to users
+ user_assignable: Whether users can assign this tag
+
+ Returns:
+ Tag dictionary with id, name, userVisible, userAssignable
+
+ Raises:
+ HTTPStatusError: If tag creation fails (409 if already exists)
+ """
+ response = await self._client.post(
+ "/ocs/v2.php/apps/systemtags/api/v1/tags",
+ headers={
+ "OCS-APIRequest": "true",
+ "Content-Type": "application/json",
+ },
+ json={
+ "name": name,
+ "userVisible": user_visible,
+ "userAssignable": user_assignable,
+ },
+ )
+ response.raise_for_status()
+
+ # Parse OCS response
+ data = response.json()
+ ocs_data = data.get("ocs", {}).get("data", {})
+
+ tag_info = {
+ "id": ocs_data.get("id"),
+ "name": ocs_data.get("name", name),
+ "userVisible": ocs_data.get("userVisible", user_visible),
+ "userAssignable": ocs_data.get("userAssignable", user_assignable),
+ }
+
+ logger.info(f"Created tag '{name}' with ID {tag_info['id']}")
+ return tag_info
+
+ async def get_or_create_tag(
+ self,
+ name: str,
+ user_visible: bool = True,
+ user_assignable: bool = True,
+ ) -> dict[str, Any]:
+ """Get a tag by name, creating it if it doesn't exist.
+
+ Args:
+ name: Name of the tag
+ user_visible: Whether the tag is visible to users (for creation)
+ user_assignable: Whether users can assign this tag (for creation)
+
+ Returns:
+ Tag dictionary with id, name, userVisible, userAssignable
+ """
+ # First try to get existing tag
+ existing_tag = await self.get_tag_by_name(name)
+ if existing_tag:
+ logger.debug(f"Tag '{name}' already exists with ID {existing_tag['id']}")
+ return existing_tag
+
+ # Create new tag
+ try:
+ return await self.create_tag(name, user_visible, user_assignable)
+ except HTTPStatusError as e:
+ if e.response.status_code == 409:
+ # Tag was created between our check and creation, fetch it
+ existing_tag = await self.get_tag_by_name(name)
+ if existing_tag:
+ return existing_tag
+ raise
+
+ async def assign_tag_to_file(self, file_id: int, tag_id: int) -> bool:
+ """Assign a system tag to a file.
+
+ Args:
+ file_id: Numeric file ID
+ tag_id: Numeric tag ID
+
+ Returns:
+ True if tag was assigned successfully (or already assigned)
+
+ Raises:
+ HTTPStatusError: If tag assignment fails
+ """
+ response = await self._client.request(
+ "PUT",
+ f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
+ headers={"Content-Length": "0"},
+ content=b"",
+ )
+
+ # 201 = Created (new assignment), 409 = Conflict (already assigned)
+ if response.status_code in (201, 409):
+ logger.info(f"Tagged file {file_id} with tag {tag_id}")
+ return True
+
+ response.raise_for_status()
+ return True
+
+ async def remove_tag_from_file(self, file_id: int, tag_id: int) -> bool:
+ """Remove a system tag from a file.
+
+ Args:
+ file_id: Numeric file ID
+ tag_id: Numeric tag ID
+
+ Returns:
+ True if tag was removed successfully (or wasn't assigned)
+
+ Raises:
+ HTTPStatusError: If tag removal fails
+ """
+ response = await self._client.request(
+ "DELETE",
+ f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
+ )
+
+ # 204 = No Content (removed), 404 = Not Found (wasn't assigned)
+ if response.status_code in (204, 404):
+ logger.info(f"Removed tag {tag_id} from file {file_id}")
+ return True
+
+ response.raise_for_status()
+ return True
diff --git a/tests/integration/test_rag_openai.py b/tests/integration/test_rag_openai.py
index aa32d3f..8f56495 100644
--- a/tests/integration/test_rag_openai.py
+++ b/tests/integration/test_rag_openai.py
@@ -10,6 +10,7 @@ Environment Variables:
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
OPENAI_GENERATION_MODEL: Generation model for sampling (default: "gpt-4o-mini")
+ RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: "Nextcloud_User_Manual.pdf")
For GitHub CI, set:
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
@@ -18,15 +19,17 @@ For GitHub CI, set:
OPENAI_GENERATION_MODEL: openai/gpt-4o-mini
Prerequisites:
- - Nextcloud User Manual indexed in Qdrant (via vector sync)
+ - Nextcloud User Manual PDF uploaded to Nextcloud
- VECTOR_SYNC_ENABLED=true on the MCP server
"""
import json
+import logging
import os
from pathlib import Path
from typing import Any, AsyncGenerator
+import anyio
import pytest
from mcp import ClientSession
@@ -34,6 +37,11 @@ from nextcloud_mcp_server.providers.openai import OpenAIProvider
from tests.conftest import create_mcp_client_session
from tests.integration.sampling_support import create_openai_sampling_callback
+logger = logging.getLogger(__name__)
+
+# Default path to the Nextcloud User Manual PDF
+DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
+
# Skip all tests if OpenAI API key not configured
pytestmark = [
pytest.mark.integration,
@@ -58,6 +66,86 @@ def ground_truth_qa():
return json.load(f)
+@pytest.fixture(scope="module")
+async def indexed_manual_pdf(nc_client, nc_mcp_client):
+ """Ensure the Nextcloud User Manual PDF is tagged and indexed for vector search.
+
+ This fixture:
+ 1. Gets file info for the manual PDF
+ 2. Creates/gets the 'vector-index' tag
+ 3. Assigns the tag to the file
+ 4. Waits for vector sync to complete indexing
+
+ Environment Variables:
+ RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: Nextcloud Manual.pdf)
+ """
+ manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
+
+ logger.info(f"Setting up indexed manual PDF: {manual_path}")
+
+ # Get file info to verify file exists and get file ID
+ file_info = await nc_client.webdav.get_file_info(manual_path)
+ if not file_info:
+ pytest.skip(f"Manual PDF not found at '{manual_path}'")
+
+ file_id = file_info["id"]
+ logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})")
+
+ # Create or get the vector-index tag
+ tag = await nc_client.webdav.get_or_create_tag("vector-index")
+ tag_id = tag["id"]
+ logger.info(f"Using tag 'vector-index' (tag_id={tag_id})")
+
+ # Assign tag to file
+ await nc_client.webdav.assign_tag_to_file(file_id, tag_id)
+ logger.info(f"Tagged file {file_id} with vector-index tag")
+
+ # Wait for vector sync to complete indexing
+ max_attempts = 60
+ poll_interval = 10
+
+ logger.info("Waiting for vector sync to index the manual...")
+
+ for attempt in range(1, max_attempts + 1):
+ try:
+ # Call the MCP tool via the existing client session
+ result = await nc_mcp_client.call_tool(
+ "nc_get_vector_sync_status",
+ arguments={},
+ )
+
+ if not result.isError:
+ content = result.structuredContent or {}
+ indexed = content.get("indexed_count", 0)
+ pending = content.get("pending_count", 1)
+
+ logger.info(
+ f"Attempt {attempt}/{max_attempts}: "
+ f"indexed={indexed}, pending={pending}"
+ )
+
+ if indexed > 0 and pending == 0:
+ logger.info(
+ f"Vector indexing complete: {indexed} documents indexed"
+ )
+ break
+ except Exception as e:
+ logger.warning(f"Attempt {attempt}: Error checking status: {e}")
+
+ if attempt < max_attempts:
+ await anyio.sleep(poll_interval)
+ else:
+ logger.warning(
+ f"Vector indexing may not be complete after {max_attempts} attempts"
+ )
+
+ yield {
+ "path": manual_path,
+ "file_id": file_id,
+ "tag_id": tag_id,
+ }
+
+
@pytest.fixture(scope="module")
async def openai_provider():
"""OpenAI provider configured from environment (embeddings only)."""
@@ -129,7 +217,9 @@ async def test_openai_embeddings_work(openai_provider: OpenAIProvider):
assert len(embedding) in [1536, 3072]
-async def test_semantic_search_retrieval(nc_mcp_client, ground_truth_qa):
+async def test_semantic_search_retrieval(
+ nc_mcp_client, ground_truth_qa, indexed_manual_pdf
+):
"""Test that semantic search retrieves relevant documents from the manual.
This tests the retrieval component of RAG - ensuring that queries
@@ -167,7 +257,7 @@ async def test_semantic_search_retrieval(nc_mcp_client, ground_truth_qa):
async def test_semantic_search_answer_with_sampling(
- nc_mcp_client_with_sampling, ground_truth_qa
+ nc_mcp_client_with_sampling, ground_truth_qa, indexed_manual_pdf
):
"""Test semantic search with MCP sampling for answer generation.
@@ -243,7 +333,7 @@ async def test_semantic_search_answer_with_sampling(
],
)
async def test_retrieval_quality_all_queries(
- nc_mcp_client, ground_truth_qa, qa_index, min_expected_results
+ nc_mcp_client, ground_truth_qa, indexed_manual_pdf, qa_index, min_expected_results
):
"""Test retrieval quality for all ground truth queries.
@@ -274,7 +364,7 @@ async def test_retrieval_quality_all_queries(
)
-async def test_no_results_for_unrelated_query(nc_mcp_client):
+async def test_no_results_for_unrelated_query(nc_mcp_client, indexed_manual_pdf):
"""Test that completely unrelated queries return low/no scores.
The Nextcloud manual shouldn't have relevant content for
diff --git a/tests/unit/client/test_webdav.py b/tests/unit/client/test_webdav.py
index be7a190..f802441 100644
--- a/tests/unit/client/test_webdav.py
+++ b/tests/unit/client/test_webdav.py
@@ -117,3 +117,244 @@ def test_parse_search_response_with_empty_tags(mocker):
assert len(results) == 1
assert "tags" in results[0]
assert results[0]["tags"] == []
+
+
+@pytest.mark.unit
+async def test_get_file_info_returns_file_details(mocker):
+ """Test that get_file_info returns file info including file ID."""
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock PROPFIND response
+ mock_response = AsyncMock()
+ mock_response.status_code = 207
+ mock_response.content = b"""
+
+
+ /remote.php/dav/files/testuser/Documents/test.pdf
+
+
+ 12345
+ test.pdf
+ 1024
+ application/pdf
+ Sat, 01 Jan 2025 00:00:00 GMT
+ "abc123"
+
+
+
+
+ """
+ mock_response.raise_for_status = mocker.Mock()
+
+ mock_http_client.request = AsyncMock(return_value=mock_response)
+
+ # Call get_file_info
+ result = await client.get_file_info("Documents/test.pdf")
+
+ # Verify result
+ assert result is not None
+ assert result["id"] == 12345
+ assert result["name"] == "test.pdf"
+ assert result["path"] == "Documents/test.pdf"
+ assert result["content_type"] == "application/pdf"
+ assert result["size"] == 1024
+ assert result["etag"] == "abc123"
+ assert result["is_directory"] is False
+
+
+@pytest.mark.unit
+async def test_get_file_info_returns_none_for_missing_file(mocker):
+ """Test that get_file_info returns None for missing files."""
+ from httpx import HTTPStatusError, Response
+
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock 404 response
+ mock_response = mocker.Mock(spec=Response)
+ mock_response.status_code = 404
+ mock_http_client.request = AsyncMock(
+ side_effect=HTTPStatusError(
+ "Not Found", request=mocker.Mock(), response=mock_response
+ )
+ )
+
+ # Call get_file_info
+ result = await client.get_file_info("nonexistent.pdf")
+
+ # Verify result is None
+ assert result is None
+
+
+@pytest.mark.unit
+async def test_create_tag_creates_system_tag(mocker):
+ """Test that create_tag creates a system tag via OCS API."""
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock OCS response
+ mock_response = AsyncMock()
+ mock_response.status_code = 200
+ mock_response.json = mocker.Mock(
+ return_value={
+ "ocs": {
+ "data": {
+ "id": 42,
+ "name": "vector-index",
+ "userVisible": True,
+ "userAssignable": True,
+ }
+ }
+ }
+ )
+ mock_response.raise_for_status = mocker.Mock()
+
+ mock_http_client.post = AsyncMock(return_value=mock_response)
+
+ # Call create_tag
+ result = await client.create_tag("vector-index")
+
+ # Verify result
+ assert result["id"] == 42
+ assert result["name"] == "vector-index"
+ assert result["userVisible"] is True
+ assert result["userAssignable"] is True
+
+ # Verify API call
+ mock_http_client.post.assert_called_once()
+ call_args = mock_http_client.post.call_args
+ assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags"
+ assert call_args[1]["json"]["name"] == "vector-index"
+
+
+@pytest.mark.unit
+async def test_get_or_create_tag_returns_existing_tag(mocker):
+ """Test that get_or_create_tag returns existing tag without creating."""
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock existing tag
+ mocker.patch.object(
+ client,
+ "get_tag_by_name",
+ return_value={"id": 42, "name": "vector-index", "userVisible": True},
+ )
+ mock_create = mocker.patch.object(client, "create_tag")
+
+ # Call get_or_create_tag
+ result = await client.get_or_create_tag("vector-index")
+
+ # Verify existing tag returned without creating
+ assert result["id"] == 42
+ mock_create.assert_not_called()
+
+
+@pytest.mark.unit
+async def test_get_or_create_tag_creates_new_tag(mocker):
+ """Test that get_or_create_tag creates tag when not found."""
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock no existing tag
+ mocker.patch.object(client, "get_tag_by_name", return_value=None)
+ mocker.patch.object(
+ client,
+ "create_tag",
+ return_value={"id": 42, "name": "vector-index", "userVisible": True},
+ )
+
+ # Call get_or_create_tag
+ result = await client.get_or_create_tag("vector-index")
+
+ # Verify tag was created
+ assert result["id"] == 42
+ client.create_tag.assert_called_once_with("vector-index", True, True)
+
+
+@pytest.mark.unit
+async def test_assign_tag_to_file_success(mocker):
+ """Test that assign_tag_to_file assigns tag via WebDAV."""
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock 201 Created response
+ mock_response = AsyncMock()
+ mock_response.status_code = 201
+
+ mock_http_client.request = AsyncMock(return_value=mock_response)
+
+ # Call assign_tag_to_file
+ result = await client.assign_tag_to_file(12345, 42)
+
+ # Verify result
+ assert result is True
+
+ # Verify API call
+ mock_http_client.request.assert_called_once()
+ call_args = mock_http_client.request.call_args
+ assert call_args[0][0] == "PUT"
+ assert "/systemtags-relations/files/12345/42" in call_args[0][1]
+
+
+@pytest.mark.unit
+async def test_assign_tag_to_file_already_assigned(mocker):
+ """Test that assign_tag_to_file handles already assigned (409) gracefully."""
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock 409 Conflict response (already assigned)
+ mock_response = AsyncMock()
+ mock_response.status_code = 409
+
+ mock_http_client.request = AsyncMock(return_value=mock_response)
+
+ # Call assign_tag_to_file
+ result = await client.assign_tag_to_file(12345, 42)
+
+ # Verify result (should succeed even with 409)
+ assert result is True
+
+
+@pytest.mark.unit
+async def test_remove_tag_from_file_success(mocker):
+ """Test that remove_tag_from_file removes tag via WebDAV."""
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock 204 No Content response
+ mock_response = AsyncMock()
+ mock_response.status_code = 204
+
+ mock_http_client.request = AsyncMock(return_value=mock_response)
+
+ # Call remove_tag_from_file
+ result = await client.remove_tag_from_file(12345, 42)
+
+ # Verify result
+ assert result is True
+
+ # Verify API call
+ mock_http_client.request.assert_called_once()
+ call_args = mock_http_client.request.call_args
+ assert call_args[0][0] == "DELETE"
+ assert "/systemtags-relations/files/12345/42" in call_args[0][1]
+
+
+@pytest.mark.unit
+async def test_remove_tag_from_file_not_assigned(mocker):
+ """Test that remove_tag_from_file handles not assigned (404) gracefully."""
+ mock_http_client = AsyncMock()
+ client = WebDAVClient(mock_http_client, "testuser")
+
+ # Mock 404 Not Found response (tag wasn't assigned)
+ mock_response = AsyncMock()
+ mock_response.status_code = 404
+
+ mock_http_client.request = AsyncMock(return_value=mock_response)
+
+ # Call remove_tag_from_file
+ result = await client.remove_tag_from_file(12345, 42)
+
+ # Verify result (should succeed even with 404)
+ assert result is True