test: Move client integration tests to mocked unit tests

This commit is contained in:
Chris Coutinho
2025-10-24 05:50:25 +02:00
parent d452684535
commit 2f1bd1bbe9
7 changed files with 1792 additions and 1289 deletions
+66
View File
@@ -255,6 +255,72 @@ tests/
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container) - 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 - **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/OIDC Testing
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically. OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
+482
View File
@@ -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)
+306 -321
View File
@@ -1,386 +1,371 @@
import logging import logging
import uuid
import anyio import httpx
import pytest 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__) logger = logging.getLogger(__name__)
# Mark all tests in this module as integration tests # Mark all tests in this module as unit tests
pytestmark = pytest.mark.integration pytestmark = pytest.mark.unit
async def test_cookbook_version(nc_client: NextcloudClient): async def test_cookbook_version(mocker):
"""Test getting Cookbook app version.""" """Test that get_version correctly parses the API response."""
logger.info("Getting Cookbook app version") mock_response = create_mock_response(
version_data = await nc_client.cookbook.get_version() 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 "cookbook_version" in version_data
assert "api_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): async def test_cookbook_config(mocker):
"""Test getting Cookbook app configuration.""" """Test that get_config correctly parses the API response."""
logger.info("Getting Cookbook app configuration") mock_response = create_mock_response(
config_data = await nc_client.cookbook.get_config() 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) 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): async def test_cookbook_list_recipes(mocker):
"""Test listing all recipes.""" """Test that list_recipes correctly parses the API response."""
logger.info("Listing all recipes") mock_response = create_mock_recipe_list_response(
recipes = await nc_client.cookbook.list_recipes() 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) 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): async def test_cookbook_create_recipe(mocker):
"""Test creating a recipe and reading it back.""" """Test that create_recipe correctly parses the API response."""
# Create a test recipe # Create_recipe returns just the recipe ID
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}" 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 = { recipe_data = {
"name": recipe_name, "name": "Test Recipe",
"description": "A test recipe for integration testing", "description": "Test description",
"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",
"recipeIngredient": ["100g flour"], "recipeIngredient": ["100g flour"],
"recipeInstructions": ["Mix ingredients"], "recipeInstructions": ["Mix ingredients"],
"recipeCategory": "Original",
} }
recipe_id = await client.create_recipe(recipe_data)
logger.info(f"Creating recipe for update test: {recipe_name}") assert recipe_id == 123
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
try: mock_make_request.assert_called_once_with(
# Get the current recipe first "POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
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)
async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient): async def test_cookbook_get_recipe(mocker):
"""Test deleting a non-existent recipe. """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 mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
rather than 404. This test verifies the behavior.""" mock_make_request = mocker.patch.object(
non_existent_id = 999999999 CookbookClient, "_make_request", return_value=mock_response
)
logger.info(f"Attempting to delete non-existent recipe ID: {non_existent_id}") client = CookbookClient(mock_client, "testuser")
try: recipe = await client.get_recipe(recipe_id=123)
result = await nc_client.cookbook.delete_recipe(non_existent_id)
logger.info(f"Delete returned: {result}") assert recipe["id"] == 123
# API may succeed silently or return an error message assert recipe["name"] == "Test Recipe"
assert isinstance(result, str) assert recipe["description"] == "Test description"
except HTTPStatusError as e: assert len(recipe["recipeIngredient"]) == 2
# API may return 404 or 502 for non-existent recipes assert len(recipe["recipeInstructions"]) == 2
assert e.response.status_code in [404, 502]
logger.info(f"Delete correctly failed with {e.response.status_code}") 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): async def test_cookbook_update_recipe(mocker):
"""Test importing a recipe from a URL. """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. mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
Uses an nginx container to serve reliable, controlled test data. mock_make_request = mocker.patch.object(
""" CookbookClient, "_make_request", return_value=mock_response
)
# Use the nginx container hostname within the Docker network client = CookbookClient(mock_client, "testuser")
test_url = "http://recipes/black-pepper-tofu" updated_data = {
"name": "Updated Recipe",
logger.info(f"Importing recipe from nginx container: {test_url}") "description": "Updated description",
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
try: "recipeInstructions": ["Mix ingredients", "Cook", "Serve"],
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"],
} }
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}") assert updated_id == 123
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
try: mock_make_request.assert_called_once_with(
# Allow time for indexing "PUT", "/apps/cookbook/api/v1/recipes/123", json=updated_data
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)
async def test_cookbook_list_categories(nc_client: NextcloudClient): async def test_cookbook_delete_recipe(mocker):
"""Test listing recipe categories.""" """Test that delete_recipe correctly parses the API response."""
logger.info("Listing recipe categories") mock_response = create_mock_response(
categories = await nc_client.cookbook.list_categories() 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) 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 mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/categories")
if categories:
assert "name" in categories[0]
assert "recipe_count" in categories[0]
async def test_cookbook_get_recipes_in_category(nc_client: NextcloudClient): async def test_cookbook_get_recipes_in_category(mocker):
"""Test getting recipes in a specific category.""" """Test that get_recipes_in_category correctly parses the API response."""
# Create a recipe in a test category mock_response = create_mock_recipe_list_response(
unique_category = f"TestCategory{uuid.uuid4().hex[:8]}" recipes=[
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}" {"id": 1, "name": "Recipe 1", "recipeCategory": "Desserts"},
recipe_data = { {"id": 2, "name": "Recipe 2", "recipeCategory": "Desserts"},
"name": recipe_name, ]
"recipeCategory": unique_category, )
"recipeIngredient": ["test"],
"recipeInstructions": ["test"],
}
logger.info(f"Creating recipe in category: {unique_category}") mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
recipe_id = await nc_client.cookbook.create_recipe(recipe_data) mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
try: client = CookbookClient(mock_client, "testuser")
# Allow time for indexing recipes_in_category = await client.get_recipes_in_category("Desserts")
await anyio.sleep(2)
# Get recipes in this category assert isinstance(recipes_in_category, list)
logger.info(f"Getting recipes in category: {unique_category}") assert len(recipes_in_category) == 2
recipes_in_category = await nc_client.cookbook.get_recipes_in_category( assert recipes_in_category[0]["recipeCategory"] == "Desserts"
unique_category
)
assert isinstance(recipes_in_category, list) # Verify URL encoding happened
assert len(recipes_in_category) > 0 mock_make_request.assert_called_once()
call_args = mock_make_request.call_args[0]
# Verify our recipe is in the results assert "/apps/cookbook/api/v1/category/" in call_args[1]
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)
async def test_cookbook_list_keywords(nc_client: NextcloudClient): async def test_cookbook_list_keywords(mocker):
"""Test listing recipe keywords.""" """Test that list_keywords correctly parses the API response."""
logger.info("Listing recipe keywords") mock_response = create_mock_response(
keywords = await nc_client.cookbook.list_keywords() 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) 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 mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/keywords")
if keywords:
assert "name" in keywords[0]
assert "recipe_count" in keywords[0]
async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient): async def test_cookbook_get_recipes_with_keywords(mocker):
"""Test getting recipes with specific keywords. """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 mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
indexing time. This test uses a longer wait time.""" mock_make_request = mocker.patch.object(
# Create a recipe with unique keywords CookbookClient, "_make_request", return_value=mock_response
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"],
}
logger.info(f"Creating recipe with keyword: {unique_keyword}") client = CookbookClient(mock_client, "testuser")
recipe_id = await nc_client.cookbook.create_recipe(recipe_data) recipes_with_keywords = await client.get_recipes_with_keywords(
["vegetarian", "quick"]
)
try: assert isinstance(recipes_with_keywords, list)
# Allow extra time for indexing assert len(recipes_with_keywords) == 2
await anyio.sleep(3)
# Trigger a reindex to ensure the recipe is indexed # Verify URL encoding and keyword joining happened
await nc_client.cookbook.reindex() mock_make_request.assert_called_once()
await anyio.sleep(2) call_args = mock_make_request.call_args[0]
assert "/apps/cookbook/api/v1/tags/" in call_args[1]
# 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)
async def test_cookbook_reindex(nc_client: NextcloudClient): async def test_cookbook_reindex(mocker):
"""Test triggering a reindex of recipes.""" """Test that reindex correctly parses the API response."""
logger.info("Triggering recipe reindex") mock_response = create_mock_response(
result = await nc_client.cookbook.reindex() 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) 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
View File
@@ -1,327 +1,511 @@
import logging import logging
import uuid
import httpx
import pytest import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client.deck import DeckClient
from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack 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__) 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( async def test_deck_get_boards(mocker):
nc_client: NextcloudClient, temporary_board: dict """Test that get_boards correctly parses the API response."""
): mock_response = create_mock_response(
""" status_code=200,
Test complete board CRUD workflow using the temporary_board fixture. json_data=[
""" {
board_data = temporary_board "id": 1,
board_id = board_data["id"] "title": "Board 1",
original_title = board_data["title"] "color": "FF0000",
original_color = board_data["color"] "owner": {
"primaryKey": "testuser",
logger.info(f"Testing CRUD operations on board ID: {board_id}") "uid": "testuser",
"displayname": "Test User",
# Read the board },
read_board = await nc_client.deck.get_board(board_id) "archived": False,
assert read_board.id == board_id "labels": [],
assert read_board.title == original_title "acl": [],
assert read_board.color == original_color "permissions": {
logger.info(f"Successfully read board ID: {board_id}") "PERMISSION_READ": True,
"PERMISSION_EDIT": True,
# Update the board "PERMISSION_MANAGE": True,
updated_title = f"Updated {original_title}" "PERMISSION_SHARE": True,
updated_color = "00FF00" # Green color },
await nc_client.deck.update_board( "users": [],
board_id, title=updated_title, color=updated_color "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 mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
updated_board = await nc_client.deck.get_board(board_id) mock_make_request = mocker.patch.object(
assert updated_board.title == updated_title DeckClient, "_make_request", return_value=mock_response
assert updated_board.color == updated_color )
logger.info(f"Successfully updated board ID: {board_id}")
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) 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 mock_make_request.assert_called_once()
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")
async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient): async def test_deck_create_board(mocker):
""" """Test that create_board correctly parses the API response."""
Test operations on non-existent board return appropriate errors. mock_response = create_mock_deck_board_response(
""" board_id=123, title="New Board", color="FF0000"
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}"
) )
# Test update non-existent board mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
with pytest.raises(HTTPStatusError) as excinfo: mock_make_request = mocker.patch.object(
await nc_client.deck.update_board(non_existent_id, title="Should Fail") DeckClient, "_make_request", return_value=mock_response
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}"
) )
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( async def test_deck_get_board(mocker):
nc_client: NextcloudClient, temporary_board: dict """Test that get_board correctly parses the API response."""
): mock_response = create_mock_deck_board_response(
""" board_id=123, title="Test Board", color="0000FF"
Test complete stack CRUD workflow. )
"""
board_id = temporary_board["id"]
stack_title = f"Test Stack {uuid.uuid4().hex[:8]}"
stack_order = 1
stack = None
try: mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
# Create stack mock_make_request = mocker.patch.object(
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order) DeckClient, "_make_request", return_value=mock_response
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}")
# Read stack client = DeckClient(mock_client, "testuser")
read_stack = await nc_client.deck.get_stack(board_id, stack_id) board = await client.get_board(board_id=123)
assert read_stack.id == stack_id
assert read_stack.title == stack_title
logger.info(f"Successfully read stack ID: {stack_id}")
# Update stack assert isinstance(board, DeckBoard)
updated_title = f"Updated {stack_title}" assert board.id == 123
updated_order = 2 assert board.title == "Test Board"
await nc_client.deck.update_stack(
board_id, stack_id, title=updated_title, order=updated_order
)
# Verify update mock_make_request.assert_called_once()
updated_stack = await nc_client.deck.get_stack(board_id, stack_id) assert "/boards/123" in mock_make_request.call_args[0][1]
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}")
# 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( async def test_deck_get_board_nonexistent(mocker):
nc_client: NextcloudClient, temporary_board_with_stack: tuple """Test that getting a non-existent board raises HTTPStatusError."""
): error_response = create_mock_error_response(404, "Board not found")
"""
Test complete card CRUD workflow.
"""
board_data, stack_data = temporary_board_with_stack
board_id = board_data["id"]
stack_id = stack_data["id"]
card_title = f"Test Card {uuid.uuid4().hex[:8]}" mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
card_description = f"Test description for card {uuid.uuid4().hex[:8]}" mock_make_request = mocker.patch.object(DeckClient, "_make_request")
card = None mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("GET", "http://test.local"),
response=error_response,
)
try: client = DeckClient(mock_client, "testuser")
# 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}")
# Read card with pytest.raises(httpx.HTTPStatusError) as excinfo:
read_card = await nc_client.deck.get_card(board_id, stack_id, card_id) await client.get_board(board_id=999999999)
assert read_card.id == card_id
assert read_card.title == card_title
logger.info(f"Successfully read card ID: {card_id}")
# Update card assert excinfo.value.response.status_code == 404
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}")
# Label CRUD Tests # Stack Tests
async def test_deck_label_crud_workflow( async def test_deck_create_stack(mocker):
nc_client: NextcloudClient, temporary_board: dict """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
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
try: mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
# Create label mock_make_request = mocker.patch.object(
label = await nc_client.deck.create_label(board_id, label_title, label_color) DeckClient, "_make_request", return_value=mock_response
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}")
# Read label client = DeckClient(mock_client, "testuser")
read_label = await nc_client.deck.get_label(board_id, label_id) stack = await client.create_stack(board_id=123, title="Test Stack", order=1)
assert read_label.id == label_id
assert read_label.title == label_title
logger.info(f"Successfully read label ID: {label_id}")
# Update label assert isinstance(stack, DeckStack)
updated_title = f"Updated {label_title}" assert stack.id == 456
updated_color = "00FF00" # Green assert stack.title == "Test Stack"
await nc_client.deck.update_label( assert stack.boardId == 123
board_id, label_id, title=updated_title, color=updated_color
)
# Verify update mock_make_request.assert_called_once()
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}")
# 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): async def test_deck_get_stacks(mocker):
""" """Test that get_stacks correctly parses the API response."""
Test deck configuration operations. mock_response = create_mock_response(
""" status_code=200,
# Get config json_data=[
config = await nc_client.deck.get_config() {"id": 1, "title": "Stack 1", "boardId": 123, "order": 1, "deletedAt": 0},
assert config is not None {"id": 2, "title": "Stack 2", "boardId": 123, "order": 2, "deletedAt": 0},
logger.info(f"Retrieved deck config: {config}") ],
)
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( # Card Tests
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"]
comment_message = f"Test comment {uuid.uuid4().hex[:8]}"
comment = None
try: async def test_deck_create_card(mocker):
# Create comment """Test that create_card correctly parses the API response."""
comment = await nc_client.deck.create_comment(card_id, comment_message) mock_response = create_mock_deck_card_response(
assert comment.message == comment_message card_id=789, title="Test Card", stack_id=456, description="Test description"
comment_id = comment.id )
logger.info(f"Created comment ID: {comment_id}")
# List comments mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
comments = await nc_client.deck.get_comments(card_id) mock_make_request = mocker.patch.object(
assert isinstance(comments, list) DeckClient, "_make_request", return_value=mock_response
assert any(c.id == comment_id for c in comments) )
logger.info(f"Found comment ID: {comment_id} in card comments")
# Update comment client = DeckClient(mock_client, "testuser")
updated_message = f"Updated {comment_message}" card = await client.create_card(
updated_comment = await nc_client.deck.update_comment( board_id=123, stack_id=456, title="Test Card", description="Test description"
card_id, comment_id, updated_message )
)
assert updated_comment.message == updated_message
logger.info(f"Successfully updated comment ID: {comment_id}")
finally: assert isinstance(card, DeckCard)
# Clean up - delete comment assert card.id == 789
if comment and hasattr(comment, "id"): assert card.title == "Test Card"
try: assert card.description == "Test description"
await nc_client.deck.delete_comment(card_id, comment.id)
logger.info(f"Cleaned up comment ID: {comment.id}") mock_make_request.assert_called_once()
except Exception as e:
logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}")
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()
+219 -224
View File
@@ -1,260 +1,255 @@
import logging import logging
import uuid # Keep uuid if needed for generating unique data within tests
import anyio import httpx
import pytest import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client.notes import NotesClient
from tests.client.conftest import create_mock_error_response, create_mock_note_response
# Note: nc_client fixture is now session-scoped in conftest.py
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Mark all tests in this module as integration tests # Mark all tests in this module as unit tests
pytestmark = pytest.mark.integration pytestmark = pytest.mark.unit
async def test_notes_api_create_and_read( async def test_notes_api_get_note(mocker):
nc_client: NextcloudClient, temporary_note: dict """Test that get_note correctly parses the API response."""
): # Create mock response
""" mock_response = create_mock_note_response(
Tests creating a note via the API (using fixture) and then reading it back. note_id=123,
""" title="Test Note",
created_note_data = temporary_note # Get data from fixture content="Test content",
note_id = created_note_data["id"] category="Test",
etag="abc123",
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
) )
logger.info(f"Note updated: {updated_note}")
assert updated_note["id"] == note_id # Mock the _make_request method
assert updated_note["title"] == update_title mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
assert updated_note["content"] == update_content mock_make_request = mocker.patch.object(
assert ( NotesClient, "_make_request", return_value=mock_response
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
) )
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 # Create client and test
logger.info( client = NotesClient(mock_client, "testuser")
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}" 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( mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
note_id=note_id, mock_make_request = mocker.patch.object(
etag=original_etag, # Use the stale etag NotesClient, "_make_request", return_value=mock_response
title="This update should fail due to conflict", )
# category=created_note_data["category"] # Pass category if required
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): async def test_notes_api_delete_note(mocker):
""" """Test that delete_note makes the correct API call."""
Tests deleting a note that doesn't exist fails with 404. # Mock get_note response (to fetch category for cleanup)
""" get_response = create_mock_note_response(note_id=123, category="Test")
non_existent_id = 999999999 # Use an ID highly unlikely to exist
logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}") # Mock delete response
with pytest.raises(HTTPStatusError) as excinfo: delete_response = create_mock_note_response(note_id=123)
await nc_client.notes.delete_note(note_id=non_existent_id)
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 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",
) )
# Mock update response with appended content
async def test_notes_api_append_content_to_existing_note( update_response = create_mock_note_response(
nc_client: NextcloudClient, temporary_note: dict note_id=123,
): content="Original content\n---\nAppended content",
""" etag="new_etag",
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
) )
logger.info(f"Note after append: {updated_note}")
# Verify the note was updated mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
assert updated_note["id"] == note_id mock_make_request = mocker.patch.object(NotesClient, "_make_request")
assert "etag" in updated_note # First call: GET (from get_note), second call: PUT (from update)
assert updated_note["etag"] != created_note_data["etag"] # Etag must change mock_make_request.side_effect = [get_response, update_response]
# Verify content has the separator and appended text client = NotesClient(mock_client, "testuser")
expected_content = original_content + "\n---\n" + append_text updated_note = await client.append_content(note_id=123, content="Appended content")
assert updated_note["content"] == expected_content
# Verify by reading the note again assert updated_note["content"] == "Original content\n---\nAppended content"
await anyio.sleep(1) # Allow potential propagation delay assert updated_note["etag"] == "new_etag"
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}")
async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient): async def test_notes_api_append_content_to_empty_note(mocker):
""" """Test that appending to empty note doesn't add separator."""
Tests appending content to an empty note (no separator should be added). # Mock get_note response with empty content
""" get_response = create_mock_note_response(
# Create an empty note note_id=123,
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,
content="", content="",
category=test_category, # Empty content etag="old_etag",
)
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
) )
expected_content_after_first = original_content + "\n---\n" + first_append # Mock update response with just the appended text (no separator)
assert updated_note["content"] == expected_content_after_first update_response = create_mock_note_response(
note_id=123,
# Second append content="First content",
updated_note = await nc_client.notes.append_content( etag="new_etag",
note_id=note_id, content=second_append
) )
expected_content_after_second = ( mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
expected_content_after_first + "\n---\n" + second_append 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 client = NotesClient(mock_client, "testuser")
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}")
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 assert excinfo.value.response.status_code == 404
logger.info(
f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404."
)
+263 -472
View File
@@ -1,535 +1,326 @@
import logging import logging
import uuid
from typing import Any, Dict
import anyio import httpx
import pytest 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__) logger = logging.getLogger(__name__)
# Mark all tests in this module as integration tests # Mark all tests in this module as unit tests
pytestmark = pytest.mark.integration pytestmark = pytest.mark.unit
@pytest.fixture(scope="module") async def test_tables_list_tables(mocker):
async def sample_table_info(nc_client: NextcloudClient) -> Dict[str, Any]: """Test that list_tables correctly parses the API response (OCS format)."""
""" mock_response = create_mock_tables_list_response(
Fixture to get information about the sample table that comes with Nextcloud Tables. tables=[
This assumes that the sample table exists in the Nextcloud instance. {"id": 1, "title": "Table 1"},
""" {"id": 2, "title": "Table 2"},
logger.info("Looking for sample table in Nextcloud Tables app") ]
)
# Get all tables mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
tables = await nc_client.tables.list_tables() mock_make_request = mocker.patch.object(
TablesClient, "_make_request", return_value=mock_response
)
# Look for a sample table (usually created by default) client = TablesClient(mock_client, "testuser")
sample_table = None tables = await client.list_tables()
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()
assert isinstance(tables, list) 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 mock_make_request.assert_called_once()
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")
async def test_tables_get_schema( async def test_tables_get_schema(mocker):
nc_client: NextcloudClient, sample_table_info: Dict[str, Any] """Test that get_table_schema correctly parses the API response."""
): mock_response = create_mock_table_schema_response(
""" table_id=123,
Test getting the schema/structure of a specific table. columns=[
""" {"id": 1, "title": "Name", "type": "text"},
table_id = sample_table_info["table_id"] {"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 isinstance(schema, dict)
assert "columns" in schema assert "columns" in schema
assert isinstance(schema["columns"], list) assert len(schema["columns"]) == 3
assert len(schema["columns"]) > 0, "Expected at least one column in the table" assert schema["columns"][0]["title"] == "Name"
# Check that each column has required fields mock_make_request.assert_called_once()
for column in schema["columns"]: assert "/tables/123/scheme" in mock_make_request.call_args[0][1]
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")
async def test_tables_read_table( async def test_tables_get_rows(mocker):
nc_client: NextcloudClient, sample_table_info: Dict[str, Any] """Test that get_table_rows correctly parses the API response."""
): mock_response = create_mock_response(
""" status_code=200,
Test reading rows from a table. json_data=[
""" {
table_id = sample_table_info["table_id"] "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 client = TablesClient(mock_client, "testuser")
rows = await nc_client.tables.get_table_rows(table_id) rows = await client.get_table_rows(table_id=123)
assert isinstance(rows, list) 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 mock_make_request.assert_called_once()
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")
async def test_tables_create_row( async def test_tables_get_rows_with_pagination(mocker):
nc_client: NextcloudClient, sample_table_info: Dict[str, Any] """Test that get_table_rows correctly handles pagination parameters."""
): mock_response = create_mock_response(
""" status_code=200,
Test creating a new row in a table. json_data=[
""" {
table_id = sample_table_info["table_id"] "id": 1,
columns = sample_table_info["columns"] "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 mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
test_data = {} mock_make_request = mocker.patch.object(
unique_suffix = uuid.uuid4().hex[:8] TablesClient, "_make_request", return_value=mock_response
)
for column in columns: client = TablesClient(mock_client, "testuser")
column_id = column["id"] rows = await client.get_table_rows(table_id=123, limit=5, offset=10)
column_type = column.get("type", "text")
column_title = column.get("title", f"column_{column_id}")
# Generate test data based on column type assert isinstance(rows, list)
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}"
logger.info(f"Testing create_row for table ID: {table_id} with data: {test_data}") # Verify pagination parameters were passed
call_args = mock_make_request.call_args
created_row = None assert call_args[1]["params"]["limit"] == 5
try: assert call_args[1]["params"]["offset"] == 10
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}")
async def test_tables_update_row( async def test_tables_create_row(mocker):
nc_client: NextcloudClient, """Test that create_row correctly parses the API response (OCS format)."""
temporary_table_row: Dict[str, Any], mock_response = create_mock_table_row_ocs_response(
sample_table_info: Dict[str, Any], row_id=456,
): table_id=123,
""" data=[
Test updating an existing row in a table. {"columnId": 1, "value": "Test Name"},
""" {"columnId": 2, "value": 99},
row_id = temporary_table_row["id"] ],
columns = sample_table_info["columns"] )
# Create updated data mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
update_data = {} mock_make_request = mocker.patch.object(
unique_suffix = uuid.uuid4().hex[:8] TablesClient, "_make_request", return_value=mock_response
)
for column in columns: client = TablesClient(mock_client, "testuser")
column_id = column["id"] test_data = {1: "Test Name", 2: 99}
column_type = column.get("type", "text") created_row = await client.create_row(table_id=123, data=test_data)
column_title = column.get("title", f"column_{column_id}")
# Generate updated test data based on column type assert isinstance(created_row, dict)
if column_type == "text": assert created_row["id"] == 456
update_data[column_id] = f"Updated {column_title} {unique_suffix}" assert created_row["tableId"] == 123
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}"
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 isinstance(updated_row, dict)
assert "id" in updated_row assert updated_row["id"] == 456
assert updated_row["id"] == row_id
# Verify the row was updated by reading it back # Verify the PUT request was made
await anyio.sleep(1) # Allow potential propagation delay call_args = mock_make_request.call_args
table_id = sample_table_info["table_id"] assert call_args[0][0] == "PUT"
rows = await nc_client.tables.get_table_rows(table_id) assert "/rows/456" in call_args[0][1]
# 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}")
async def test_tables_delete_row( async def test_tables_delete_row(mocker):
nc_client: NextcloudClient, sample_table_info: Dict[str, Any] """Test that delete_row correctly parses the API response."""
): mock_response = create_mock_response(
""" status_code=200, json_data={"message": "Row deleted"}
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}."
) )
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
async def test_tables_transform_row_data( mock_make_request = mocker.patch.object(
nc_client: NextcloudClient, sample_table_info: Dict[str, Any] TablesClient, "_make_request", return_value=mock_response
):
"""
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}"
) )
with pytest.raises(HTTPStatusError) as excinfo: client = TablesClient(mock_client, "testuser")
await nc_client.tables.get_table_schema(non_existent_id) 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 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): with pytest.raises(httpx.HTTPStatusError) as excinfo:
""" await client.get_table_schema(table_id=999999999)
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)
assert excinfo.value.response.status_code == 404 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): def test_tables_transform_row_data():
""" """Test the transform_row_data utility method (synchronous)."""
Test that creating a row in a non-existent table fails appropriately. # This is a pure function, no mocking needed
""" client = TablesClient(None, "testuser") # Client not used for this method
non_existent_id = 999999999 # Use an ID highly unlikely to exist
test_data = {1: "test value"}
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: columns = [
await nc_client.tables.create_row(non_existent_id, test_data) {"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 transformed = client.transform_row_data(raw_rows, columns)
logger.info(
f"Creating row in non-existent table ID: {non_existent_id} correctly failed with 404." 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