Two unrelated CI failures on this branch, one fix each: - tests/integration/test_deck_vector_search.py: pass str(card.id) to get_chunk_with_context. The function's contract is doc_id: str (keyword-indexed in Qdrant), and real callers (viz_routes.py URL path, server/semantic.py via str(result.id)) all stringify. The test was the only int caller, hitting the .isdigit() guard added earlier on this branch. - tests/server/login_flow/test_login_flow_integration.py: test_check_status_provisioned now accepts scopes=None as valid. Per ProvisionStatusResponse in models/auth.py, None is the documented sentinel for "all scopes granted" — and the web provisioning path (provision_routes.py, used by Astrolabe's "Enable Semantic Search" flow exercised by the new regression test added on this branch) stores exactly that. The previous is-not-None assertion hid behind test order until that flow ran. - Replace anyio.sleep(0) with anyio.lowlevel.checkpoint() Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
659 lines
26 KiB
Python
659 lines
26 KiB
Python
"""Integration tests for Login Flow v2 (ADR-022).
|
|
|
|
Tests the complete Login Flow v2 provisioning and verifies all MCP tools
|
|
work through the stored app password. This validates the end-to-end flow:
|
|
|
|
OAuth token (MCP session) → Login Flow v2 (browser) → App password → Nextcloud API
|
|
|
|
Test categories:
|
|
1. Auth tools: provision, check status, scope management
|
|
2. Notes: CRUD operations
|
|
3. Calendar: events and todos
|
|
4. Contacts: address book and contact operations
|
|
5. Files (WebDAV): directory listing, file operations
|
|
6. Deck: board management
|
|
7. Cookbook: recipe operations
|
|
8. Tables: table operations
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
|
|
import pytest
|
|
from mcp import ClientSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
pytestmark = [pytest.mark.login_flow, pytest.mark.integration]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowAuthTools:
|
|
"""Test Login Flow v2 auth tools."""
|
|
|
|
async def test_check_status_provisioned(
|
|
self, nc_mcp_login_flow_client: ClientSession
|
|
):
|
|
"""After fixture setup, status should be 'provisioned'."""
|
|
result = await nc_mcp_login_flow_client.call_tool("nc_auth_check_status", {})
|
|
data = json.loads(result.content[0].text)
|
|
assert data["status"] == "provisioned"
|
|
assert data["username"] is not None
|
|
# ``scopes`` may legitimately be ``None`` — per ProvisionStatusResponse
|
|
# in models/auth.py, ``None`` is the documented sentinel for "all
|
|
# scopes granted" and is what the web provisioning path
|
|
# (``provision_routes.py``, used by Astrolabe's "Enable Semantic
|
|
# Search" flow) stores. So accept either a non-empty list or None;
|
|
# the field's *presence* in the payload is what we care about here.
|
|
assert data["scopes"] is None or len(data["scopes"]) > 0
|
|
logger.info(f"Provisioned as: {data['username']}, scopes: {data['scopes']}")
|
|
|
|
async def test_provision_access_already_provisioned(
|
|
self, nc_mcp_login_flow_client: ClientSession
|
|
):
|
|
"""Calling provision when already provisioned returns 'already_provisioned'."""
|
|
result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_auth_provision_access", {}
|
|
)
|
|
data = json.loads(result.content[0].text)
|
|
assert data["status"] == "already_provisioned"
|
|
assert "already provisioned" in data["message"].lower()
|
|
|
|
async def test_list_tools_includes_auth_tools(
|
|
self, nc_mcp_login_flow_client: ClientSession
|
|
):
|
|
"""Login Flow server should expose auth tools."""
|
|
tools = await nc_mcp_login_flow_client.list_tools()
|
|
tool_names = [t.name for t in tools.tools]
|
|
assert "nc_auth_provision_access" in tool_names
|
|
assert "nc_auth_check_status" in tool_names
|
|
assert "nc_auth_update_scopes" in tool_names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowNotes:
|
|
"""Test Notes CRUD via Login Flow v2 app password."""
|
|
|
|
async def test_notes_crud(self, nc_mcp_login_flow_client: ClientSession):
|
|
"""Full Notes CRUD: create → read → update → search → delete."""
|
|
suffix = uuid.uuid4().hex[:8]
|
|
title = f"LoginFlow Test {suffix}"
|
|
content = f"Content for {suffix}"
|
|
category = "LoginFlowTest"
|
|
|
|
# Create
|
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_notes_create_note",
|
|
{"title": title, "content": content, "category": category},
|
|
)
|
|
assert create_result.isError is False, (
|
|
f"Create failed: {create_result.content[0].text}"
|
|
)
|
|
note = json.loads(create_result.content[0].text)
|
|
note_id = note["id"]
|
|
etag = note["etag"]
|
|
logger.info(f"Created note {note_id}")
|
|
|
|
try:
|
|
# Read
|
|
read_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_notes_get_note", {"note_id": note_id}
|
|
)
|
|
assert read_result.isError is False
|
|
read_data = json.loads(read_result.content[0].text)
|
|
assert read_data["title"] == title
|
|
assert read_data["content"] == content
|
|
|
|
# Update (title, content, category are all required params)
|
|
updated_content = f"Updated content for {suffix}"
|
|
update_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_notes_update_note",
|
|
{
|
|
"note_id": note_id,
|
|
"title": title,
|
|
"content": updated_content,
|
|
"category": category,
|
|
"etag": etag,
|
|
},
|
|
)
|
|
assert update_result.isError is False, (
|
|
f"Update failed: {update_result.content[0].text}"
|
|
)
|
|
updated = json.loads(update_result.content[0].text)
|
|
# UpdateNoteResponse returns id, title, category, etag (no content)
|
|
assert updated["title"] == title
|
|
assert "etag" in updated
|
|
|
|
# Append
|
|
append_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_notes_append_content",
|
|
{"note_id": note_id, "content": "\n\nAppended text"},
|
|
)
|
|
assert append_result.isError is False
|
|
|
|
# Search
|
|
search_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_notes_search_notes", {"query": suffix}
|
|
)
|
|
assert search_result.isError is False
|
|
search_data = json.loads(search_result.content[0].text)
|
|
assert search_data["total_found"] >= 1
|
|
|
|
finally:
|
|
# Delete
|
|
await nc_mcp_login_flow_client.call_tool(
|
|
"nc_notes_delete_note", {"note_id": note_id}
|
|
)
|
|
logger.info(f"Deleted note {note_id}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar Events
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowCalendarEvents:
|
|
"""Test Calendar event operations via Login Flow v2."""
|
|
|
|
async def test_calendar_events_workflow(
|
|
self, nc_mcp_login_flow_client: ClientSession
|
|
):
|
|
"""List calendars → create event → get event → delete event."""
|
|
# List calendars
|
|
cal_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_list_calendars", {}
|
|
)
|
|
assert cal_result.isError is False
|
|
cal_data = json.loads(cal_result.content[0].text)
|
|
calendars = cal_data.get("calendars", [])
|
|
assert len(calendars) > 0
|
|
calendar_name = calendars[0].get("name", "personal")
|
|
logger.info(f"Using calendar: {calendar_name}")
|
|
|
|
suffix = uuid.uuid4().hex[:8]
|
|
event_title = f"LoginFlow Event {suffix}"
|
|
|
|
# Create event (uses start_datetime/end_datetime)
|
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_create_event",
|
|
{
|
|
"calendar_name": calendar_name,
|
|
"title": event_title,
|
|
"start_datetime": "2026-03-01T10:00:00",
|
|
"end_datetime": "2026-03-01T11:00:00",
|
|
"description": f"Test event for login flow {suffix}",
|
|
},
|
|
)
|
|
assert create_result.isError is False, (
|
|
f"Create event failed: {create_result.content[0].text}"
|
|
)
|
|
event_data = json.loads(create_result.content[0].text)
|
|
event_uid = event_data.get("uid") or event_data.get("event_uid")
|
|
logger.info(f"Created event: {event_uid}")
|
|
|
|
try:
|
|
# Get event
|
|
get_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_get_event",
|
|
{"calendar_name": calendar_name, "event_uid": event_uid},
|
|
)
|
|
assert get_result.isError is False
|
|
|
|
finally:
|
|
# Delete event
|
|
await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_delete_event",
|
|
{"calendar_name": calendar_name, "event_uid": event_uid},
|
|
)
|
|
logger.info(f"Deleted event {event_uid}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar Todos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowCalendarTodos:
|
|
"""Test Calendar todo (VTODO) operations via Login Flow v2."""
|
|
|
|
async def test_todo_workflow(self, nc_mcp_login_flow_client: ClientSession):
|
|
"""Create todo → list todos → update todo → delete todo."""
|
|
cal_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_list_calendars", {}
|
|
)
|
|
cal_data = json.loads(cal_result.content[0].text)
|
|
calendars = cal_data.get("calendars", [])
|
|
calendar_name = calendars[0].get("name", "personal")
|
|
|
|
suffix = uuid.uuid4().hex[:8]
|
|
todo_title = f"LoginFlow Todo {suffix}"
|
|
|
|
# Create todo (uses 'summary', not 'title')
|
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_create_todo",
|
|
{
|
|
"calendar_name": calendar_name,
|
|
"summary": todo_title,
|
|
"description": f"Test todo {suffix}",
|
|
},
|
|
)
|
|
if create_result.isError:
|
|
error_text = create_result.content[0].text
|
|
if "AuthorizationError" in error_text:
|
|
pytest.skip(
|
|
f"Calendar '{calendar_name}' does not support VTODO: {error_text}"
|
|
)
|
|
raise AssertionError(f"Create todo failed: {error_text}")
|
|
todo_data = json.loads(create_result.content[0].text)
|
|
todo_uid = todo_data.get("uid") or todo_data.get("todo_uid")
|
|
logger.info(f"Created todo: {todo_uid}")
|
|
|
|
try:
|
|
# List todos
|
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_list_todos",
|
|
{"calendar_name": calendar_name},
|
|
)
|
|
assert list_result.isError is False
|
|
|
|
# Update todo
|
|
update_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_update_todo",
|
|
{
|
|
"calendar_name": calendar_name,
|
|
"todo_uid": todo_uid,
|
|
"percent_complete": 50,
|
|
},
|
|
)
|
|
assert update_result.isError is False
|
|
|
|
finally:
|
|
await nc_mcp_login_flow_client.call_tool(
|
|
"nc_calendar_delete_todo",
|
|
{"calendar_name": calendar_name, "todo_uid": todo_uid},
|
|
)
|
|
logger.info(f"Deleted todo {todo_uid}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Contacts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowContacts:
|
|
"""Test Contacts (CardDAV) operations via Login Flow v2."""
|
|
|
|
async def test_contacts_workflow(self, nc_mcp_login_flow_client: ClientSession):
|
|
"""Create addressbook → create contact → list contacts → cleanup."""
|
|
suffix = uuid.uuid4().hex[:8]
|
|
ab_name = f"lf-test-{suffix}"
|
|
contact_uid = f"login-flow-test-{suffix}"
|
|
contact_fn = f"LoginFlow Contact {suffix}"
|
|
|
|
# List address books (basic smoke test)
|
|
ab_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_contacts_list_addressbooks", {}
|
|
)
|
|
assert ab_result.isError is False
|
|
|
|
# Create a temporary address book for isolation
|
|
create_ab_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_contacts_create_addressbook",
|
|
{"name": ab_name, "display_name": f"Login Flow Test {suffix}"},
|
|
)
|
|
assert create_ab_result.isError is False, (
|
|
f"Create addressbook failed: {create_ab_result.content[0].text}"
|
|
)
|
|
logger.info(f"Created address book: {ab_name}")
|
|
|
|
try:
|
|
# Create contact (requires addressbook, uid, contact_data dict)
|
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_contacts_create_contact",
|
|
{
|
|
"addressbook": ab_name,
|
|
"uid": contact_uid,
|
|
"contact_data": {
|
|
"fn": contact_fn,
|
|
"email": f"test-{suffix}@example.com",
|
|
},
|
|
},
|
|
)
|
|
assert create_result.isError is False, (
|
|
f"Create contact failed: {create_result.content[0].text}"
|
|
)
|
|
logger.info(f"Created contact: {contact_uid}")
|
|
|
|
# List contacts in our clean addressbook
|
|
# Note: may fail due to server-side Pydantic bug where ContactField.value
|
|
# is a dict (structured email) but model expects string
|
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_contacts_list_contacts",
|
|
{"addressbook": ab_name},
|
|
)
|
|
if list_result.isError:
|
|
error_text = list_result.content[0].text
|
|
if "ContactField" in error_text:
|
|
logger.warning(
|
|
f"Known server bug: ContactField validation: {error_text}"
|
|
)
|
|
else:
|
|
raise AssertionError(f"List contacts failed: {error_text}")
|
|
else:
|
|
list_data = json.loads(list_result.content[0].text)
|
|
contacts = list_data.get("contacts", [])
|
|
contact_uids = [c.get("uid", "") for c in contacts]
|
|
assert contact_uid in contact_uids, (
|
|
f"Created contact {contact_uid} not found in list"
|
|
)
|
|
|
|
# Delete contact
|
|
await nc_mcp_login_flow_client.call_tool(
|
|
"nc_contacts_delete_contact",
|
|
{"addressbook": ab_name, "uid": contact_uid},
|
|
)
|
|
logger.info(f"Deleted contact {contact_uid}")
|
|
|
|
finally:
|
|
# Always clean up the temporary address book
|
|
await nc_mcp_login_flow_client.call_tool(
|
|
"nc_contacts_delete_addressbook",
|
|
{"name": ab_name},
|
|
)
|
|
logger.info(f"Deleted address book {ab_name}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Files (WebDAV)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowFiles:
|
|
"""Test WebDAV file operations via Login Flow v2."""
|
|
|
|
async def test_file_operations(self, nc_mcp_login_flow_client: ClientSession):
|
|
"""Create dir → write file → read file → list dir → delete."""
|
|
suffix = uuid.uuid4().hex[:8]
|
|
dir_path = f"/LoginFlowTest_{suffix}"
|
|
file_path = f"{dir_path}/test_file.txt"
|
|
file_content = f"Hello from Login Flow v2 test {suffix}"
|
|
|
|
# Create directory
|
|
mkdir_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_webdav_create_directory", {"path": dir_path}
|
|
)
|
|
assert mkdir_result.isError is False, (
|
|
f"Create dir failed: {mkdir_result.content[0].text}"
|
|
)
|
|
logger.info(f"Created directory: {dir_path}")
|
|
|
|
try:
|
|
# Write file
|
|
write_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_webdav_write_file",
|
|
{"path": file_path, "content": file_content},
|
|
)
|
|
assert write_result.isError is False
|
|
|
|
# Read file
|
|
read_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_webdav_read_file", {"path": file_path}
|
|
)
|
|
assert read_result.isError is False
|
|
read_data = json.loads(read_result.content[0].text)
|
|
assert file_content in read_data.get("content", "")
|
|
|
|
# List directory (response uses 'files' field, each with 'name')
|
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_webdav_list_directory", {"path": dir_path}
|
|
)
|
|
assert list_result.isError is False
|
|
list_data = json.loads(list_result.content[0].text)
|
|
files = list_data.get("files", [])
|
|
file_names = [f.get("name", "") for f in files]
|
|
assert "test_file.txt" in file_names
|
|
|
|
# Find files by name (uses 'pattern' and 'scope')
|
|
search_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_webdav_find_by_name",
|
|
{"pattern": "test_file.txt", "scope": dir_path},
|
|
)
|
|
assert search_result.isError is False
|
|
|
|
finally:
|
|
# Clean up: delete file then directory
|
|
await nc_mcp_login_flow_client.call_tool(
|
|
"nc_webdav_delete_resource", {"path": file_path}
|
|
)
|
|
await nc_mcp_login_flow_client.call_tool(
|
|
"nc_webdav_delete_resource", {"path": dir_path}
|
|
)
|
|
logger.info(f"Cleaned up {dir_path}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Deck
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowDeck:
|
|
"""Test Deck (Kanban) operations via Login Flow v2."""
|
|
|
|
async def test_deck_board_workflow(self, nc_mcp_login_flow_client: ClientSession):
|
|
"""Create board → list boards → get board details."""
|
|
import os
|
|
|
|
import httpx
|
|
|
|
suffix = uuid.uuid4().hex[:8]
|
|
board_title = f"LoginFlow Board {suffix}"
|
|
board_id = None
|
|
|
|
try:
|
|
# Create board (requires title and color)
|
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
|
"deck_create_board", {"title": board_title, "color": "0076D1"}
|
|
)
|
|
assert create_result.isError is False, (
|
|
f"Create board failed: {create_result.content[0].text}"
|
|
)
|
|
board_data = json.loads(create_result.content[0].text)
|
|
board_id = board_data.get("id") or board_data.get("board_id")
|
|
logger.info(f"Created board: {board_id}")
|
|
|
|
# List boards (tool name is deck_get_boards)
|
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
|
"deck_get_boards", {}
|
|
)
|
|
assert list_result.isError is False
|
|
boards_data = json.loads(list_result.content[0].text)
|
|
boards = boards_data.get("boards", [])
|
|
board_ids = [b.get("id") for b in boards]
|
|
assert board_id in board_ids
|
|
|
|
# Get board details
|
|
detail_result = await nc_mcp_login_flow_client.call_tool(
|
|
"deck_get_board", {"board_id": board_id}
|
|
)
|
|
assert detail_result.isError is False
|
|
finally:
|
|
# Clean up board via Deck REST API (no MCP delete_board tool exists)
|
|
if board_id is not None:
|
|
nc_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
|
nc_user = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
|
nc_pass = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
base_url=nc_host,
|
|
auth=httpx.BasicAuth(nc_user, nc_pass),
|
|
headers={"OCS-APIREQUEST": "true"},
|
|
) as client:
|
|
resp = await client.delete(
|
|
f"/apps/deck/api/v1.0/boards/{board_id}"
|
|
)
|
|
logger.info(f"Board cleanup: {board_id} → {resp.status_code}")
|
|
except Exception as e:
|
|
logger.warning(f"Board cleanup failed: {e}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tables
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowTables:
|
|
"""Test Tables operations via Login Flow v2."""
|
|
|
|
@pytest.mark.xfail(
|
|
reason="Server-side Pydantic bug: Table.owner_display_name required but missing from API",
|
|
strict=False,
|
|
)
|
|
async def test_tables_list(self, nc_mcp_login_flow_client: ClientSession):
|
|
"""List tables (may be empty but should not error)."""
|
|
result = await nc_mcp_login_flow_client.call_tool("nc_tables_list_tables", {})
|
|
assert result.isError is False, f"List tables failed: {result.content[0].text}"
|
|
data = json.loads(result.content[0].text)
|
|
logger.info(f"Tables: {data}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cookbook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowCookbook:
|
|
"""Test Cookbook operations via Login Flow v2."""
|
|
|
|
async def test_cookbook_list_and_categories(
|
|
self, nc_mcp_login_flow_client: ClientSession
|
|
):
|
|
"""List recipes and categories (may be empty but should not error)."""
|
|
# List recipes
|
|
list_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_cookbook_list_recipes", {}
|
|
)
|
|
assert list_result.isError is False
|
|
|
|
# List categories
|
|
cat_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_cookbook_list_categories", {}
|
|
)
|
|
assert cat_result.isError is False
|
|
|
|
async def test_cookbook_create_and_delete(
|
|
self, nc_mcp_login_flow_client: ClientSession
|
|
):
|
|
"""Create recipe → get recipe → delete recipe."""
|
|
suffix = uuid.uuid4().hex[:8]
|
|
|
|
create_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_cookbook_create_recipe",
|
|
{
|
|
"name": f"LoginFlow Recipe {suffix}",
|
|
"description": f"Test recipe {suffix}",
|
|
"ingredients": ["flour", "sugar", "butter"],
|
|
"instructions": ["Mix ingredients", "Bake at 350F"],
|
|
"keywords": "test,login-flow", # keywords is a string, not list
|
|
},
|
|
)
|
|
assert create_result.isError is False, (
|
|
f"Create recipe failed: {create_result.content[0].text}"
|
|
)
|
|
recipe_data = json.loads(create_result.content[0].text)
|
|
recipe_id = recipe_data.get("id") or recipe_data.get("recipe_id")
|
|
logger.info(f"Created recipe: {recipe_id}")
|
|
|
|
try:
|
|
# Get recipe (may fail due to server-side Pydantic bug with recipeYield=None)
|
|
get_result = await nc_mcp_login_flow_client.call_tool(
|
|
"nc_cookbook_get_recipe", {"recipe_id": recipe_id}
|
|
)
|
|
if get_result.isError:
|
|
error_text = get_result.content[0].text
|
|
if "recipeYield" in error_text:
|
|
logger.warning(
|
|
f"Known server bug: Recipe.recipeYield validation: {error_text}"
|
|
)
|
|
else:
|
|
raise AssertionError(f"Get recipe failed: {error_text}")
|
|
|
|
finally:
|
|
if recipe_id:
|
|
await nc_mcp_login_flow_client.call_tool(
|
|
"nc_cookbook_delete_recipe", {"recipe_id": recipe_id}
|
|
)
|
|
logger.info(f"Deleted recipe {recipe_id}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connectivity & Tool Listing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoginFlowConnectivity:
|
|
"""Basic connectivity and tool listing tests."""
|
|
|
|
async def test_list_tools(self, nc_mcp_login_flow_client: ClientSession):
|
|
"""Verify key tools are available."""
|
|
tools = await nc_mcp_login_flow_client.list_tools()
|
|
tool_names = [t.name for t in tools.tools]
|
|
|
|
# Auth tools (Login Flow v2 specific)
|
|
assert "nc_auth_provision_access" in tool_names
|
|
assert "nc_auth_check_status" in tool_names
|
|
assert "nc_auth_update_scopes" in tool_names
|
|
|
|
# Standard Nextcloud tools (verified against server/test_mcp.py)
|
|
expected = [
|
|
"nc_notes_create_note",
|
|
"nc_notes_search_notes",
|
|
"nc_notes_get_note",
|
|
"nc_notes_update_note",
|
|
"nc_notes_delete_note",
|
|
"nc_notes_append_content",
|
|
"nc_calendar_list_calendars",
|
|
"nc_calendar_create_event",
|
|
"nc_calendar_list_events",
|
|
"nc_calendar_get_event",
|
|
"nc_calendar_delete_event",
|
|
"nc_calendar_list_todos",
|
|
"nc_calendar_create_todo",
|
|
"nc_calendar_update_todo",
|
|
"nc_calendar_delete_todo",
|
|
"nc_contacts_list_addressbooks",
|
|
"nc_contacts_create_contact",
|
|
"nc_contacts_list_contacts",
|
|
"nc_contacts_delete_contact",
|
|
"nc_webdav_list_directory",
|
|
"nc_webdav_read_file",
|
|
"nc_webdav_write_file",
|
|
"nc_webdav_create_directory",
|
|
"nc_webdav_delete_resource",
|
|
"nc_webdav_find_by_name",
|
|
"deck_create_board",
|
|
"deck_get_boards",
|
|
"deck_get_board",
|
|
"nc_tables_list_tables",
|
|
"nc_cookbook_list_recipes",
|
|
"nc_cookbook_create_recipe",
|
|
"nc_cookbook_get_recipe",
|
|
"nc_cookbook_delete_recipe",
|
|
"nc_cookbook_list_categories",
|
|
]
|
|
|
|
for tool in expected:
|
|
assert tool in tool_names, f"Expected tool '{tool}' not found"
|
|
|
|
async def test_list_resources(self, nc_mcp_login_flow_client: ClientSession):
|
|
"""Verify resource templates are available."""
|
|
templates = await nc_mcp_login_flow_client.list_resource_templates()
|
|
logger.info(f"Resource templates: {len(templates.resourceTemplates)}")
|