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