test: Move client integration tests to mocked unit tests
This commit is contained in:
@@ -255,6 +255,72 @@ tests/
|
||||
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
#### Writing Mocked Unit Tests
|
||||
|
||||
For client-layer tests that verify response parsing logic, use mocked HTTP responses instead of real network calls:
|
||||
|
||||
**Pattern:**
|
||||
```python
|
||||
import httpx
|
||||
import pytest
|
||||
from nextcloud_mcp_server.client.notes import NotesClient
|
||||
from tests.conftest import create_mock_note_response
|
||||
|
||||
async def test_notes_api_get_note(mocker):
|
||||
"""Test that get_note correctly parses the API response."""
|
||||
# Create mock response using helper functions
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Test Note",
|
||||
content="Test content",
|
||||
category="Test",
|
||||
etag="abc123",
|
||||
)
|
||||
|
||||
# Mock the _make_request method
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Create client and test
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.get_note(note_id=123)
|
||||
|
||||
# Verify the response was parsed correctly
|
||||
assert note["id"] == 123
|
||||
assert note["title"] == "Test Note"
|
||||
# Verify the correct API endpoint was called
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||
```
|
||||
|
||||
**Mock Response Helpers in `tests/conftest.py`:**
|
||||
- `create_mock_response()` - Generic HTTP response builder
|
||||
- `create_mock_note_response()` - Pre-configured note response
|
||||
- `create_mock_error_response()` - Error responses (404, 412, etc.)
|
||||
|
||||
**Benefits:**
|
||||
- ⚡ Fast execution (~0.1s vs minutes for integration tests)
|
||||
- 🔒 No Docker dependency
|
||||
- 🎯 Tests focus on response parsing logic
|
||||
- ♻️ Repeatable and deterministic
|
||||
|
||||
**When to use:**
|
||||
- Testing client methods that parse JSON responses
|
||||
- Testing error handling (404, 412, etc.)
|
||||
- Testing request parameter building
|
||||
|
||||
**When NOT to use (keep as integration tests):**
|
||||
- Complex protocol interactions (CalDAV, CardDAV, WebDAV)
|
||||
- Multi-component workflows (Notes + WebDAV attachments)
|
||||
- OAuth flows
|
||||
- End-to-end MCP tool testing
|
||||
|
||||
**Reference Implementation:**
|
||||
- See `tests/client/notes/test_notes_api.py` for complete examples
|
||||
- Mark unit tests with `pytestmark = pytest.mark.unit`
|
||||
- Run with: `uv run pytest tests/unit/ tests/client/notes/test_notes_api.py -v`
|
||||
|
||||
#### OAuth/OIDC Testing
|
||||
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import httpx
|
||||
|
||||
# ============================================================================
|
||||
# Mock Response Helpers for Unit Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def create_mock_response(
|
||||
status_code: int = 200,
|
||||
json_data: dict | list | None = None,
|
||||
headers: dict | None = None,
|
||||
content: bytes | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock httpx.Response for testing.
|
||||
|
||||
Args:
|
||||
status_code: HTTP status code
|
||||
json_data: JSON data to return from response.json()
|
||||
headers: Response headers
|
||||
content: Raw response content (if not using json_data)
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response object
|
||||
"""
|
||||
import json as json_module
|
||||
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
# If json_data is provided, serialize it to content
|
||||
if json_data is not None:
|
||||
content = json_module.dumps(json_data).encode("utf-8")
|
||||
headers.setdefault("content-type", "application/json")
|
||||
|
||||
if content is None:
|
||||
content = b""
|
||||
|
||||
# Create a mock request
|
||||
request = httpx.Request("GET", "http://test.local/api")
|
||||
|
||||
# Create the response
|
||||
return httpx.Response(
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
content=content,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_note_response(
|
||||
note_id: int = 1,
|
||||
title: str = "Test Note",
|
||||
content: str = "Test content",
|
||||
category: str = "Test",
|
||||
etag: str = "abc123",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud note.
|
||||
|
||||
Args:
|
||||
note_id: Note ID
|
||||
title: Note title
|
||||
content: Note content
|
||||
category: Note category
|
||||
etag: ETag header value
|
||||
**kwargs: Additional note fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with note data
|
||||
"""
|
||||
note_data = {
|
||||
"id": note_id,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"category": category,
|
||||
"etag": etag,
|
||||
"modified": 1234567890,
|
||||
"favorite": False,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=note_data,
|
||||
headers={"etag": f'"{etag}"'},
|
||||
)
|
||||
|
||||
|
||||
def create_mock_error_response(
|
||||
status_code: int,
|
||||
message: str = "Error",
|
||||
) -> httpx.Response:
|
||||
"""Create a mock error response.
|
||||
|
||||
Args:
|
||||
status_code: HTTP error status code (e.g., 404, 412)
|
||||
message: Error message
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with error
|
||||
"""
|
||||
return create_mock_response(
|
||||
status_code=status_code,
|
||||
json_data={"message": message},
|
||||
)
|
||||
|
||||
|
||||
def create_mock_recipe_response(
|
||||
recipe_id: int = 1,
|
||||
name: str = "Test Recipe",
|
||||
description: str = "Test description",
|
||||
recipe_category: str = "Test",
|
||||
keywords: str = "test",
|
||||
recipe_yield: int = 4,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Cookbook recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: Recipe ID
|
||||
name: Recipe name
|
||||
description: Recipe description
|
||||
recipe_category: Recipe category
|
||||
keywords: Recipe keywords (comma-separated)
|
||||
recipe_yield: Recipe yield (number of servings)
|
||||
**kwargs: Additional recipe fields (recipeIngredient, recipeInstructions, etc.)
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with recipe data
|
||||
"""
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"recipeCategory": recipe_category,
|
||||
"keywords": keywords,
|
||||
"recipeYield": recipe_yield,
|
||||
"recipeIngredient": kwargs.get("recipeIngredient", []),
|
||||
"recipeInstructions": kwargs.get("recipeInstructions", []),
|
||||
"prepTime": kwargs.get("prepTime", "PT15M"),
|
||||
"cookTime": kwargs.get("cookTime", "PT30M"),
|
||||
"totalTime": kwargs.get("totalTime", "PT45M"),
|
||||
"url": kwargs.get("url", ""),
|
||||
**{
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k
|
||||
not in [
|
||||
"recipeIngredient",
|
||||
"recipeInstructions",
|
||||
"prepTime",
|
||||
"cookTime",
|
||||
"totalTime",
|
||||
"url",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=recipe_data,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_recipe_list_response(
|
||||
recipes: list[dict] = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a list of recipe stubs.
|
||||
|
||||
Args:
|
||||
recipes: List of recipe stub dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with recipe list data
|
||||
"""
|
||||
if recipes is None:
|
||||
recipes = []
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=recipes,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_deck_board_response(
|
||||
board_id: int = 1,
|
||||
title: str = "Test Board",
|
||||
color: str = "0000FF",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck board.
|
||||
|
||||
Args:
|
||||
board_id: Board ID
|
||||
title: Board title
|
||||
color: Board color (hex without #)
|
||||
**kwargs: Additional board fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with board data
|
||||
"""
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
"title": title,
|
||||
"color": color,
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=board_data)
|
||||
|
||||
|
||||
def create_mock_deck_stack_response(
|
||||
stack_id: int = 1,
|
||||
title: str = "Test Stack",
|
||||
board_id: int = 1,
|
||||
order: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck stack.
|
||||
|
||||
Args:
|
||||
stack_id: Stack ID
|
||||
title: Stack title
|
||||
board_id: Parent board ID
|
||||
order: Stack order
|
||||
**kwargs: Additional stack fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with stack data
|
||||
"""
|
||||
stack_data = {
|
||||
"id": stack_id,
|
||||
"title": title,
|
||||
"boardId": board_id,
|
||||
"order": order,
|
||||
"deletedAt": 0,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=stack_data)
|
||||
|
||||
|
||||
def create_mock_deck_card_response(
|
||||
card_id: int = 1,
|
||||
title: str = "Test Card",
|
||||
stack_id: int = 1,
|
||||
description: str = "Test description",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck card.
|
||||
|
||||
Args:
|
||||
card_id: Card ID
|
||||
title: Card title
|
||||
stack_id: Parent stack ID
|
||||
description: Card description
|
||||
**kwargs: Additional card fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with card data
|
||||
"""
|
||||
card_data = {
|
||||
"id": card_id,
|
||||
"title": title,
|
||||
"stackId": stack_id,
|
||||
"type": "plain",
|
||||
"order": 999,
|
||||
"archived": False,
|
||||
"owner": "testuser",
|
||||
"description": description,
|
||||
"labels": [],
|
||||
"assignedUsers": [],
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=card_data)
|
||||
|
||||
|
||||
def create_mock_deck_label_response(
|
||||
label_id: int = 1,
|
||||
title: str = "Test Label",
|
||||
color: str = "FF0000",
|
||||
board_id: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck label.
|
||||
|
||||
Args:
|
||||
label_id: Label ID
|
||||
title: Label title
|
||||
color: Label color (hex without #)
|
||||
board_id: Parent board ID
|
||||
**kwargs: Additional label fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with label data
|
||||
"""
|
||||
label_data = {
|
||||
"id": label_id,
|
||||
"title": title,
|
||||
"color": color,
|
||||
"boardId": board_id,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=label_data)
|
||||
|
||||
|
||||
def create_mock_deck_comment_response(
|
||||
comment_id: int = 1,
|
||||
message: str = "Test comment",
|
||||
card_id: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck comment (OCS format).
|
||||
|
||||
Args:
|
||||
comment_id: Comment ID
|
||||
message: Comment message
|
||||
card_id: Parent card ID
|
||||
**kwargs: Additional comment fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with comment data in OCS format
|
||||
"""
|
||||
comment_data = {
|
||||
"id": comment_id,
|
||||
"objectId": card_id,
|
||||
"message": message,
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [], # Required field
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
# Wrap in OCS format
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": comment_data}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_tables_list_response(
|
||||
tables: list[dict] = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for list of Nextcloud Tables (OCS format).
|
||||
|
||||
Args:
|
||||
tables: List of table dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with tables list data in OCS format
|
||||
"""
|
||||
if tables is None:
|
||||
tables = []
|
||||
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": tables}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_table_schema_response(
|
||||
table_id: int = 1,
|
||||
columns: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for Nextcloud Tables schema.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
columns: List of column definitions. If None, creates sample columns.
|
||||
**kwargs: Additional schema fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with table schema data
|
||||
"""
|
||||
if columns is None:
|
||||
columns = [
|
||||
{"id": 1, "title": "Column 1", "type": "text"},
|
||||
{"id": 2, "title": "Column 2", "type": "number"},
|
||||
]
|
||||
|
||||
schema_data = {
|
||||
"id": table_id,
|
||||
"columns": columns,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=schema_data)
|
||||
|
||||
|
||||
def create_mock_table_row_response(
|
||||
row_id: int = 1,
|
||||
table_id: int = 1,
|
||||
data: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for Nextcloud Tables row.
|
||||
|
||||
Args:
|
||||
row_id: Row ID
|
||||
table_id: Table ID
|
||||
data: List of column data dicts. If None, creates sample data.
|
||||
**kwargs: Additional row fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with row data
|
||||
"""
|
||||
if data is None:
|
||||
data = [
|
||||
{"columnId": 1, "value": "Test value"},
|
||||
{"columnId": 2, "value": 42},
|
||||
]
|
||||
|
||||
row_data = {
|
||||
"id": row_id,
|
||||
"tableId": table_id,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": data,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=row_data)
|
||||
|
||||
|
||||
def create_mock_table_row_ocs_response(
|
||||
row_id: int = 1,
|
||||
table_id: int = 1,
|
||||
data: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock OCS response for Nextcloud Tables row (used by create_row).
|
||||
|
||||
Args:
|
||||
row_id: Row ID
|
||||
table_id: Table ID
|
||||
data: List of column data dicts. If None, creates sample data.
|
||||
**kwargs: Additional row fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with row data in OCS format
|
||||
"""
|
||||
if data is None:
|
||||
data = [
|
||||
{"columnId": 1, "value": "Test value"},
|
||||
{"columnId": 2, "value": 42},
|
||||
]
|
||||
|
||||
row_data = {
|
||||
"id": row_id,
|
||||
"tableId": table_id,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": data,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
@@ -1,386 +1,371 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.client.cookbook import CookbookClient
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_recipe_list_response,
|
||||
create_mock_recipe_response,
|
||||
create_mock_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
async def test_cookbook_version(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app version."""
|
||||
logger.info("Getting Cookbook app version")
|
||||
version_data = await nc_client.cookbook.get_version()
|
||||
async def test_cookbook_version(mocker):
|
||||
"""Test that get_version correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"cookbook_version": "1.0.0",
|
||||
"api_version": "1.0.0",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
version_data = await client.get_version()
|
||||
|
||||
assert "cookbook_version" in version_data
|
||||
assert "api_version" in version_data
|
||||
logger.info(f"Cookbook version: {version_data}")
|
||||
assert version_data["cookbook_version"] == "1.0.0"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/version")
|
||||
|
||||
|
||||
async def test_cookbook_config(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app configuration."""
|
||||
logger.info("Getting Cookbook app configuration")
|
||||
config_data = await nc_client.cookbook.get_config()
|
||||
async def test_cookbook_config(mocker):
|
||||
"""Test that get_config correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"folder": "/recipes",
|
||||
"update_interval": 60,
|
||||
"print_image": True,
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
config_data = await client.get_config()
|
||||
|
||||
# Config may be empty initially, just verify we can get it
|
||||
assert isinstance(config_data, dict)
|
||||
logger.info(f"Cookbook config: {config_data}")
|
||||
assert config_data["folder"] == "/recipes"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/config")
|
||||
|
||||
|
||||
async def test_cookbook_list_recipes(nc_client: NextcloudClient):
|
||||
"""Test listing all recipes."""
|
||||
logger.info("Listing all recipes")
|
||||
recipes = await nc_client.cookbook.list_recipes()
|
||||
async def test_cookbook_list_recipes(mocker):
|
||||
"""Test that list_recipes correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "recipeCategory": "Test"},
|
||||
{"id": 2, "name": "Recipe 2", "recipeCategory": "Test"},
|
||||
]
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes = await client.list_recipes()
|
||||
|
||||
assert isinstance(recipes, list)
|
||||
logger.info(f"Found {len(recipes)} recipes")
|
||||
assert len(recipes) == 2
|
||||
assert recipes[0]["name"] == "Recipe 1"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/recipes")
|
||||
|
||||
|
||||
async def test_cookbook_create_and_read_recipe(nc_client: NextcloudClient):
|
||||
"""Test creating a recipe and reading it back."""
|
||||
# Create a test recipe
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
async def test_cookbook_create_recipe(mocker):
|
||||
"""Test that create_recipe correctly parses the API response."""
|
||||
# Create_recipe returns just the recipe ID
|
||||
mock_response = create_mock_response(status_code=200, json_data=123)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "A test recipe for integration testing",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": [
|
||||
"Mix ingredients",
|
||||
"Cook for 20 minutes",
|
||||
"Serve hot",
|
||||
],
|
||||
"recipeCategory": "Test",
|
||||
"keywords": "test,integration",
|
||||
"recipeYield": 4,
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT20M",
|
||||
"totalTime": "PT35M",
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
logger.info(f"Created recipe with ID: {recipe_id}")
|
||||
|
||||
try:
|
||||
# Read the recipe back
|
||||
logger.info(f"Reading recipe ID: {recipe_id}")
|
||||
retrieved_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
|
||||
assert retrieved_recipe["name"] == recipe_name
|
||||
assert (
|
||||
retrieved_recipe["description"] == "A test recipe for integration testing"
|
||||
)
|
||||
assert len(retrieved_recipe["recipeIngredient"]) == 3
|
||||
assert len(retrieved_recipe["recipeInstructions"]) == 3
|
||||
assert retrieved_recipe["recipeCategory"] == "Test"
|
||||
assert retrieved_recipe["recipeYield"] == 4
|
||||
logger.info(f"Successfully verified recipe: {recipe_name}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
logger.info(f"Successfully deleted recipe ID: {recipe_id}")
|
||||
|
||||
|
||||
async def test_cookbook_update_recipe(nc_client: NextcloudClient):
|
||||
"""Test updating a recipe."""
|
||||
# Create a test recipe
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "Original description",
|
||||
"name": "Test Recipe",
|
||||
"description": "Test description",
|
||||
"recipeIngredient": ["100g flour"],
|
||||
"recipeInstructions": ["Mix ingredients"],
|
||||
"recipeCategory": "Original",
|
||||
}
|
||||
recipe_id = await client.create_recipe(recipe_data)
|
||||
|
||||
logger.info(f"Creating recipe for update test: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
assert recipe_id == 123
|
||||
|
||||
try:
|
||||
# Get the current recipe first
|
||||
current_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
|
||||
# Update the recipe with all required fields
|
||||
updated_data = current_recipe.copy()
|
||||
updated_data["description"] = "Updated description"
|
||||
updated_data["recipeIngredient"] = ["100g flour", "2 eggs"]
|
||||
updated_data["recipeInstructions"] = ["Mix ingredients", "Cook"]
|
||||
updated_data["recipeCategory"] = "Updated"
|
||||
|
||||
logger.info(f"Updating recipe ID: {recipe_id}")
|
||||
updated_id = await nc_client.cookbook.update_recipe(recipe_id, updated_data)
|
||||
assert updated_id == recipe_id
|
||||
|
||||
# Verify the update
|
||||
await anyio.sleep(1) # Allow propagation
|
||||
updated_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
assert updated_recipe["description"] == "Updated description"
|
||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||
assert len(updated_recipe["recipeInstructions"]) == 2
|
||||
assert updated_recipe["recipeCategory"] == "Updated"
|
||||
logger.info(f"Successfully updated recipe ID: {recipe_id}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient):
|
||||
"""Test deleting a non-existent recipe.
|
||||
async def test_cookbook_get_recipe(mocker):
|
||||
"""Test that get_recipe correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_response(
|
||||
recipe_id=123,
|
||||
name="Test Recipe",
|
||||
description="Test description",
|
||||
recipe_category="Test",
|
||||
keywords="test,integration",
|
||||
recipe_yield=4,
|
||||
recipeIngredient=["100g flour", "2 eggs"],
|
||||
recipeInstructions=["Mix ingredients", "Cook"],
|
||||
)
|
||||
|
||||
Note: The Cookbook API may return 502 or succeed silently for non-existent IDs
|
||||
rather than 404. This test verifies the behavior."""
|
||||
non_existent_id = 999999999
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
logger.info(f"Attempting to delete non-existent recipe ID: {non_existent_id}")
|
||||
try:
|
||||
result = await nc_client.cookbook.delete_recipe(non_existent_id)
|
||||
logger.info(f"Delete returned: {result}")
|
||||
# API may succeed silently or return an error message
|
||||
assert isinstance(result, str)
|
||||
except HTTPStatusError as e:
|
||||
# API may return 404 or 502 for non-existent recipes
|
||||
assert e.response.status_code in [404, 502]
|
||||
logger.info(f"Delete correctly failed with {e.response.status_code}")
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipe = await client.get_recipe(recipe_id=123)
|
||||
|
||||
assert recipe["id"] == 123
|
||||
assert recipe["name"] == "Test Recipe"
|
||||
assert recipe["description"] == "Test description"
|
||||
assert len(recipe["recipeIngredient"]) == 2
|
||||
assert len(recipe["recipeInstructions"]) == 2
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"GET", "/apps/cookbook/api/v1/recipes/123"
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient):
|
||||
"""Test importing a recipe from a URL.
|
||||
async def test_cookbook_update_recipe(mocker):
|
||||
"""Test that update_recipe correctly parses the API response."""
|
||||
# Update_recipe returns the recipe ID
|
||||
mock_response = create_mock_response(status_code=200, json_data=123)
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Use the nginx container hostname within the Docker network
|
||||
test_url = "http://recipes/black-pepper-tofu"
|
||||
|
||||
logger.info(f"Importing recipe from nginx container: {test_url}")
|
||||
|
||||
try:
|
||||
imported_recipe = await nc_client.cookbook.import_recipe(test_url)
|
||||
logger.info(f"Successfully imported recipe: {imported_recipe.get('name')}")
|
||||
|
||||
# Verify basic recipe structure
|
||||
assert "name" in imported_recipe
|
||||
assert imported_recipe["name"] == "Black Pepper Tofu"
|
||||
assert "id" in imported_recipe
|
||||
|
||||
# Verify schema.org fields were imported correctly
|
||||
assert imported_recipe.get("description")
|
||||
assert len(imported_recipe.get("recipeIngredient", [])) > 0
|
||||
assert len(imported_recipe.get("recipeInstructions", [])) > 0
|
||||
assert imported_recipe.get("recipeCategory") == "Main Course"
|
||||
assert "tofu" in imported_recipe.get("keywords", "").lower()
|
||||
|
||||
recipe_id = int(imported_recipe["id"])
|
||||
|
||||
# Verify we can read it back
|
||||
retrieved = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
assert retrieved["name"] == imported_recipe["name"]
|
||||
logger.info(f"Verified imported recipe ID: {recipe_id}")
|
||||
|
||||
# Clean up
|
||||
logger.info(f"Deleting imported recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
logger.info("Successfully deleted imported recipe")
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
# Recipe already exists - this is acceptable in tests
|
||||
logger.warning("Recipe already exists (409 conflict)")
|
||||
pytest.skip("Recipe already exists in test environment")
|
||||
elif e.response.status_code == 400:
|
||||
# URL couldn't be imported
|
||||
logger.error(
|
||||
f"Failed to import recipe from nginx container: {test_url}. "
|
||||
f"Status: {e.response.status_code}, Response: {e.response.text}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def test_cookbook_search_recipes(nc_client: NextcloudClient):
|
||||
"""Test searching for recipes."""
|
||||
# Create a test recipe with unique keywords
|
||||
unique_keyword = f"testkeyword{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": f"Recipe for testing search with {unique_keyword}",
|
||||
"keywords": unique_keyword,
|
||||
"recipeIngredient": ["test ingredient"],
|
||||
"recipeInstructions": ["test instruction"],
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
updated_data = {
|
||||
"name": "Updated Recipe",
|
||||
"description": "Updated description",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": ["Mix ingredients", "Cook", "Serve"],
|
||||
}
|
||||
updated_id = await client.update_recipe(recipe_id=123, recipe_data=updated_data)
|
||||
|
||||
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
assert updated_id == 123
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Search for the recipe
|
||||
logger.info(f"Searching for recipes with keyword: {unique_keyword}")
|
||||
search_results = await nc_client.cookbook.search_recipes(unique_keyword)
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
# Should find at least our recipe
|
||||
assert len(search_results) > 0
|
||||
|
||||
# Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(recipe_id) for r in search_results)
|
||||
assert found, f"Recipe {recipe_id} not found in search results"
|
||||
logger.info(f"Successfully found recipe {recipe_id} in search results")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
mock_make_request.assert_called_once_with(
|
||||
"PUT", "/apps/cookbook/api/v1/recipes/123", json=updated_data
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_list_categories(nc_client: NextcloudClient):
|
||||
"""Test listing recipe categories."""
|
||||
logger.info("Listing recipe categories")
|
||||
categories = await nc_client.cookbook.list_categories()
|
||||
async def test_cookbook_delete_recipe(mocker):
|
||||
"""Test that delete_recipe correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200, json_data="Recipe deleted successfully"
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
result = await client.delete_recipe(recipe_id=123)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "deleted" in result.lower()
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"DELETE", "/apps/cookbook/api/v1/recipes/123"
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_delete_nonexistent_recipe(mocker):
|
||||
"""Test that deleting a non-existent recipe raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Recipe not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(CookbookClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("DELETE", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_recipe(recipe_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
async def test_cookbook_search_recipes(mocker):
|
||||
"""Test that search_recipes correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Test Recipe 1", "keywords": "test,search"},
|
||||
{"id": 2, "name": "Test Recipe 2", "keywords": "test,search"},
|
||||
]
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
search_results = await client.search_recipes("test")
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
assert len(search_results) == 2
|
||||
|
||||
# Verify URL encoding happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/search/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_list_categories(mocker):
|
||||
"""Test that list_categories correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"name": "Desserts", "recipe_count": 5},
|
||||
{"name": "Main Course", "recipe_count": 10},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
categories = await client.list_categories()
|
||||
|
||||
assert isinstance(categories, list)
|
||||
logger.info(f"Found {len(categories)} categories")
|
||||
assert len(categories) == 2
|
||||
assert categories[0]["name"] == "Desserts"
|
||||
assert categories[0]["recipe_count"] == 5
|
||||
|
||||
# Each category should have name and recipe_count
|
||||
if categories:
|
||||
assert "name" in categories[0]
|
||||
assert "recipe_count" in categories[0]
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/categories")
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_in_category(nc_client: NextcloudClient):
|
||||
"""Test getting recipes in a specific category."""
|
||||
# Create a recipe in a test category
|
||||
unique_category = f"TestCategory{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"recipeCategory": unique_category,
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
async def test_cookbook_get_recipes_in_category(mocker):
|
||||
"""Test that get_recipes_in_category correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "recipeCategory": "Desserts"},
|
||||
{"id": 2, "name": "Recipe 2", "recipeCategory": "Desserts"},
|
||||
]
|
||||
)
|
||||
|
||||
logger.info(f"Creating recipe in category: {unique_category}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await anyio.sleep(2)
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes_in_category = await client.get_recipes_in_category("Desserts")
|
||||
|
||||
# Get recipes in this category
|
||||
logger.info(f"Getting recipes in category: {unique_category}")
|
||||
recipes_in_category = await nc_client.cookbook.get_recipes_in_category(
|
||||
unique_category
|
||||
)
|
||||
assert isinstance(recipes_in_category, list)
|
||||
assert len(recipes_in_category) == 2
|
||||
assert recipes_in_category[0]["recipeCategory"] == "Desserts"
|
||||
|
||||
assert isinstance(recipes_in_category, list)
|
||||
assert len(recipes_in_category) > 0
|
||||
|
||||
# Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(recipe_id) for r in recipes_in_category)
|
||||
assert found, f"Recipe {recipe_id} not found in category {unique_category}"
|
||||
logger.info(f"Successfully found recipe in category {unique_category}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
# Verify URL encoding happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/category/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_list_keywords(nc_client: NextcloudClient):
|
||||
"""Test listing recipe keywords."""
|
||||
logger.info("Listing recipe keywords")
|
||||
keywords = await nc_client.cookbook.list_keywords()
|
||||
async def test_cookbook_list_keywords(mocker):
|
||||
"""Test that list_keywords correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"name": "vegetarian", "recipe_count": 15},
|
||||
{"name": "quick", "recipe_count": 8},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
keywords = await client.list_keywords()
|
||||
|
||||
assert isinstance(keywords, list)
|
||||
logger.info(f"Found {len(keywords)} keywords")
|
||||
assert len(keywords) == 2
|
||||
assert keywords[0]["name"] == "vegetarian"
|
||||
assert keywords[0]["recipe_count"] == 15
|
||||
|
||||
# Each keyword should have name and recipe_count
|
||||
if keywords:
|
||||
assert "name" in keywords[0]
|
||||
assert "recipe_count" in keywords[0]
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/keywords")
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient):
|
||||
"""Test getting recipes with specific keywords.
|
||||
async def test_cookbook_get_recipes_with_keywords(mocker):
|
||||
"""Test that get_recipes_with_keywords correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "keywords": "vegetarian,quick"},
|
||||
{"id": 2, "name": "Recipe 2", "keywords": "vegetarian,healthy"},
|
||||
]
|
||||
)
|
||||
|
||||
Note: The keywords filtering may require exact keyword matches and sufficient
|
||||
indexing time. This test uses a longer wait time."""
|
||||
# Create a recipe with unique keywords
|
||||
unique_keyword = f"testtag{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"keywords": f"{unique_keyword},integration",
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
logger.info(f"Creating recipe with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes_with_keywords = await client.get_recipes_with_keywords(
|
||||
["vegetarian", "quick"]
|
||||
)
|
||||
|
||||
try:
|
||||
# Allow extra time for indexing
|
||||
await anyio.sleep(3)
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
assert len(recipes_with_keywords) == 2
|
||||
|
||||
# Trigger a reindex to ensure the recipe is indexed
|
||||
await nc_client.cookbook.reindex()
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Get recipes with this keyword
|
||||
logger.info(f"Getting recipes with keyword: {unique_keyword}")
|
||||
recipes_with_keywords = await nc_client.cookbook.get_recipes_with_keywords(
|
||||
[unique_keyword]
|
||||
)
|
||||
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
# Keyword filtering might not find recipes immediately due to indexing
|
||||
# Log the results for debugging
|
||||
logger.info(
|
||||
f"Found {len(recipes_with_keywords)} recipes with keyword {unique_keyword}"
|
||||
)
|
||||
|
||||
if len(recipes_with_keywords) > 0:
|
||||
# Verify our recipe is in the results if any are found
|
||||
found = any(
|
||||
str(r.get("id")) == str(recipe_id) for r in recipes_with_keywords
|
||||
)
|
||||
if found:
|
||||
logger.info(f"Successfully found recipe with keyword {unique_keyword}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Recipe {recipe_id} not in keyword results, but other recipes found"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No recipes found with keyword {unique_keyword} - may be indexing delay"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
# Verify URL encoding and keyword joining happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/tags/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_reindex(nc_client: NextcloudClient):
|
||||
"""Test triggering a reindex of recipes."""
|
||||
logger.info("Triggering recipe reindex")
|
||||
result = await nc_client.cookbook.reindex()
|
||||
async def test_cookbook_reindex(mocker):
|
||||
"""Test that reindex correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data="Reindex completed successfully",
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
result = await client.reindex()
|
||||
|
||||
# Should return a success message
|
||||
assert isinstance(result, str)
|
||||
logger.info(f"Reindex result: {result}")
|
||||
assert "reindex" in result.lower() or "completed" in result.lower()
|
||||
|
||||
mock_make_request.assert_called_once_with("POST", "/apps/cookbook/api/v1/reindex")
|
||||
|
||||
+455
-271
@@ -1,327 +1,511 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack
|
||||
from nextcloud_mcp_server.client.deck import DeckClient
|
||||
from nextcloud_mcp_server.models.deck import (
|
||||
DeckBoard,
|
||||
DeckCard,
|
||||
DeckComment,
|
||||
DeckLabel,
|
||||
DeckStack,
|
||||
)
|
||||
from tests.client.conftest import (
|
||||
create_mock_deck_board_response,
|
||||
create_mock_deck_card_response,
|
||||
create_mock_deck_comment_response,
|
||||
create_mock_deck_label_response,
|
||||
create_mock_deck_stack_response,
|
||||
create_mock_error_response,
|
||||
create_mock_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# Board CRUD Tests
|
||||
# Board Tests
|
||||
|
||||
|
||||
async def test_deck_board_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete board CRUD workflow using the temporary_board fixture.
|
||||
"""
|
||||
board_data = temporary_board
|
||||
board_id = board_data["id"]
|
||||
original_title = board_data["title"]
|
||||
original_color = board_data["color"]
|
||||
|
||||
logger.info(f"Testing CRUD operations on board ID: {board_id}")
|
||||
|
||||
# Read the board
|
||||
read_board = await nc_client.deck.get_board(board_id)
|
||||
assert read_board.id == board_id
|
||||
assert read_board.title == original_title
|
||||
assert read_board.color == original_color
|
||||
logger.info(f"Successfully read board ID: {board_id}")
|
||||
|
||||
# Update the board
|
||||
updated_title = f"Updated {original_title}"
|
||||
updated_color = "00FF00" # Green color
|
||||
await nc_client.deck.update_board(
|
||||
board_id, title=updated_title, color=updated_color
|
||||
async def test_deck_get_boards(mocker):
|
||||
"""Test that get_boards correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Board 1",
|
||||
"color": "FF0000",
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Board 2",
|
||||
"color": "00FF00",
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the update
|
||||
updated_board = await nc_client.deck.get_board(board_id)
|
||||
assert updated_board.title == updated_title
|
||||
assert updated_board.color == updated_color
|
||||
logger.info(f"Successfully updated board ID: {board_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
boards = await client.get_boards()
|
||||
|
||||
async def test_deck_list_boards(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all boards with different options.
|
||||
"""
|
||||
# Test basic listing
|
||||
boards = await nc_client.deck.get_boards()
|
||||
assert isinstance(boards, list)
|
||||
logger.info(f"Found {len(boards)} boards")
|
||||
assert len(boards) == 2
|
||||
assert all(isinstance(b, DeckBoard) for b in boards)
|
||||
assert boards[0].id == 1
|
||||
assert boards[0].title == "Board 1"
|
||||
|
||||
# Test with details
|
||||
detailed_boards = await nc_client.deck.get_boards(details=True)
|
||||
assert isinstance(detailed_boards, list)
|
||||
logger.info(f"Found {len(detailed_boards)} boards with details")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test operations on non-existent board return appropriate errors.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
# Test get non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.get_board(non_existent_id)
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
] # 403 might be returned for access denied
|
||||
logger.info(
|
||||
f"Get non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
async def test_deck_create_board(mocker):
|
||||
"""Test that create_board correctly parses the API response."""
|
||||
mock_response = create_mock_deck_board_response(
|
||||
board_id=123, title="New Board", color="FF0000"
|
||||
)
|
||||
|
||||
# Test update non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.update_board(non_existent_id, title="Should Fail")
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
400,
|
||||
] # 400 for bad request on invalid board ID
|
||||
logger.info(
|
||||
f"Update non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
board = await client.create_board(title="New Board", color="FF0000")
|
||||
|
||||
# Stack CRUD Tests
|
||||
assert isinstance(board, DeckBoard)
|
||||
assert board.id == 123
|
||||
assert board.title == "New Board"
|
||||
assert board.color == "FF0000"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "POST"
|
||||
assert call_args[1]["json"]["title"] == "New Board"
|
||||
|
||||
|
||||
async def test_deck_stack_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete stack CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
stack_title = f"Test Stack {uuid.uuid4().hex[:8]}"
|
||||
stack_order = 1
|
||||
stack = None
|
||||
async def test_deck_get_board(mocker):
|
||||
"""Test that get_board correctly parses the API response."""
|
||||
mock_response = create_mock_deck_board_response(
|
||||
board_id=123, title="Test Board", color="0000FF"
|
||||
)
|
||||
|
||||
try:
|
||||
# Create stack
|
||||
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.title == stack_title
|
||||
assert stack.order == stack_order
|
||||
stack_id = stack.id
|
||||
logger.info(f"Created stack ID: {stack_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Read stack
|
||||
read_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert read_stack.id == stack_id
|
||||
assert read_stack.title == stack_title
|
||||
logger.info(f"Successfully read stack ID: {stack_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
board = await client.get_board(board_id=123)
|
||||
|
||||
# Update stack
|
||||
updated_title = f"Updated {stack_title}"
|
||||
updated_order = 2
|
||||
await nc_client.deck.update_stack(
|
||||
board_id, stack_id, title=updated_title, order=updated_order
|
||||
)
|
||||
assert isinstance(board, DeckBoard)
|
||||
assert board.id == 123
|
||||
assert board.title == "Test Board"
|
||||
|
||||
# Verify update
|
||||
updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert updated_stack.title == updated_title
|
||||
assert updated_stack.order == updated_order
|
||||
logger.info(f"Successfully updated stack ID: {stack_id}")
|
||||
|
||||
# List stacks
|
||||
stacks = await nc_client.deck.get_stacks(board_id)
|
||||
assert isinstance(stacks, list)
|
||||
assert any(s.id == stack_id for s in stacks)
|
||||
logger.info(f"Found stack ID: {stack_id} in board stacks list")
|
||||
|
||||
finally:
|
||||
# Clean up - delete stack
|
||||
if stack and hasattr(stack, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_stack(board_id, stack.id)
|
||||
logger.info(f"Cleaned up stack ID: {stack.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up stack ID: {stack.id}: {e}")
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
# Card CRUD Tests
|
||||
async def test_deck_update_board(mocker):
|
||||
"""Test that update_board makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
await client.update_board(board_id=123, title="Updated Board", color="00FF00")
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "PUT"
|
||||
assert "/boards/123" in call_args[0][1]
|
||||
assert call_args[1]["json"]["title"] == "Updated Board"
|
||||
|
||||
|
||||
async def test_deck_card_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_stack: tuple
|
||||
):
|
||||
"""
|
||||
Test complete card CRUD workflow.
|
||||
"""
|
||||
board_data, stack_data = temporary_board_with_stack
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
async def test_deck_get_board_nonexistent(mocker):
|
||||
"""Test that getting a non-existent board raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Board not found")
|
||||
|
||||
card_title = f"Test Card {uuid.uuid4().hex[:8]}"
|
||||
card_description = f"Test description for card {uuid.uuid4().hex[:8]}"
|
||||
card = None
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(DeckClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
try:
|
||||
# Create card
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, stack_id, card_title, description=card_description
|
||||
)
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.title == card_title
|
||||
assert card.description == card_description
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
|
||||
# Read card
|
||||
read_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert read_card.id == card_id
|
||||
assert read_card.title == card_title
|
||||
logger.info(f"Successfully read card ID: {card_id}")
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.get_board(board_id=999999999)
|
||||
|
||||
# Update card
|
||||
updated_title = f"Updated {card_title}"
|
||||
updated_description = f"Updated description for {card_title}"
|
||||
await nc_client.deck.update_card(
|
||||
board_id,
|
||||
stack_id,
|
||||
card_id,
|
||||
title=updated_title,
|
||||
description=updated_description,
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert updated_card.title == updated_title
|
||||
assert updated_card.description == updated_description
|
||||
logger.info(f"Successfully updated card ID: {card_id}")
|
||||
|
||||
# Archive and unarchive card
|
||||
await nc_client.deck.archive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Archived card ID: {card_id}")
|
||||
|
||||
await nc_client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Unarchived card ID: {card_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete card
|
||||
if card and hasattr(card, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card.id)
|
||||
logger.info(f"Cleaned up card ID: {card.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up card ID: {card.id}: {e}")
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
# Label CRUD Tests
|
||||
# Stack Tests
|
||||
|
||||
|
||||
async def test_deck_label_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete label CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
label_title = f"Test Label {uuid.uuid4().hex[:8]}"
|
||||
label_color = "FF0000" # Red
|
||||
label = None
|
||||
async def test_deck_create_stack(mocker):
|
||||
"""Test that create_stack correctly parses the API response."""
|
||||
mock_response = create_mock_deck_stack_response(
|
||||
stack_id=456, title="Test Stack", board_id=123, order=1
|
||||
)
|
||||
|
||||
try:
|
||||
# Create label
|
||||
label = await nc_client.deck.create_label(board_id, label_title, label_color)
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.title == label_title
|
||||
assert label.color == label_color
|
||||
label_id = label.id
|
||||
logger.info(f"Created label ID: {label_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Read label
|
||||
read_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert read_label.id == label_id
|
||||
assert read_label.title == label_title
|
||||
logger.info(f"Successfully read label ID: {label_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stack = await client.create_stack(board_id=123, title="Test Stack", order=1)
|
||||
|
||||
# Update label
|
||||
updated_title = f"Updated {label_title}"
|
||||
updated_color = "00FF00" # Green
|
||||
await nc_client.deck.update_label(
|
||||
board_id, label_id, title=updated_title, color=updated_color
|
||||
)
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.id == 456
|
||||
assert stack.title == "Test Stack"
|
||||
assert stack.boardId == 123
|
||||
|
||||
# Verify update
|
||||
updated_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert updated_label.title == updated_title
|
||||
assert updated_label.color == updated_color
|
||||
logger.info(f"Successfully updated label ID: {label_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete label
|
||||
if label and hasattr(label, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_label(board_id, label.id)
|
||||
logger.info(f"Cleaned up label ID: {label.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up label ID: {label.id}: {e}")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
# Configuration and Comments Tests
|
||||
async def test_deck_get_stack(mocker):
|
||||
"""Test that get_stack correctly parses the API response."""
|
||||
mock_response = create_mock_deck_stack_response(
|
||||
stack_id=456, title="Test Stack", board_id=123, order=1
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stack = await client.get_stack(board_id=123, stack_id=456)
|
||||
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.id == 456
|
||||
assert stack.title == "Test Stack"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/stacks/456" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_deck_config_operations(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test deck configuration operations.
|
||||
"""
|
||||
# Get config
|
||||
config = await nc_client.deck.get_config()
|
||||
assert config is not None
|
||||
logger.info(f"Retrieved deck config: {config}")
|
||||
async def test_deck_get_stacks(mocker):
|
||||
"""Test that get_stacks correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"id": 1, "title": "Stack 1", "boardId": 123, "order": 1, "deletedAt": 0},
|
||||
{"id": 2, "title": "Stack 2", "boardId": 123, "order": 2, "deletedAt": 0},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stacks = await client.get_stacks(board_id=123)
|
||||
|
||||
assert isinstance(stacks, list)
|
||||
assert len(stacks) == 2
|
||||
assert all(isinstance(s, DeckStack) for s in stacks)
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_comments_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_card: tuple
|
||||
):
|
||||
"""
|
||||
Test comment operations on a card.
|
||||
"""
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
card_id = card_data["id"]
|
||||
# Card Tests
|
||||
|
||||
comment_message = f"Test comment {uuid.uuid4().hex[:8]}"
|
||||
comment = None
|
||||
|
||||
try:
|
||||
# Create comment
|
||||
comment = await nc_client.deck.create_comment(card_id, comment_message)
|
||||
assert comment.message == comment_message
|
||||
comment_id = comment.id
|
||||
logger.info(f"Created comment ID: {comment_id}")
|
||||
async def test_deck_create_card(mocker):
|
||||
"""Test that create_card correctly parses the API response."""
|
||||
mock_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Test Card", stack_id=456, description="Test description"
|
||||
)
|
||||
|
||||
# List comments
|
||||
comments = await nc_client.deck.get_comments(card_id)
|
||||
assert isinstance(comments, list)
|
||||
assert any(c.id == comment_id for c in comments)
|
||||
logger.info(f"Found comment ID: {comment_id} in card comments")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Update comment
|
||||
updated_message = f"Updated {comment_message}"
|
||||
updated_comment = await nc_client.deck.update_comment(
|
||||
card_id, comment_id, updated_message
|
||||
)
|
||||
assert updated_comment.message == updated_message
|
||||
logger.info(f"Successfully updated comment ID: {comment_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
card = await client.create_card(
|
||||
board_id=123, stack_id=456, title="Test Card", description="Test description"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up - delete comment
|
||||
if comment and hasattr(comment, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_comment(card_id, comment.id)
|
||||
logger.info(f"Cleaned up comment ID: {comment.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}")
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.id == 789
|
||||
assert card.title == "Test Card"
|
||||
assert card.description == "Test description"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_card(mocker):
|
||||
"""Test that get_card correctly parses the API response."""
|
||||
mock_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Test Card", stack_id=456
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
card = await client.get_card(board_id=123, stack_id=456, card_id=789)
|
||||
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.id == 789
|
||||
assert card.title == "Test Card"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/stacks/456/cards/789" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_deck_update_card(mocker):
|
||||
"""Test that update_card makes the correct API calls."""
|
||||
# Mock get_card response (update_card calls get_card first)
|
||||
get_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Original Card", stack_id=456
|
||||
)
|
||||
|
||||
# Mock update response
|
||||
update_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(DeckClient, "_make_request")
|
||||
# First call returns the card, second call is the update
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
await client.update_card(
|
||||
board_id=123, stack_id=456, card_id=789, title="Updated Card"
|
||||
)
|
||||
|
||||
# Should be called twice: GET then PUT
|
||||
assert mock_make_request.call_count == 2
|
||||
|
||||
# Check the PUT call
|
||||
put_call = mock_make_request.call_args_list[1]
|
||||
assert put_call[0][0] == "PUT"
|
||||
assert "/boards/123/stacks/456/cards/789" in put_call[0][1]
|
||||
assert put_call[1]["json"]["title"] == "Updated Card"
|
||||
|
||||
|
||||
# Label Tests
|
||||
|
||||
|
||||
async def test_deck_create_label(mocker):
|
||||
"""Test that create_label correctly parses the API response."""
|
||||
mock_response = create_mock_deck_label_response(
|
||||
label_id=111, title="Test Label", color="FF0000", board_id=123
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
label = await client.create_label(board_id=123, title="Test Label", color="FF0000")
|
||||
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.id == 111
|
||||
assert label.title == "Test Label"
|
||||
assert label.color == "FF0000"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_label(mocker):
|
||||
"""Test that get_label correctly parses the API response."""
|
||||
mock_response = create_mock_deck_label_response(
|
||||
label_id=111, title="Test Label", color="FF0000", board_id=123
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
label = await client.get_label(board_id=123, label_id=111)
|
||||
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.id == 111
|
||||
assert label.title == "Test Label"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/labels/111" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
# Comment Tests
|
||||
|
||||
|
||||
async def test_deck_create_comment(mocker):
|
||||
"""Test that create_comment correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_deck_comment_response(
|
||||
comment_id=222, message="Test comment", card_id=789
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comment = await client.create_comment(card_id=789, message="Test comment")
|
||||
|
||||
assert isinstance(comment, DeckComment)
|
||||
assert comment.id == 222
|
||||
assert comment.message == "Test comment"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_comments(mocker):
|
||||
"""Test that get_comments correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"objectId": 789,
|
||||
"message": "Comment 1",
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"objectId": 789,
|
||||
"message": "Comment 2",
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comments = await client.get_comments(card_id=789)
|
||||
|
||||
assert isinstance(comments, list)
|
||||
assert len(comments) == 2
|
||||
assert all(isinstance(c, DeckComment) for c in comments)
|
||||
assert comments[0].message == "Comment 1"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_update_comment(mocker):
|
||||
"""Test that update_comment correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_deck_comment_response(
|
||||
comment_id=222, message="Updated comment", card_id=789
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comment = await client.update_comment(
|
||||
card_id=789, comment_id=222, message="Updated comment"
|
||||
)
|
||||
|
||||
assert isinstance(comment, DeckComment)
|
||||
assert comment.id == 222
|
||||
assert comment.message == "Updated comment"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
# Config Test
|
||||
|
||||
|
||||
async def test_deck_get_config(mocker):
|
||||
"""Test that get_config correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": {
|
||||
"calendar": True,
|
||||
"cardDetailsInModal": True,
|
||||
"cardIdBadge": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
config = await client.get_config()
|
||||
|
||||
assert config.calendar is True
|
||||
assert config.cardDetailsInModal is True
|
||||
assert config.cardIdBadge is False
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
@@ -1,260 +1,255 @@
|
||||
import logging
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is now session-scoped in conftest.py
|
||||
from nextcloud_mcp_server.client.notes import NotesClient
|
||||
from tests.client.conftest import create_mock_error_response, create_mock_note_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
async def test_notes_api_create_and_read(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests creating a note via the API (using fixture) and then reading it back.
|
||||
"""
|
||||
created_note_data = temporary_note # Get data from fixture
|
||||
note_id = created_note_data["id"]
|
||||
|
||||
logger.info(f"Reading note created by fixture, ID: {note_id}")
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
|
||||
assert read_note["id"] == note_id
|
||||
assert read_note["title"] == created_note_data["title"]
|
||||
assert read_note["content"] == created_note_data["content"]
|
||||
assert read_note["category"] == created_note_data["category"]
|
||||
logger.info(f"Successfully read and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
"""
|
||||
Tests updating a note created by the fixture.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
original_category = created_note_data["category"]
|
||||
|
||||
update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
|
||||
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=update_title,
|
||||
content=update_content,
|
||||
# category=original_category # Explicitly pass category if required by update
|
||||
async def test_notes_api_get_note(mocker):
|
||||
"""Test that get_note correctly parses the API response."""
|
||||
# Create mock response
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Test Note",
|
||||
content="Test content",
|
||||
category="Test",
|
||||
etag="abc123",
|
||||
)
|
||||
logger.info(f"Note updated: {updated_note}")
|
||||
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert (
|
||||
updated_note["category"] == original_category
|
||||
) # Verify category didn't change
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
|
||||
# Optional: Verify update by reading again
|
||||
await anyio.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
logger.info(f"Successfully updated and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests that attempting to update with an old etag fails with 412.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
|
||||
# Perform a first update to change the etag
|
||||
first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Performing first update on note ID: {note_id} to change etag.")
|
||||
first_updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=first_update_title,
|
||||
content="First update content",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
# Mock the _make_request method
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
new_etag = first_updated_note["etag"]
|
||||
assert new_etag != original_etag
|
||||
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Now attempt update with the *original* etag
|
||||
logger.info(
|
||||
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}"
|
||||
# Create client and test
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.get_note(note_id=123)
|
||||
|
||||
# Verify the response was parsed correctly
|
||||
assert note["id"] == 123
|
||||
assert note["title"] == "Test Note"
|
||||
assert note["content"] == "Test content"
|
||||
assert note["category"] == "Test"
|
||||
assert note["etag"] == "abc123"
|
||||
|
||||
# Verify the correct API endpoint was called
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||
|
||||
|
||||
async def test_notes_api_create_note(mocker):
|
||||
"""Test that create_note correctly parses the API response."""
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=456,
|
||||
title="New Note",
|
||||
content="New content",
|
||||
category="Category",
|
||||
etag="def456",
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag, # Use the stale etag
|
||||
title="This update should fail due to conflict",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.create_note(
|
||||
title="New Note", content="New content", category="Category"
|
||||
)
|
||||
|
||||
assert note["id"] == 456
|
||||
assert note["title"] == "New Note"
|
||||
assert note["content"] == "New content"
|
||||
assert note["category"] == "Category"
|
||||
|
||||
# Verify the correct API call was made
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/notes/api/v1/notes",
|
||||
json={"title": "New Note", "content": "New content", "category": "Category"},
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_update(mocker):
|
||||
"""Test that update correctly parses the API response and handles etag."""
|
||||
# Mock the update response (no category passed, so no GET call happens)
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Updated Title",
|
||||
content="Updated content",
|
||||
category="Test",
|
||||
etag="new_etag",
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
# Mock _make_request to return the update response
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.return_value = update_response
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.update(
|
||||
note_id=123,
|
||||
etag="abc123",
|
||||
title="Updated Title",
|
||||
content="Updated content",
|
||||
)
|
||||
|
||||
assert updated_note["id"] == 123
|
||||
assert updated_note["title"] == "Updated Title"
|
||||
assert updated_note["content"] == "Updated content"
|
||||
assert updated_note["etag"] == "new_etag"
|
||||
|
||||
# Verify the PUT request was made with the correct etag header (only 1 call since no category)
|
||||
assert mock_make_request.call_count == 1
|
||||
put_call = mock_make_request.call_args_list[0]
|
||||
assert put_call[0] == ("PUT", "/apps/notes/api/v1/notes/123")
|
||||
assert put_call[1]["headers"]["If-Match"] == '"abc123"'
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(mocker):
|
||||
"""Test that update raises HTTPStatusError on 412 conflict."""
|
||||
# Mock the 412 error response
|
||||
error_response = create_mock_error_response(412, "Precondition Failed")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"412 Precondition Failed",
|
||||
request=httpx.Request("PUT", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.update(
|
||||
note_id=123,
|
||||
etag="old_etag",
|
||||
title="This should fail",
|
||||
)
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
|
||||
|
||||
assert excinfo.value.response.status_code == 412
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests deleting a note that doesn't exist fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.delete_note(note_id=non_existent_id)
|
||||
async def test_notes_api_delete_note(mocker):
|
||||
"""Test that delete_note makes the correct API call."""
|
||||
# Mock get_note response (to fetch category for cleanup)
|
||||
get_response = create_mock_note_response(note_id=123, category="Test")
|
||||
|
||||
# Mock delete response
|
||||
delete_response = create_mock_note_response(note_id=123)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = [get_response, delete_response]
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
await client.delete_note(note_id=123)
|
||||
|
||||
# Verify DELETE was called
|
||||
assert any(call[0][0] == "DELETE" for call in mock_make_request.call_args_list)
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(mocker):
|
||||
"""Test that deleting a non-existent note raises 404."""
|
||||
# Mock 404 error when fetching note details
|
||||
error_response = create_mock_error_response(404, "Not Found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_note(note_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
|
||||
|
||||
async def test_notes_api_append_content(mocker):
|
||||
"""Test that append_content correctly appends to existing content."""
|
||||
# Mock get_note response (to fetch current content)
|
||||
get_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="Original content",
|
||||
etag="old_etag",
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_existing_note(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content to an existing note using the new append functionality.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
# Mock update response with appended content
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="Original content\n---\nAppended content",
|
||||
etag="new_etag",
|
||||
)
|
||||
logger.info(f"Note after append: {updated_note}")
|
||||
|
||||
# Verify the note was updated
|
||||
assert updated_note["id"] == note_id
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != created_note_data["etag"] # Etag must change
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
# First call: GET (from get_note), second call: PUT (from update)
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
# Verify content has the separator and appended text
|
||||
expected_content = original_content + "\n---\n" + append_text
|
||||
assert updated_note["content"] == expected_content
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.append_content(note_id=123, content="Appended content")
|
||||
|
||||
# Verify by reading the note again
|
||||
await anyio.sleep(1) # Allow potential propagation delay
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content
|
||||
logger.info(f"Successfully appended content to note ID: {note_id}")
|
||||
assert updated_note["content"] == "Original content\n---\nAppended content"
|
||||
assert updated_note["etag"] == "new_etag"
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests appending content to an empty note (no separator should be added).
|
||||
"""
|
||||
# Create an empty note
|
||||
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
|
||||
test_category = "Test"
|
||||
|
||||
logger.info("Creating empty note for append test")
|
||||
empty_note = await nc_client.notes.create_note(
|
||||
title=test_title,
|
||||
async def test_notes_api_append_content_to_empty_note(mocker):
|
||||
"""Test that appending to empty note doesn't add separator."""
|
||||
# Mock get_note response with empty content
|
||||
get_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="",
|
||||
category=test_category, # Empty content
|
||||
)
|
||||
note_id = empty_note["id"]
|
||||
|
||||
try:
|
||||
append_text = f"First content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to empty note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
|
||||
# For empty notes, content should just be the appended text (no separator)
|
||||
assert updated_note["content"] == append_text
|
||||
|
||||
# Verify by reading the note again
|
||||
await anyio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == append_text
|
||||
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the test note
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Cleaned up test note ID: {note_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up test note ID: {note_id}: {e}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_multiple_times(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content multiple times to verify separator behavior.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
first_append = f"First append {uuid.uuid4().hex[:8]}"
|
||||
second_append = f"Second append {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Performing multiple appends to note ID: {note_id}")
|
||||
|
||||
# First append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=first_append
|
||||
etag="old_etag",
|
||||
)
|
||||
|
||||
expected_content_after_first = original_content + "\n---\n" + first_append
|
||||
assert updated_note["content"] == expected_content_after_first
|
||||
|
||||
# Second append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=second_append
|
||||
# Mock update response with just the appended text (no separator)
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="First content",
|
||||
etag="new_etag",
|
||||
)
|
||||
|
||||
expected_content_after_second = (
|
||||
expected_content_after_first + "\n---\n" + second_append
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
# First call: GET (from get_note), second call: PUT (from update)
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.append_content(note_id=123, content="First content")
|
||||
|
||||
# For empty notes, no separator should be added
|
||||
assert updated_note["content"] == "First content"
|
||||
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(mocker):
|
||||
"""Test that appending to a non-existent note raises 404."""
|
||||
error_response = create_mock_error_response(404, "Not Found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
assert updated_note["content"] == expected_content_after_second
|
||||
|
||||
# Verify by reading the note again
|
||||
await anyio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content_after_second
|
||||
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.append_content(note_id=999999999, content="This should fail")
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests that appending to a non-existent note fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.append_content(
|
||||
note_id=non_existent_id, content="This should fail"
|
||||
)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
@@ -1,535 +1,326 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.client.tables import TablesClient
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_response,
|
||||
create_mock_table_row_ocs_response,
|
||||
create_mock_table_row_response,
|
||||
create_mock_table_schema_response,
|
||||
create_mock_tables_list_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def sample_table_info(nc_client: NextcloudClient) -> Dict[str, Any]:
|
||||
"""
|
||||
Fixture to get information about the sample table that comes with Nextcloud Tables.
|
||||
This assumes that the sample table exists in the Nextcloud instance.
|
||||
"""
|
||||
logger.info("Looking for sample table in Nextcloud Tables app")
|
||||
async def test_tables_list_tables(mocker):
|
||||
"""Test that list_tables correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_tables_list_response(
|
||||
tables=[
|
||||
{"id": 1, "title": "Table 1"},
|
||||
{"id": 2, "title": "Table 2"},
|
||||
]
|
||||
)
|
||||
|
||||
# Get all tables
|
||||
tables = await nc_client.tables.list_tables()
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Look for a sample table (usually created by default)
|
||||
sample_table = None
|
||||
for table in tables:
|
||||
# Common names for sample tables
|
||||
if any(
|
||||
keyword in table.get("title", "").lower()
|
||||
for keyword in ["sample", "demo", "example", "test"]
|
||||
):
|
||||
sample_table = table
|
||||
break
|
||||
|
||||
if not sample_table and tables:
|
||||
# If no sample table found, use the first available table
|
||||
sample_table = tables[0]
|
||||
logger.info(
|
||||
f"No sample table found, using first available table: {sample_table.get('title')}"
|
||||
)
|
||||
|
||||
if not sample_table:
|
||||
pytest.skip(
|
||||
"No tables found in Nextcloud Tables app. Please ensure Tables app is installed and has at least one table."
|
||||
)
|
||||
|
||||
# Get the schema for the sample table
|
||||
table_id = sample_table["id"]
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
logger.info(f"Using sample table: {sample_table.get('title')} (ID: {table_id})")
|
||||
|
||||
return {
|
||||
"table": sample_table,
|
||||
"schema": schema,
|
||||
"table_id": table_id,
|
||||
"columns": schema.get("columns", []),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_table_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Fixture to create a temporary row in the sample table for testing.
|
||||
Yields the created row data and cleans up afterward.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 42
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating temporary row in table {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row.get("id")
|
||||
|
||||
if not row_id:
|
||||
pytest.fail("Failed to get ID from created temporary row.")
|
||||
|
||||
logger.info(f"Temporary row created with ID: {row_id}")
|
||||
yield created_row
|
||||
|
||||
finally:
|
||||
if created_row and created_row.get("id"):
|
||||
row_id = created_row["id"]
|
||||
logger.info(f"Cleaning up temporary row ID: {row_id}")
|
||||
try:
|
||||
await nc_client.tables.delete_row(row_id)
|
||||
logger.info(f"Successfully deleted temporary row ID: {row_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if row was already deleted by the test itself
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"HTTP error deleting temporary row {row_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Temporary row {row_id} already deleted (404).")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary row {row_id}: {e}")
|
||||
|
||||
|
||||
async def test_tables_list_tables(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all tables available to the user.
|
||||
"""
|
||||
logger.info("Testing list_tables functionality")
|
||||
|
||||
tables = await nc_client.tables.list_tables()
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
tables = await client.list_tables()
|
||||
|
||||
assert isinstance(tables, list)
|
||||
assert len(tables) > 0, "Expected at least one table to be available"
|
||||
assert len(tables) == 2
|
||||
assert tables[0]["id"] == 1
|
||||
assert tables[0]["title"] == "Table 1"
|
||||
|
||||
# Check that each table has required fields
|
||||
for table in tables:
|
||||
assert "id" in table
|
||||
assert "title" in table
|
||||
assert isinstance(table["id"], int)
|
||||
assert isinstance(table["title"], str)
|
||||
|
||||
logger.info(f"Successfully listed {len(tables)} tables")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_tables_get_schema(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test getting the schema/structure of a specific table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
async def test_tables_get_schema(mocker):
|
||||
"""Test that get_table_schema correctly parses the API response."""
|
||||
mock_response = create_mock_table_schema_response(
|
||||
table_id=123,
|
||||
columns=[
|
||||
{"id": 1, "title": "Name", "type": "text"},
|
||||
{"id": 2, "title": "Age", "type": "number"},
|
||||
{"id": 3, "title": "Email", "type": "text"},
|
||||
],
|
||||
)
|
||||
|
||||
logger.info(f"Testing get_table_schema for table ID: {table_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
schema = await client.get_table_schema(table_id=123)
|
||||
|
||||
assert isinstance(schema, dict)
|
||||
assert "columns" in schema
|
||||
assert isinstance(schema["columns"], list)
|
||||
assert len(schema["columns"]) > 0, "Expected at least one column in the table"
|
||||
assert len(schema["columns"]) == 3
|
||||
assert schema["columns"][0]["title"] == "Name"
|
||||
|
||||
# Check that each column has required fields
|
||||
for column in schema["columns"]:
|
||||
assert "id" in column
|
||||
assert "title" in column
|
||||
assert "type" in column
|
||||
assert isinstance(column["id"], int)
|
||||
assert isinstance(column["title"], str)
|
||||
assert isinstance(column["type"], str)
|
||||
|
||||
logger.info(f"Successfully retrieved schema with {len(schema['columns'])} columns")
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/tables/123/scheme" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_read_table(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test reading rows from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
async def test_tables_get_rows(mocker):
|
||||
"""Test that get_table_rows correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"data": [
|
||||
{"columnId": 1, "value": "John"},
|
||||
{"columnId": 2, "value": 30},
|
||||
],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"tableId": 123,
|
||||
"data": [
|
||||
{"columnId": 1, "value": "Jane"},
|
||||
{"columnId": 2, "value": 25},
|
||||
],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
logger.info(f"Testing get_table_rows for table ID: {table_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Test without pagination
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
rows = await client.get_table_rows(table_id=123)
|
||||
|
||||
assert isinstance(rows, list)
|
||||
# Note: The table might be empty, so we don't assert len > 0
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["id"] == 1
|
||||
assert rows[0]["tableId"] == 123
|
||||
|
||||
# Test with pagination
|
||||
rows_limited = await nc_client.tables.get_table_rows(table_id, limit=5, offset=0)
|
||||
|
||||
assert isinstance(rows_limited, list)
|
||||
assert len(rows_limited) <= 5
|
||||
|
||||
# If there are rows, check their structure
|
||||
if rows:
|
||||
row = rows[0]
|
||||
assert "id" in row
|
||||
assert "tableId" in row
|
||||
assert "data" in row
|
||||
assert isinstance(row["id"], int)
|
||||
assert isinstance(row["tableId"], int)
|
||||
assert isinstance(row["data"], list)
|
||||
|
||||
logger.info(f"Successfully read {len(rows)} rows from table")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_tables_create_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test creating a new row in a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
async def test_tables_get_rows_with_pagination(mocker):
|
||||
"""Test that get_table_rows correctly handles pagination parameters."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"data": [],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
rows = await client.get_table_rows(table_id=123, limit=5, offset=10)
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 123
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
assert isinstance(rows, list)
|
||||
|
||||
logger.info(f"Testing create_row for table ID: {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
|
||||
assert isinstance(created_row, dict)
|
||||
assert "id" in created_row
|
||||
assert "tableId" in created_row
|
||||
assert isinstance(created_row["id"], int)
|
||||
assert created_row["tableId"] == table_id
|
||||
|
||||
# Verify the row was created by reading it back
|
||||
await anyio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
created_row_id = created_row["id"]
|
||||
|
||||
# Find the created row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == created_row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, (
|
||||
f"Created row with ID {created_row_id} not found in table"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created row with ID: {created_row_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the created row
|
||||
if created_row and created_row.get("id"):
|
||||
try:
|
||||
await nc_client.tables.delete_row(created_row["id"])
|
||||
logger.info(f"Cleaned up created row ID: {created_row['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up created row: {e}")
|
||||
# Verify pagination parameters were passed
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[1]["params"]["limit"] == 5
|
||||
assert call_args[1]["params"]["offset"] == 10
|
||||
|
||||
|
||||
async def test_tables_update_row(
|
||||
nc_client: NextcloudClient,
|
||||
temporary_table_row: Dict[str, Any],
|
||||
sample_table_info: Dict[str, Any],
|
||||
):
|
||||
"""
|
||||
Test updating an existing row in a table.
|
||||
"""
|
||||
row_id = temporary_table_row["id"]
|
||||
columns = sample_table_info["columns"]
|
||||
async def test_tables_create_row(mocker):
|
||||
"""Test that create_row correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_table_row_ocs_response(
|
||||
row_id=456,
|
||||
table_id=123,
|
||||
data=[
|
||||
{"columnId": 1, "value": "Test Name"},
|
||||
{"columnId": 2, "value": 99},
|
||||
],
|
||||
)
|
||||
|
||||
# Create updated data
|
||||
update_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
test_data = {1: "Test Name", 2: 99}
|
||||
created_row = await client.create_row(table_id=123, data=test_data)
|
||||
|
||||
# Generate updated test data based on column type
|
||||
if column_type == "text":
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
update_data[column_id] = 456
|
||||
elif column_type == "datetime":
|
||||
update_data[column_id] = "2024-12-31T23:59:59Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
update_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
update_data[column_id] = "Updated Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
assert isinstance(created_row, dict)
|
||||
assert created_row["id"] == 456
|
||||
assert created_row["tableId"] == 123
|
||||
|
||||
logger.info(f"Testing update_row for row ID: {row_id} with data: {update_data}")
|
||||
# Verify the data was transformed to string keys
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[1]["json"]["data"]["1"] == "Test Name"
|
||||
assert call_args[1]["json"]["data"]["2"] == 99
|
||||
|
||||
updated_row = await nc_client.tables.update_row(row_id, update_data)
|
||||
|
||||
async def test_tables_update_row(mocker):
|
||||
"""Test that update_row correctly parses the API response."""
|
||||
mock_response = create_mock_table_row_response(
|
||||
row_id=456,
|
||||
table_id=123,
|
||||
data=[
|
||||
{"columnId": 1, "value": "Updated Name"},
|
||||
{"columnId": 2, "value": 100},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
update_data = {1: "Updated Name", 2: 100}
|
||||
updated_row = await client.update_row(row_id=456, data=update_data)
|
||||
|
||||
assert isinstance(updated_row, dict)
|
||||
assert "id" in updated_row
|
||||
assert updated_row["id"] == row_id
|
||||
assert updated_row["id"] == 456
|
||||
|
||||
# Verify the row was updated by reading it back
|
||||
await anyio.sleep(1) # Allow potential propagation delay
|
||||
table_id = sample_table_info["table_id"]
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Find the updated row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, f"Updated row with ID {row_id} not found in table"
|
||||
|
||||
logger.info(f"Successfully updated row with ID: {row_id}")
|
||||
# Verify the PUT request was made
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "PUT"
|
||||
assert "/rows/456" in call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_delete_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test deleting a row from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# First create a row to delete
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 789
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-06-15T10:30:00Z"
|
||||
elif column_type == "select":
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Delete Option"
|
||||
else:
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating row for delete test in table ID: {table_id}")
|
||||
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row["id"]
|
||||
|
||||
logger.info(f"Testing delete_row for row ID: {row_id}")
|
||||
|
||||
# Delete the row
|
||||
delete_result = await nc_client.tables.delete_row(row_id)
|
||||
|
||||
assert isinstance(delete_result, dict)
|
||||
# The delete response might vary, but it should be successful
|
||||
|
||||
# Verify the row was deleted by trying to find it
|
||||
await anyio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Ensure the deleted row is not in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is None, f"Deleted row with ID {row_id} still found in table"
|
||||
|
||||
logger.info(f"Successfully deleted row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that deleting a non-existent row fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing delete_row for non-existent row ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.delete_row(non_existent_id)
|
||||
|
||||
# Accept both 404 and 500 as valid error responses for non-existent rows
|
||||
# The API behavior may vary between Nextcloud versions
|
||||
assert excinfo.value.response.status_code in [404, 500]
|
||||
logger.info(
|
||||
f"Deleting non-existent row ID: {non_existent_id} correctly failed with {excinfo.value.response.status_code}."
|
||||
async def test_tables_delete_row(mocker):
|
||||
"""Test that delete_row correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200, json_data={"message": "Row deleted"}
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_transform_row_data(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test the transform_row_data utility method.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
logger.info(f"Testing transform_row_data for table ID: {table_id}")
|
||||
|
||||
# Get some rows to transform
|
||||
rows = await nc_client.tables.get_table_rows(table_id, limit=5)
|
||||
|
||||
if not rows:
|
||||
logger.info("No rows to transform, skipping transform_row_data test")
|
||||
return
|
||||
|
||||
# Transform the rows
|
||||
transformed_rows = nc_client.tables.transform_row_data(rows, columns)
|
||||
|
||||
assert isinstance(transformed_rows, list)
|
||||
assert len(transformed_rows) == len(rows)
|
||||
|
||||
# Check the structure of transformed rows
|
||||
for i, transformed_row in enumerate(transformed_rows):
|
||||
original_row = rows[i]
|
||||
|
||||
assert "id" in transformed_row
|
||||
assert "tableId" in transformed_row
|
||||
assert "data" in transformed_row
|
||||
assert transformed_row["id"] == original_row["id"]
|
||||
assert transformed_row["tableId"] == original_row["tableId"]
|
||||
assert isinstance(transformed_row["data"], dict)
|
||||
|
||||
# Check that column IDs were transformed to column names
|
||||
for column in columns:
|
||||
column_title = column["title"]
|
||||
# The transformed data should have column names as keys
|
||||
# (though the column might not have data in this row)
|
||||
if any(item["columnId"] == column["id"] for item in original_row["data"]):
|
||||
assert column_title in transformed_row["data"]
|
||||
|
||||
logger.info(f"Successfully transformed {len(transformed_rows)} rows")
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_table_schema(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that getting schema for a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(
|
||||
f"Testing get_table_schema for non-existent table ID: {non_existent_id}"
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_schema(non_existent_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
result = await client.delete_row(row_id=456)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
# Verify the DELETE request was made
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "DELETE"
|
||||
assert "/rows/456" in call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(mocker):
|
||||
"""Test that deleting a non-existent row raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Row not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(TablesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("DELETE", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_row(row_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Getting schema for non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_schema(mocker):
|
||||
"""Test that getting schema for non-existent table raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Table not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(TablesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
|
||||
async def test_tables_read_nonexistent_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that reading from a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing get_table_rows for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_rows(non_existent_id)
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.get_table_schema(table_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Reading from non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_create_row_invalid_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that creating a row in a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
test_data = {1: "test value"}
|
||||
def test_tables_transform_row_data():
|
||||
"""Test the transform_row_data utility method (synchronous)."""
|
||||
# This is a pure function, no mocking needed
|
||||
client = TablesClient(None, "testuser") # Client not used for this method
|
||||
|
||||
logger.info(f"Testing create_row for non-existent table ID: {non_existent_id}")
|
||||
raw_rows = [
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": [
|
||||
{"columnId": 1, "value": "John Doe"},
|
||||
{"columnId": 2, "value": 30},
|
||||
{"columnId": 3, "value": "john@example.com"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"tableId": 123,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": [
|
||||
{"columnId": 1, "value": "Jane Smith"},
|
||||
{"columnId": 2, "value": 25},
|
||||
{"columnId": 3, "value": "jane@example.com"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.create_row(non_existent_id, test_data)
|
||||
columns = [
|
||||
{"id": 1, "title": "Name", "type": "text"},
|
||||
{"id": 2, "title": "Age", "type": "number"},
|
||||
{"id": 3, "title": "Email", "type": "text"},
|
||||
]
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Creating row in non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
transformed = client.transform_row_data(raw_rows, columns)
|
||||
|
||||
assert len(transformed) == 2
|
||||
assert transformed[0]["id"] == 1
|
||||
assert transformed[0]["data"]["Name"] == "John Doe"
|
||||
assert transformed[0]["data"]["Age"] == 30
|
||||
assert transformed[0]["data"]["Email"] == "john@example.com"
|
||||
|
||||
assert transformed[1]["data"]["Name"] == "Jane Smith"
|
||||
assert transformed[1]["data"]["Age"] == 25
|
||||
|
||||
Vendored
+1
-1
Submodule third_party/oidc updated: 515ae3a8cf...e0668dffbd
Reference in New Issue
Block a user