diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b2ea2f..29c6021 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,13 @@ jobs: done echo "Service is ready (returned 401)." + - name: Install notes app + run: | + docker compose exec app php occ apps:enable notes + # Add subsequent steps here, e.g., running tests - # - name: Run tests - # run: | - # echo "Running tests..." - # # Your test commands here + - name: Run tests + run: | + apt update && apt install python3-poetry && apt clean + poetry install + poetry run python -m pytest \ No newline at end of file diff --git a/nextcloud_mcp_server/client.py b/nextcloud_mcp_server/client.py index d55f14a..f065a43 100644 --- a/nextcloud_mcp_server/client.py +++ b/nextcloud_mcp_server/client.py @@ -61,17 +61,17 @@ class NextcloudClient: return response.json() def notes_get_settings(self): - response = self._client.get("index.php/apps/notes/api/v1/settings") + response = self._client.get("/apps/notes/api/v1/settings") response.raise_for_status() return response.json() def notes_get_all(self): - response = self._client.get("index.php/apps/notes/api/v1/notes") + response = self._client.get("/apps/notes/api/v1/notes") response.raise_for_status() return response.json() def notes_get_note(self, *, note_id: int): - response = self._client.get(f"index.php/apps/notes/api/v1/notes/{note_id}") + response = self._client.get(f"/apps/notes/api/v1/notes/{note_id}") response.raise_for_status() return response.json() @@ -91,7 +91,7 @@ class NextcloudClient: body.update({"category": category}) response = self._client.post( - url="index.php/apps/notes/api/v1/notes", + url="/apps/notes/api/v1/notes", json=body, ) response.raise_for_status() @@ -123,7 +123,7 @@ class NextcloudClient: ) # Ensure conditional PUT using If-Match header is active response = self._client.put( - url=f"index.php/apps/notes/api/v1/notes/{note_id}", + url=f"/apps/notes/api/v1/notes/{note_id}", json=body, headers={"If-Match": f'"{etag}"'}, # This was current_etag in the loop ) @@ -155,6 +155,6 @@ class NextcloudClient: return search_results def notes_delete_note(self, *, note_id: int): - response = self._client.delete(f"index.php/apps/notes/api/v1/notes/{note_id}") + response = self._client.delete(f"/apps/notes/api/v1/notes/{note_id}") response.raise_for_status() return response.json() diff --git a/poetry.lock b/poetry.lock index 5c6d77c..e720343 100644 --- a/poetry.lock +++ b/poetry.lock @@ -129,11 +129,11 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "decorator" @@ -248,6 +248,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + [[package]] name = "ipython" version = "9.2.0" @@ -416,7 +428,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -483,6 +495,22 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.14.1)"] +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "prompt-toolkit" version = "3.0.51" @@ -699,6 +727,46 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pytest" +version = "8.3.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-httpx" +version = "0.35.0" +description = "Send responses to httpx." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744"}, + {file = "pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f"}, +] + +[package.dependencies] +httpx = "==0.28.*" +pytest = "==8.*" + +[package.extras] +testing = ["pytest-asyncio (==0.24.*)", "pytest-cov (==6.*)"] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -924,4 +992,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "0cfa237f52575752ea90de13e5cfd4b11a7261bdb803ae4cab7f11bce73c7fcf" +content-hash = "b1f706fc8649cb39ac581f26cf9efd331817a09d1a3acde691cc28df0c0ff747" diff --git a/pyproject.toml b/pyproject.toml index 07025f3..ab2d35d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,11 @@ dependencies = [ [project.scripts] nc-mcp-server = "nextcloud_mcp_server.server:run" +[tool.pytest.ini_options] +markers = [ + "integration: marks tests as slow (deselect with '-m \"not slow\"')" +] + [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] @@ -23,3 +28,4 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.group.dev.dependencies] black = "^25.1.0" ipython = "^9.0.2" +pytest = "^8.2.2" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..20d8b10 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,147 @@ +import pytest +import os +import time +import uuid +from httpx import HTTPStatusError + +from nextcloud_mcp_server.client import NextcloudClient + +# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set + +@pytest.fixture(scope="module") +def nc_client() -> NextcloudClient: + """ + Fixture to create a NextcloudClient instance for integration tests. + Reads credentials from environment variables. + Scope is 'module' so the client is reused for all tests in this file. + """ + # Basic check to ensure env vars seem present - tests will fail properly if not + assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set" + assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set" + assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set" + return NextcloudClient.from_env() + +@pytest.mark.integration +def test_note_crud_integration(nc_client: NextcloudClient): + """ + Integration test for the complete CRUD (Create, Read, Update, Delete) + lifecycle of a note. + """ + # --- Create --- + unique_id = str(uuid.uuid4()) # To ensure note is unique for this test run + create_title = f"Integration Test Note {unique_id}" + create_content = f"Content for integration test {unique_id}" + create_category = "IntegrationTesting" + + created_note = None # Initialize to ensure cleanup happens even if create fails mid-assert + try: + print(f"\nAttempting to create note: {create_title}") + created_note = nc_client.notes_create_note( + title=create_title, content=create_content, category=create_category + ) + print(f"Note created: {created_note}") + + assert created_note is not None + assert "id" in created_note + assert created_note["title"] == create_title + assert created_note["content"] == create_content + assert created_note["category"] == create_category + assert "etag" in created_note + note_id = created_note["id"] + etag = created_note["etag"] + + # Add a small delay to allow Nextcloud to process if needed + time.sleep(1) + + # --- Read (Verify Create) --- + print(f"Attempting to read note ID: {note_id}") + read_note = nc_client.notes_get_note(note_id=note_id) + print(f"Note read: {read_note}") + assert read_note["id"] == note_id + assert read_note["title"] == create_title + assert read_note["content"] == create_content + assert read_note["category"] == create_category + # Etag might change even on read in some systems, so don't assert etag equality here + + # --- Update --- + update_title = f"Updated Test Note {unique_id}" + update_content = f"Updated content {unique_id}" + # Use the etag from the *creation* for the update's If-Match header + print(f"Attempting to update note ID: {note_id} with etag: {etag}") + updated_note = nc_client.notes_update_note( + note_id=note_id, + etag=etag, + title=update_title, + content=update_content, + # category=create_category # Keep category same or update if needed + ) + print(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"] == create_category # Category wasn't updated + assert "etag" in updated_note + assert updated_note["etag"] != etag # Etag must change on update + new_etag = updated_note["etag"] + + # Add a small delay + time.sleep(1) + + # --- Read (Verify Update) --- + print(f"Attempting to read updated note ID: {note_id}") + read_updated_note = nc_client.notes_get_note(note_id=note_id) + print(f"Updated note read: {read_updated_note}") + assert read_updated_note["id"] == note_id + assert read_updated_note["title"] == update_title + assert read_updated_note["content"] == update_content + # Don't assert etag equality here either + + # --- Test Update Conflict (Precondition Failed) --- + print(f"Attempting to update note ID: {note_id} with OLD etag: {etag}") + with pytest.raises(HTTPStatusError) as excinfo: + nc_client.notes_update_note( + note_id=note_id, + etag=etag, # Use the OLD etag + title="This update should fail", + ) + assert excinfo.value.response.status_code == 412 # Precondition Failed + print("Update with old etag correctly failed with 412.") + + finally: + # --- Delete --- + if created_note and "id" in created_note: + note_id_to_delete = created_note["id"] + print(f"Attempting to delete note ID: {note_id_to_delete}") + try: + delete_response = nc_client.notes_delete_note(note_id=note_id_to_delete) + print(f"Delete response: {delete_response}") + # Check if delete returns the deleted object or just status + # Assuming it returns the object based on previous tests + assert delete_response["id"] == note_id_to_delete + print(f"Note ID: {note_id_to_delete} deleted successfully.") + + # --- Verify Delete --- + print(f"Attempting to read deleted note ID: {note_id_to_delete}") + with pytest.raises(HTTPStatusError) as excinfo_del: + nc_client.notes_get_note(note_id=note_id_to_delete) + assert excinfo_del.value.response.status_code == 404 + print(f"Reading deleted note ID: {note_id_to_delete} correctly failed with 404.") + + except HTTPStatusError as e: + # If deletion fails unexpectedly, log it but don't fail the test here + # as the primary goal was CRUD, and cleanup failure is secondary. + print(f"Error during cleanup (deleting note {note_id_to_delete}): {e}") + except Exception as e: + print(f"Unexpected error during cleanup: {e}") + else: + print("Skipping delete step as note creation might have failed or ID was not available.") + +@pytest.mark.integration +def test_delete_nonexistent_note(nc_client: NextcloudClient): + """Test deleting a note that doesn't exist.""" + non_existent_id = 999999999 # Use an ID highly unlikely to exist + print(f"\nAttempting to delete non-existent note ID: {non_existent_id}") + with pytest.raises(HTTPStatusError) as excinfo: + nc_client.notes_delete_note(note_id=non_existent_id) + assert excinfo.value.response.status_code == 404 + print(f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404.")