refactor: convert f-string logging to lazy %-style format (G004)
Sweep all 1676 G004 violations across 112 files, converting
`logger.<level>(f"…{x}…")` to `logger.<level>("…%s…", x)`.
Why: ruff rule G004 was added to pyproject.toml to enforce lazy
%-style logging — defers formatting until the log level is enabled
and lets structured log tooling match the unformatted template.
Conversion preserves rendered output byte-for-byte:
- `{x}` → `%s` + `x`
- `{x!r}` / `{x!s}` / `{x!a}` → `%r` / `%s` / `%a`
- Format specs (`{x:.2f}`, `{x:>10}`) → `%s` + `format(x, 'spec')`
(printf-style specs aren't 1:1 with Python format specs, so we
delegate to `format()` to keep identical output)
- Literal `%` → `%%`
- Concatenated f-strings (`f"a {x} " "b"`) flattened
- Trailing kwargs (`exc_info=True`) preserved
Verified:
- `uv run ruff check --select G004` → 0 violations
- `uv run ty check -- nextcloud_mcp_server` → passes
- `uv run pytest tests/unit/` → 1010 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a4e6125d28
commit
665cb9b1eb
@@ -44,29 +44,29 @@ async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"Creating temporary event in calendar: {calendar_name}")
|
||||
logger.info("Creating temporary event in calendar: %s", calendar_name)
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result.get("uid")
|
||||
|
||||
if not event_uid:
|
||||
pytest.fail("Failed to create temporary event")
|
||||
|
||||
logger.info(f"Created temporary event with UID: {event_uid}")
|
||||
logger.info("Created temporary event with UID: %s", event_uid)
|
||||
yield {"uid": event_uid, "calendar_name": calendar_name, "data": event_data}
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if event_uid:
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary event: {event_uid}")
|
||||
logger.info("Cleaning up temporary event: %s", event_uid)
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
logger.info(f"Successfully deleted temporary event: {event_uid}")
|
||||
logger.info("Successfully deleted temporary event: %s", event_uid)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"Error deleting temporary event {event_uid}: {e}")
|
||||
logger.error("Error deleting temporary event %s: %s", event_uid, e)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary event {event_uid}: {e}"
|
||||
"Unexpected error deleting temporary event %s: %s", event_uid, e
|
||||
)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ async def test_list_calendars(nc_client: NextcloudClient):
|
||||
if not calendars:
|
||||
pytest.skip("No calendars available - Calendar app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(calendars)} calendars")
|
||||
logger.info("Found %s calendars", len(calendars))
|
||||
|
||||
# Check structure of calendars
|
||||
for calendar in calendars:
|
||||
@@ -90,7 +90,7 @@ async def test_list_calendars(nc_client: NextcloudClient):
|
||||
assert "description" in calendar
|
||||
assert "color" in calendar
|
||||
|
||||
logger.info(f"Calendar: {calendar['name']} - {calendar['display_name']}")
|
||||
logger.info("Calendar: %s - %s", calendar["name"], calendar["display_name"])
|
||||
|
||||
|
||||
async def test_create_and_delete_event(
|
||||
@@ -118,7 +118,7 @@ async def test_create_and_delete_event(
|
||||
assert result["status_code"] in [200, 201, 204]
|
||||
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with UID: {event_uid}")
|
||||
logger.info("Created event with UID: %s", event_uid)
|
||||
|
||||
# Verify event was created by retrieving it
|
||||
retrieved_event, etag = await nc_client.calendar.get_event(
|
||||
@@ -132,10 +132,10 @@ async def test_create_and_delete_event(
|
||||
delete_result = await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
assert delete_result["status_code"] in [200, 204, 404]
|
||||
|
||||
logger.info(f"Successfully deleted event: {event_uid}")
|
||||
logger.info("Successfully deleted event: %s", event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed: {e}")
|
||||
logger.error("Test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ async def test_create_all_day_event(
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created all-day event with UID: {event_uid}")
|
||||
logger.info("Created all-day event with UID: %s", event_uid)
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
@@ -170,7 +170,7 @@ async def test_create_all_day_event(
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"All-day event test failed: {e}")
|
||||
logger.error("All-day event test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ async def test_create_recurring_event(
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created recurring event with UID: {event_uid}")
|
||||
logger.info("Created recurring event with UID: %s", event_uid)
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
@@ -207,7 +207,7 @@ async def test_create_recurring_event(
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Recurring event test failed: {e}")
|
||||
logger.error("Recurring event test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ async def test_list_events_in_range(nc_client: NextcloudClient, temporary_event:
|
||||
)
|
||||
|
||||
assert isinstance(events, list)
|
||||
logger.info(f"Found {len(events)} events in date range")
|
||||
logger.info("Found %s events in date range", len(events))
|
||||
|
||||
# Our temporary event should be in the list
|
||||
event_uids = [event.get("uid") for event in events]
|
||||
@@ -266,10 +266,10 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
||||
assert updated_event["location"] == "Updated Location"
|
||||
assert updated_event["priority"] == 1
|
||||
|
||||
logger.info(f"Successfully updated event: {event_uid}")
|
||||
logger.info("Successfully updated event: %s", event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event update test failed: {e}")
|
||||
logger.error("Event update test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ async def test_update_event_extended_fields(
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created base event for extended fields test: {event_uid}")
|
||||
logger.info("Created base event for extended fields test: %s", event_uid)
|
||||
|
||||
# --- Phase 1: Set all four extended fields ---
|
||||
updated_data = {
|
||||
@@ -343,7 +343,7 @@ async def test_update_event_extended_fields(
|
||||
logger.info("Phase 2 passed: all extended fields cleared correctly")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Extended fields update test failed: {e}")
|
||||
logger.error("Extended fields update test failed: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if event_uid:
|
||||
@@ -374,7 +374,7 @@ async def test_create_event_with_attendees(
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with attendees, UID: {event_uid}")
|
||||
logger.info("Created event with attendees, UID: %s", event_uid)
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
@@ -388,7 +388,7 @@ async def test_create_event_with_attendees(
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with attendees test failed: {e}")
|
||||
logger.error("Event with attendees test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ async def test_get_nonexistent_event(
|
||||
with pytest.raises(Exception, match="not found"):
|
||||
await nc_client.calendar.get_event(calendar_name, fake_uid)
|
||||
|
||||
logger.info(f"Correctly raised exception for nonexistent event: {fake_uid}")
|
||||
logger.info("Correctly raised exception for nonexistent event: %s", fake_uid)
|
||||
|
||||
|
||||
async def test_delete_nonexistent_event(
|
||||
@@ -415,7 +415,7 @@ async def test_delete_nonexistent_event(
|
||||
|
||||
result = await nc_client.calendar.delete_event(calendar_name, fake_uid)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for deleting nonexistent event: {fake_uid}")
|
||||
logger.info("Correctly got 404 for deleting nonexistent event: %s", fake_uid)
|
||||
|
||||
|
||||
async def test_event_with_url_and_categories(
|
||||
@@ -439,7 +439,7 @@ async def test_event_with_url_and_categories(
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with metadata, UID: {event_uid}")
|
||||
logger.info("Created event with metadata, UID: %s", event_uid)
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
@@ -456,7 +456,7 @@ async def test_event_with_url_and_categories(
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with metadata test failed: {e}")
|
||||
logger.error("Event with metadata test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -485,7 +485,7 @@ async def test_list_events_date_range_filtering(
|
||||
calendar_name, past_event_data
|
||||
)
|
||||
past_uid = result_past["uid"]
|
||||
logger.info(f"Created past event: {past_uid}")
|
||||
logger.info("Created past event: %s", past_uid)
|
||||
|
||||
# Create Event B: 1 day in the future
|
||||
future_date = datetime.now() + timedelta(days=1)
|
||||
@@ -499,7 +499,7 @@ async def test_list_events_date_range_filtering(
|
||||
calendar_name, future_event_data
|
||||
)
|
||||
future_uid = result_future["uid"]
|
||||
logger.info(f"Created future event: {future_uid}")
|
||||
logger.info("Created future event: %s", future_uid)
|
||||
|
||||
# Query with date range: today → 7 days ahead
|
||||
now = datetime.now()
|
||||
@@ -526,8 +526,8 @@ async def test_list_events_date_range_filtering(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Date range filtering works: {len(events)} events returned, "
|
||||
f"past event correctly excluded"
|
||||
"Date range filtering works: %s events returned, past event correctly excluded",
|
||||
len(events),
|
||||
)
|
||||
|
||||
finally:
|
||||
@@ -537,7 +537,7 @@ async def test_list_events_date_range_filtering(
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
||||
logger.warning("Cleanup failed for event %s: %s", uid, e)
|
||||
|
||||
|
||||
async def test_recurring_event_date_range_expansion(
|
||||
@@ -569,7 +569,7 @@ async def test_recurring_event_date_range_expansion(
|
||||
}
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created daily recurring event: {event_uid}")
|
||||
logger.info("Created daily recurring event: %s", event_uid)
|
||||
|
||||
# Query with date range: today → 3 days ahead
|
||||
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
@@ -619,8 +619,8 @@ async def test_recurring_event_date_range_expansion(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Recurring event expansion works: {len(our_events)} occurrences "
|
||||
f"returned with unique start dates"
|
||||
"Recurring event expansion works: %s occurrences returned with unique start dates",
|
||||
len(our_events),
|
||||
)
|
||||
|
||||
finally:
|
||||
@@ -628,7 +628,9 @@ async def test_recurring_event_date_range_expansion(
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}")
|
||||
logger.warning(
|
||||
"Cleanup failed for recurring event %s: %s", event_uid, e
|
||||
)
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(
|
||||
|
||||
@@ -68,7 +68,7 @@ END:VCALENDAR"""
|
||||
event.data = custom_ical
|
||||
await event.save()
|
||||
|
||||
logger.info(f"Injected custom iCal properties into event {event_uid}")
|
||||
logger.info("Injected custom iCal properties into event %s", event_uid)
|
||||
|
||||
# Reload the event to confirm custom fields are present
|
||||
await event.load()
|
||||
@@ -91,7 +91,7 @@ END:VCALENDAR"""
|
||||
}
|
||||
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
logger.info(f"Updated event {event_uid} through MCP client")
|
||||
logger.info("Updated event %s through MCP client", event_uid)
|
||||
|
||||
# Reload the event to see if custom fields survived
|
||||
await event.load()
|
||||
@@ -117,7 +117,7 @@ END:VCALENDAR"""
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}")
|
||||
logger.warning("Failed to cleanup event %s: %s", event_uid, cleanup_error)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -145,7 +145,7 @@ async def test_contact_extended_fields_preservation(nc_client):
|
||||
contact_data=basic_contact_data,
|
||||
)
|
||||
|
||||
logger.info(f"Created basic contact {contact_uid}")
|
||||
logger.info("Created basic contact %s", contact_uid)
|
||||
|
||||
# Now inject a rich vCard with extended fields directly via CardDAV
|
||||
extended_vcard = f"""BEGIN:VCARD
|
||||
@@ -182,7 +182,7 @@ END:VCARD"""
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
)
|
||||
|
||||
logger.info(f"Injected extended vCard for contact {contact_uid}")
|
||||
logger.info("Injected extended vCard for contact %s", contact_uid)
|
||||
|
||||
# Retrieve the contact to confirm extended fields are present in raw vCard
|
||||
response = await nc_client.contacts._make_request("GET", contact_path)
|
||||
@@ -232,7 +232,7 @@ END:VCARD"""
|
||||
)
|
||||
logger.info("✓ Contact updated successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Failed to update contact: {e}")
|
||||
logger.error("✗ Failed to update contact: %s", e)
|
||||
raise
|
||||
|
||||
# Retrieve the contact again to see if extended fields survived
|
||||
@@ -268,9 +268,9 @@ END:VCARD"""
|
||||
all_preserved = True
|
||||
for field_pattern, field_name in extended_field_checks:
|
||||
if field_pattern in updated_addressdata:
|
||||
logger.info(f"✓ {field_name} preserved")
|
||||
logger.info("✓ %s preserved", field_name)
|
||||
else:
|
||||
logger.error(f"✗ {field_name} was lost during update")
|
||||
logger.error("✗ %s was lost during update", field_name)
|
||||
all_preserved = False
|
||||
|
||||
# The test should PASS - field preservation should work
|
||||
@@ -286,7 +286,7 @@ END:VCARD"""
|
||||
await nc_client.contacts.delete_addressbook(name=addressbook_name)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(
|
||||
f"Failed to cleanup addressbook {addressbook_name}: {cleanup_error}"
|
||||
"Failed to cleanup addressbook %s: %s", addressbook_name, cleanup_error
|
||||
)
|
||||
|
||||
|
||||
@@ -415,9 +415,9 @@ END:VCALENDAR"""
|
||||
else:
|
||||
lost.append(prop)
|
||||
|
||||
logger.info(f"Properties that SURVIVED: {survived}")
|
||||
logger.info("Properties that SURVIVED: %s", survived)
|
||||
if lost:
|
||||
logger.error(f"Properties that were LOST: {lost}")
|
||||
logger.error("Properties that were LOST: %s", lost)
|
||||
|
||||
# Assert that all extended properties were preserved
|
||||
assert len(lost) == 0, (
|
||||
@@ -430,4 +430,4 @@ END:VCALENDAR"""
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}")
|
||||
logger.warning("Failed to cleanup event %s: %s", event_uid, cleanup_error)
|
||||
|
||||
@@ -33,29 +33,29 @@ async def temporary_todo(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"Creating temporary todo in calendar: {calendar_name}")
|
||||
logger.info("Creating temporary todo in calendar: %s", calendar_name)
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uid = result.get("uid")
|
||||
|
||||
if not todo_uid:
|
||||
pytest.fail("Failed to create temporary todo")
|
||||
|
||||
logger.info(f"Created temporary todo with UID: {todo_uid}")
|
||||
logger.info("Created temporary todo with UID: %s", todo_uid)
|
||||
yield {"uid": todo_uid, "calendar_name": calendar_name, "data": todo_data}
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if todo_uid:
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary todo: {todo_uid}")
|
||||
logger.info("Cleaning up temporary todo: %s", todo_uid)
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
logger.info(f"Successfully deleted temporary todo: {todo_uid}")
|
||||
logger.info("Successfully deleted temporary todo: %s", todo_uid)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"Error deleting temporary todo {todo_uid}: {e}")
|
||||
logger.error("Error deleting temporary todo %s: %s", todo_uid, e)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary todo {todo_uid}: {e}"
|
||||
"Unexpected error deleting temporary todo %s: %s", todo_uid, e
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ async def test_create_and_delete_todo(
|
||||
assert result["status_code"] in [200, 201, 204]
|
||||
|
||||
todo_uid = result["uid"]
|
||||
logger.info(f"Created todo with UID: {todo_uid}")
|
||||
logger.info("Created todo with UID: %s", todo_uid)
|
||||
|
||||
# Verify todo was created by listing todos
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
@@ -103,10 +103,10 @@ async def test_create_and_delete_todo(
|
||||
delete_result = await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
assert delete_result["status_code"] in [200, 204, 404]
|
||||
|
||||
logger.info(f"Successfully deleted todo: {todo_uid}")
|
||||
logger.info("Successfully deleted todo: %s", todo_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed: {e}")
|
||||
logger.error("Test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ async def test_list_todos(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
for uid in todo_uids:
|
||||
assert uid in listed_uids
|
||||
|
||||
logger.info(f"Found {len(todos)} todos in calendar")
|
||||
logger.info("Found %s todos in calendar", len(todos))
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
@@ -187,10 +187,10 @@ async def test_update_todo(nc_client: NextcloudClient, temporary_todo: dict):
|
||||
assert updated_todo["priority"] == 1
|
||||
assert updated_todo["percent_complete"] == 50
|
||||
|
||||
logger.info(f"Successfully updated todo: {todo_uid}")
|
||||
logger.info("Successfully updated todo: %s", todo_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Todo update test failed: {e}")
|
||||
logger.error("Todo update test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ async def test_todo_with_dates(nc_client: NextcloudClient, temporary_calendar: s
|
||||
try:
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uid = result["uid"]
|
||||
logger.info(f"Created todo with dates, UID: {todo_uid}")
|
||||
logger.info("Created todo with dates, UID: %s", todo_uid)
|
||||
|
||||
# Verify dates
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
@@ -228,7 +228,7 @@ async def test_todo_with_dates(nc_client: NextcloudClient, temporary_calendar: s
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Date handling test failed: {e}")
|
||||
logger.error("Date handling test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ async def test_todo_status_transitions(
|
||||
assert todo["percent_complete"] == 100
|
||||
assert "completed" in todo
|
||||
|
||||
logger.info(f"Successfully transitioned todo through statuses: {todo_uid}")
|
||||
logger.info("Successfully transitioned todo through statuses: %s", todo_uid)
|
||||
|
||||
finally:
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
@@ -315,7 +315,7 @@ async def test_todo_priority_levels(
|
||||
assert todo is not None
|
||||
assert todo["priority"] == expected_priority
|
||||
|
||||
logger.info(f"Successfully tested priority levels: {priorities}")
|
||||
logger.info("Successfully tested priority levels: %s", priorities)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
@@ -342,7 +342,7 @@ async def test_todo_with_categories(
|
||||
try:
|
||||
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
|
||||
todo_uid = result["uid"]
|
||||
logger.info(f"Created todo with categories, UID: {todo_uid}")
|
||||
logger.info("Created todo with categories, UID: %s", todo_uid)
|
||||
|
||||
# Verify categories
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
@@ -360,7 +360,7 @@ async def test_todo_with_categories(
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Categories test failed: {e}")
|
||||
logger.error("Categories test failed: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -399,7 +399,7 @@ async def test_search_todos_across_calendars(
|
||||
assert todo1["calendar_name"] == cal1_name
|
||||
assert todo2["calendar_name"] == cal2_name
|
||||
|
||||
logger.info(f"Found {len(all_todos)} todos across all calendars")
|
||||
logger.info("Found %s todos across all calendars", len(all_todos))
|
||||
|
||||
finally:
|
||||
# Cleanup: Delete only the todos we created (calendars are reused/built-in)
|
||||
@@ -428,7 +428,7 @@ async def test_get_nonexistent_todo(
|
||||
matching_todos = [t for t in todos if t.get("uid") == fake_uid]
|
||||
assert len(matching_todos) == 0
|
||||
|
||||
logger.info(f"Verified nonexistent todo UID: {fake_uid}")
|
||||
logger.info("Verified nonexistent todo UID: %s", fake_uid)
|
||||
|
||||
|
||||
async def test_delete_nonexistent_todo(
|
||||
@@ -440,7 +440,7 @@ async def test_delete_nonexistent_todo(
|
||||
|
||||
result = await nc_client.calendar.delete_todo(calendar_name, fake_uid)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for deleting nonexistent todo: {fake_uid}")
|
||||
logger.info("Correctly got 404 for deleting nonexistent todo: %s", fake_uid)
|
||||
|
||||
|
||||
async def test_list_todos_with_filters(
|
||||
@@ -487,7 +487,7 @@ async def test_list_todos_with_filters(
|
||||
our_todo_uids = [t["uid"] for t in all_todos if t["uid"] in created_uids]
|
||||
assert len(our_todo_uids) == 3
|
||||
|
||||
logger.info(f"Successfully created and listed {len(created_uids)} test todos")
|
||||
logger.info("Successfully created and listed %s test todos", len(created_uids))
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
|
||||
@@ -22,7 +22,7 @@ async def test_list_addressbooks(nc_client: NextcloudClient):
|
||||
if not addressbooks:
|
||||
pytest.skip("No addressbooks available - Contacts app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(addressbooks)} addressbooks")
|
||||
logger.info("Found %s addressbooks", len(addressbooks))
|
||||
|
||||
# Check structure of addressbooks
|
||||
for addressbook in addressbooks:
|
||||
@@ -31,7 +31,7 @@ async def test_list_addressbooks(nc_client: NextcloudClient):
|
||||
assert "getctag" in addressbook
|
||||
|
||||
logger.info(
|
||||
f"Addressbook: {addressbook['name']} - {addressbook['display_name']}"
|
||||
"Addressbook: %s - %s", addressbook["name"], addressbook["display_name"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,14 +27,18 @@ async def test_attachments_add_and_get(
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
|
||||
logger.info(
|
||||
f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}"
|
||||
"Attempting to retrieve attachment '%s' added by fixture for note ID: %s",
|
||||
attachment_filename,
|
||||
note_id,
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=note_category
|
||||
)
|
||||
logger.info(
|
||||
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
|
||||
"Attachment retrieved. Mime type: %s, Size: %s bytes",
|
||||
retrieved_mime,
|
||||
len(retrieved_content),
|
||||
)
|
||||
|
||||
assert retrieved_content == attachment_content
|
||||
@@ -55,7 +59,9 @@ async def test_attachments_add_to_note_with_category(
|
||||
note_id = note_data["id"]
|
||||
note_category = note_data["category"]
|
||||
logger.info(
|
||||
f"Using note ID: {note_id} with category '{note_category}' for attachment test."
|
||||
"Using note ID: %s with category '%s' for attachment test.",
|
||||
note_id,
|
||||
note_category,
|
||||
)
|
||||
|
||||
# Add attachment within the test
|
||||
@@ -65,7 +71,7 @@ async def test_attachments_add_to_note_with_category(
|
||||
attachment_mime = "text/plain"
|
||||
|
||||
logger.info(
|
||||
f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}"
|
||||
"Attempting to add attachment '%s' to note ID: %s", attachment_filename, note_id
|
||||
)
|
||||
# Pass category to add_note_attachment
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
@@ -78,13 +84,17 @@ async def test_attachments_add_to_note_with_category(
|
||||
assert upload_response and "status_code" in upload_response
|
||||
assert upload_response["status_code"] in [201, 204]
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']})."
|
||||
"Attachment '%s' added successfully (Status: %s).",
|
||||
attachment_filename,
|
||||
upload_response["status_code"],
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
# Get and Verify Attachment
|
||||
logger.info(
|
||||
f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}"
|
||||
"Attempting to retrieve attachment '%s' from note ID: %s",
|
||||
attachment_filename,
|
||||
note_id,
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
|
||||
@@ -93,7 +103,9 @@ async def test_attachments_add_to_note_with_category(
|
||||
category=note_category, # Pass the note's category
|
||||
)
|
||||
logger.info(
|
||||
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
|
||||
"Attachment retrieved. Mime type: %s, Size: %s bytes",
|
||||
retrieved_mime,
|
||||
len(retrieved_content),
|
||||
)
|
||||
|
||||
assert retrieved_content == attachment_content
|
||||
@@ -123,24 +135,28 @@ async def test_attachments_cleanup_on_note_delete(
|
||||
# Instead, we will manually delete the note here and verify the attachment is gone.
|
||||
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' exists for note {note_id} (added by fixture)."
|
||||
"Attachment '%s' exists for note %s (added by fixture).",
|
||||
attachment_filename,
|
||||
note_id,
|
||||
)
|
||||
|
||||
# Manually delete the note
|
||||
logger.info(f"Manually deleting note ID: {note_id} within the test.")
|
||||
logger.info("Manually deleting note ID: %s within the test.", note_id)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note ID: {note_id} deleted successfully.")
|
||||
logger.info("Note ID: %s deleted successfully.", note_id)
|
||||
time.sleep(1)
|
||||
|
||||
# Verify Note Is Deleted
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note:
|
||||
await nc_client.notes.get_note(note_id=note_id)
|
||||
assert excinfo_note.value.response.status_code == 404
|
||||
logger.info(f"Verified note {note_id} deletion (404 received).")
|
||||
logger.info("Verified note %s deletion (404 received).", note_id)
|
||||
|
||||
# Verify Attachment Is Deleted (via 404 on GET)
|
||||
logger.info(
|
||||
f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}"
|
||||
"Verifying attachment '%s' is deleted for note ID: %s",
|
||||
attachment_filename,
|
||||
note_id,
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo_attach:
|
||||
# Pass category to get_note_attachment - although it should fail anyway
|
||||
@@ -154,7 +170,8 @@ async def test_attachments_cleanup_on_note_delete(
|
||||
# Expect 404 because the note itself is gone
|
||||
assert excinfo_attach.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Attachment '{attachment_filename}' correctly not found (404) after note deletion."
|
||||
"Attachment '%s' correctly not found (404) after note deletion.",
|
||||
attachment_filename,
|
||||
)
|
||||
|
||||
# Directly verify attachment directory doesn't exist using WebDAV PROPFIND
|
||||
@@ -172,7 +189,7 @@ async def test_attachments_cleanup_on_note_delete(
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(
|
||||
f"Attachment directory still exists! PROPFIND returned {status}"
|
||||
"Attachment directory still exists! PROPFIND returned %s", status
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
||||
@@ -205,18 +222,21 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
|
||||
try:
|
||||
# 1. Create note with initial category
|
||||
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
|
||||
logger.info("Creating note '%s' in category '%s'", note_title, initial_category)
|
||||
created_note = await nc_client.notes.create_note(
|
||||
title=note_title, content="Initial content", category=initial_category
|
||||
)
|
||||
note_id = created_note["id"]
|
||||
etag1 = created_note["etag"]
|
||||
logger.info(f"Note created with ID: {note_id}, Etag: {etag1}")
|
||||
logger.info("Note created with ID: %s, Etag: %s", note_id, etag1)
|
||||
time.sleep(1)
|
||||
|
||||
# 2. Add attachment (passing initial category)
|
||||
logger.info(
|
||||
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
|
||||
"Adding attachment '%s' to note %s (in %s)",
|
||||
attachment_filename,
|
||||
note_id,
|
||||
initial_category,
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
@@ -231,7 +251,8 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
|
||||
# 3. Verify attachment retrieval from initial category (passing initial category)
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from initial category '{initial_category}'"
|
||||
"Verifying attachment retrieval from initial category '%s'",
|
||||
initial_category,
|
||||
)
|
||||
retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
@@ -241,7 +262,10 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
|
||||
# 4. Update note category (with retry for ETag conflicts from background scanner)
|
||||
logger.info(
|
||||
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
||||
"Updating note %s category from '%s' to '%s'",
|
||||
note_id,
|
||||
initial_category,
|
||||
new_category,
|
||||
)
|
||||
# Retry logic for 412 Precondition Failed (ETag conflict)
|
||||
# This can happen if the background vector scanner touches the note
|
||||
@@ -252,7 +276,10 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
||||
current_etag = current_note_data["etag"]
|
||||
logger.info(
|
||||
f"Update attempt {attempt + 1}/{max_update_attempts}, current etag: {current_etag}"
|
||||
"Update attempt %s/%s, current etag: %s",
|
||||
attempt + 1,
|
||||
max_update_attempts,
|
||||
current_etag,
|
||||
)
|
||||
|
||||
updated_note = await nc_client.notes.update(
|
||||
@@ -264,14 +291,14 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
)
|
||||
etag3 = updated_note["etag"]
|
||||
assert updated_note["category"] == new_category
|
||||
logger.info(f"Note category updated successfully. New Etag: {etag3}")
|
||||
logger.info("Note category updated successfully. New Etag: %s", etag3)
|
||||
break # Success, exit retry loop
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 412 and attempt < max_update_attempts - 1:
|
||||
# ETag conflict (likely from background scanner), retry
|
||||
logger.warning(
|
||||
f"ETag conflict (412) on attempt {attempt + 1}, retrying..."
|
||||
"ETag conflict (412) on attempt %s, retrying...", attempt + 1
|
||||
)
|
||||
time.sleep(1) # Brief delay before retry
|
||||
continue
|
||||
@@ -283,7 +310,7 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
|
||||
# 5. Verify attachment retrieval from *new* category (passing new category)
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from new category '{new_category}'"
|
||||
"Verifying attachment retrieval from new category '%s'", new_category
|
||||
)
|
||||
retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=new_category
|
||||
@@ -305,7 +332,8 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(
|
||||
f"Old attachment directory still exists! PROPFIND returned {status}"
|
||||
"Old attachment directory still exists! PROPFIND returned %s",
|
||||
status,
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!"
|
||||
@@ -333,11 +361,13 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
200,
|
||||
], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(
|
||||
f"Verified new attachment directory exists via PROPFIND ({status} received)"
|
||||
"Verified new attachment directory exists via PROPFIND (%s received)",
|
||||
status,
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"New attachment directory not found! PROPFIND failed with {e.response.status_code}"
|
||||
"New attachment directory not found! PROPFIND failed with %s",
|
||||
e.response.status_code,
|
||||
)
|
||||
assert False, (
|
||||
f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
@@ -347,11 +377,13 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
# 6. Cleanup: Delete the note (client should use the *final* category for cleanup path)
|
||||
if note_id:
|
||||
logger.info(
|
||||
f"Cleaning up note ID: {note_id} (last known category: '{new_category}')"
|
||||
"Cleaning up note ID: %s (last known category: '%s')",
|
||||
note_id,
|
||||
new_category,
|
||||
)
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note {note_id} deleted.")
|
||||
logger.info("Note %s deleted.", note_id)
|
||||
time.sleep(1)
|
||||
# Verify note deletion
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note_del:
|
||||
@@ -424,4 +456,4 @@ async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
||||
"Verified all attachment directories are properly cleaned up."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup for note {note_id}: {e}")
|
||||
logger.error("Error during cleanup for note %s: %s", note_id, e)
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_image_data() -> tuple[bytes, str]:
|
||||
img.save(img_byte_arr, format="PNG")
|
||||
image_bytes = img_byte_arr.getvalue()
|
||||
suggested_filename = "test_image.png"
|
||||
logger.info(f"Generated test image data ({len(image_bytes)} bytes).")
|
||||
logger.info("Generated test image data (%s bytes).", len(image_bytes))
|
||||
return image_bytes, suggested_filename
|
||||
|
||||
|
||||
@@ -61,7 +61,10 @@ async def test_note_with_embedded_image(
|
||||
# 1. Upload the image as an attachment
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
logger.info(
|
||||
f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')..."
|
||||
"Uploading image attachment '%s' to note %s (category: '%s')...",
|
||||
attachment_filename,
|
||||
note_id,
|
||||
note_category or "",
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
@@ -72,7 +75,7 @@ async def test_note_with_embedded_image(
|
||||
)
|
||||
assert upload_response and upload_response.get("status_code") in [201, 204]
|
||||
logger.info(
|
||||
f"Image uploaded successfully (Status: {upload_response.get('status_code')})."
|
||||
"Image uploaded successfully (Status: %s).", upload_response.get("status_code")
|
||||
)
|
||||
time.sleep(1) # Allow potential processing time
|
||||
|
||||
@@ -94,11 +97,12 @@ async def test_note_with_embedded_image(
|
||||
200,
|
||||
], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(
|
||||
f"Verified attachment directory exists via PROPFIND ({status} received)"
|
||||
"Verified attachment directory exists via PROPFIND (%s received)", status
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"Attachment directory not found! PROPFIND failed with {e.response.status_code}"
|
||||
"Attachment directory not found! PROPFIND failed with %s",
|
||||
e.response.status_code,
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
@@ -135,7 +139,9 @@ async def test_note_with_embedded_image(
|
||||
|
||||
# 4. Verify the image attachment can be retrieved
|
||||
logger.info(
|
||||
f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')..."
|
||||
"Retrieving image attachment '%s' (category: '%s')...",
|
||||
attachment_filename,
|
||||
note_category or "",
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_img_content, mime_type = await nc_client.webdav.get_note_attachment(
|
||||
@@ -149,17 +155,17 @@ async def test_note_with_embedded_image(
|
||||
|
||||
# 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown)
|
||||
logger.info(
|
||||
f"Manually deleting note ID: {note_id} to verify proper attachment cleanup"
|
||||
"Manually deleting note ID: %s to verify proper attachment cleanup", note_id
|
||||
)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note ID: {note_id} deleted successfully.")
|
||||
logger.info("Note ID: %s deleted successfully.", note_id)
|
||||
time.sleep(1)
|
||||
|
||||
# 6. Verify note is deleted
|
||||
with pytest.raises(HTTPStatusError) as excinfo_note:
|
||||
await nc_client.notes.get_note(note_id=note_id)
|
||||
assert excinfo_note.value.response.status_code == 404
|
||||
logger.info(f"Verified note {note_id} deletion (404 received).")
|
||||
logger.info("Verified note %s deletion (404 received).", note_id)
|
||||
|
||||
# 7. Verify attachment directory is deleted via WebDAV PROPFIND
|
||||
logger.info("Directly verifying attachment directory doesn't exist via PROPFIND")
|
||||
@@ -170,7 +176,7 @@ async def test_note_with_embedded_image(
|
||||
status = propfind_resp.status_code
|
||||
if status in [200, 207]: # Successful PROPFIND means directory exists
|
||||
logger.error(
|
||||
f"Attachment directory still exists! PROPFIND returned {status}"
|
||||
"Attachment directory still exists! PROPFIND returned %s", status
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
||||
|
||||
@@ -39,7 +39,7 @@ async def test_create_and_delete_share(nc_client):
|
||||
assert share_data is not None
|
||||
assert "id" in share_data
|
||||
share_id = share_data["id"]
|
||||
logger.info(f"Created share: {share_id}")
|
||||
logger.info("Created share: %s", share_id)
|
||||
|
||||
# Get share info
|
||||
share_info = await nc_client.sharing.get_share(share_id)
|
||||
@@ -56,7 +56,7 @@ async def test_create_and_delete_share(nc_client):
|
||||
# Cleanup
|
||||
if share_id:
|
||||
await nc_client.sharing.delete_share(share_id)
|
||||
logger.info(f"Deleted share: {share_id}")
|
||||
logger.info("Deleted share: %s", share_id)
|
||||
|
||||
await nc_client.webdav.delete_resource(file_path)
|
||||
|
||||
|
||||
@@ -29,18 +29,21 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
|
||||
try:
|
||||
# 1. Create note with initial category
|
||||
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
|
||||
logger.info("Creating note '%s' in category '%s'", note_title, initial_category)
|
||||
created_note = await nc_client.notes.create_note(
|
||||
title=note_title, content="Initial content", category=initial_category
|
||||
)
|
||||
note_id = created_note["id"]
|
||||
etag1 = created_note["etag"]
|
||||
logger.info(f"Note created with ID: {note_id}, Etag: {etag1}")
|
||||
logger.info("Note created with ID: %s, Etag: %s", note_id, etag1)
|
||||
time.sleep(1)
|
||||
|
||||
# 2. Add attachment (passing initial category)
|
||||
logger.info(
|
||||
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
|
||||
"Adding attachment '%s' to note %s (in %s)",
|
||||
attachment_filename,
|
||||
note_id,
|
||||
initial_category,
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
@@ -55,7 +58,8 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
|
||||
# 3. Verify attachment retrieval from initial category
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from initial category '{initial_category}'"
|
||||
"Verifying attachment retrieval from initial category '%s'",
|
||||
initial_category,
|
||||
)
|
||||
retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
@@ -65,13 +69,16 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
|
||||
# 4. Construct and check the WebDAV path for the initial category's attachment directory
|
||||
initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}"
|
||||
logger.info(f"Initial WebDAV path for attachments: {initial_webdav_path}")
|
||||
logger.info("Initial WebDAV path for attachments: %s", initial_webdav_path)
|
||||
# Here we would check if the directory exists, but the WebDAV client doesn't directly
|
||||
# expose directory listing functionality, so we'll infer from attachment retrieval success
|
||||
|
||||
# 5. Update note category
|
||||
logger.info(
|
||||
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
||||
"Updating note %s category from '%s' to '%s'",
|
||||
note_id,
|
||||
initial_category,
|
||||
new_category,
|
||||
)
|
||||
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
||||
current_etag = current_note_data["etag"]
|
||||
@@ -84,12 +91,12 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
)
|
||||
etag3 = updated_note["etag"]
|
||||
assert updated_note["category"] == new_category
|
||||
logger.info(f"Note category updated successfully. New Etag: {etag3}")
|
||||
logger.info("Note category updated successfully. New Etag: %s", etag3)
|
||||
time.sleep(1)
|
||||
|
||||
# 6. Verify attachment retrieval from new category
|
||||
logger.info(
|
||||
f"Verifying attachment retrieval from new category '{new_category}'"
|
||||
"Verifying attachment retrieval from new category '%s'", new_category
|
||||
)
|
||||
retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=new_category
|
||||
@@ -99,7 +106,8 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
|
||||
# 7. Try to retrieve from old category - this should fail
|
||||
logger.info(
|
||||
f"Trying to retrieve attachment from old category '{initial_category}' - should fail"
|
||||
"Trying to retrieve attachment from old category '%s' - should fail",
|
||||
initial_category,
|
||||
)
|
||||
try:
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
@@ -115,7 +123,8 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
except HTTPStatusError as e:
|
||||
# This is the expected outcome - old directory should be gone
|
||||
logger.info(
|
||||
f"Correctly got error accessing old category path: {e.response.status_code}"
|
||||
"Correctly got error accessing old category path: %s",
|
||||
e.response.status_code,
|
||||
)
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected 404, got {e.response.status_code}"
|
||||
@@ -143,14 +152,16 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"Old attachment directory still exists! PROPFIND returned {status}"
|
||||
"Old attachment directory still exists! PROPFIND returned %s",
|
||||
status,
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified old attachment directory does not exist (PROPFIND returned {status})"
|
||||
"Verified old attachment directory does not exist (PROPFIND returned %s)",
|
||||
status,
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
# 404 is expected - directory should not exist
|
||||
@@ -164,10 +175,10 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
finally:
|
||||
# 8. Cleanup: Delete the note
|
||||
if note_id:
|
||||
logger.info(f"Cleaning up note ID: {note_id}")
|
||||
logger.info("Cleaning up note ID: %s", note_id)
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note {note_id} deleted.")
|
||||
logger.info("Note %s deleted.", note_id)
|
||||
time.sleep(1)
|
||||
|
||||
# 9. Verify both old and new attachment paths are gone
|
||||
@@ -209,14 +220,16 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"New category attachment directory still exists! PROPFIND returned {status}"
|
||||
"New category attachment directory still exists! PROPFIND returned %s",
|
||||
status,
|
||||
)
|
||||
assert False, (
|
||||
f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified new category attachment directory does not exist (PROPFIND returned {status})"
|
||||
"Verified new category attachment directory does not exist (PROPFIND returned %s)",
|
||||
status,
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
@@ -240,14 +253,16 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"Old category attachment directory still exists! PROPFIND returned {status}"
|
||||
"Old category attachment directory still exists! PROPFIND returned %s",
|
||||
status,
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified old category attachment directory does not exist (PROPFIND returned {status})"
|
||||
"Verified old category attachment directory does not exist (PROPFIND returned %s)",
|
||||
status,
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
assert e.response.status_code == 404, (
|
||||
@@ -261,4 +276,4 @@ async def test_category_change_cleans_up_old_attachments_directory(
|
||||
"Verified all attachment directories are properly cleaned up."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup for note {note_id}: {e}")
|
||||
logger.error("Error during cleanup for note %s: %s", note_id, e)
|
||||
|
||||
@@ -33,7 +33,7 @@ async def test_create_and_delete_directory(
|
||||
# Create directory
|
||||
result = await nc_client.webdav.create_directory(test_dir)
|
||||
assert result["status_code"] == 201 # Created
|
||||
logger.info(f"Created directory: {test_dir}")
|
||||
logger.info("Created directory: %s", test_dir)
|
||||
|
||||
# Verify directory exists by listing parent
|
||||
parent_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
@@ -43,7 +43,7 @@ async def test_create_and_delete_directory(
|
||||
# Delete directory
|
||||
delete_result = await nc_client.webdav.delete_resource(test_dir)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted directory: {test_dir}")
|
||||
logger.info("Deleted directory: %s", test_dir)
|
||||
|
||||
finally:
|
||||
# Cleanup: ensure directory is deleted
|
||||
@@ -67,13 +67,13 @@ async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path
|
||||
test_file, test_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert write_result["status_code"] in [200, 201, 204] # Success codes
|
||||
logger.info(f"Wrote file: {test_file}")
|
||||
logger.info("Wrote file: %s", test_file)
|
||||
|
||||
# Read file back
|
||||
content, content_type = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == test_content
|
||||
assert "text/plain" in content_type
|
||||
logger.info(f"Read file: {test_file}")
|
||||
logger.info("Read file: %s", test_file)
|
||||
|
||||
# Verify file appears in directory listing
|
||||
listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
@@ -83,7 +83,7 @@ async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path
|
||||
# Delete file
|
||||
delete_result = await nc_client.webdav.delete_resource(test_file)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted file: {test_file}")
|
||||
logger.info("Deleted file: %s", test_file)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
@@ -106,7 +106,7 @@ async def test_list_directory_empty_and_populated(
|
||||
empty_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert isinstance(empty_listing, list)
|
||||
assert len(empty_listing) == 0
|
||||
logger.info(f"Empty directory listing: {len(empty_listing)} items")
|
||||
logger.info("Empty directory listing: %s items", len(empty_listing))
|
||||
|
||||
# Add some files and directories
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir1")
|
||||
@@ -140,7 +140,7 @@ async def test_list_directory_empty_and_populated(
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Populated directory listing: {len(populated_listing)} items")
|
||||
logger.info("Populated directory listing: %s items", len(populated_listing))
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
@@ -162,7 +162,7 @@ async def test_read_nonexistent_file(nc_client: NextcloudClient):
|
||||
await nc_client.webdav.read_file(nonexistent_file)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent file: {nonexistent_file}")
|
||||
logger.info("Correctly got 404 for nonexistent file: %s", nonexistent_file)
|
||||
|
||||
|
||||
async def test_delete_nonexistent_resource(nc_client: NextcloudClient):
|
||||
@@ -171,7 +171,7 @@ async def test_delete_nonexistent_resource(nc_client: NextcloudClient):
|
||||
|
||||
result = await nc_client.webdav.delete_resource(nonexistent_resource)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent resource: {nonexistent_resource}")
|
||||
logger.info("Correctly got 404 for nonexistent resource: %s", nonexistent_resource)
|
||||
|
||||
|
||||
async def test_create_nested_directories(
|
||||
@@ -200,7 +200,7 @@ async def test_create_nested_directories(
|
||||
assert level2_listing[0]["name"] == "level3"
|
||||
assert level2_listing[0]["is_directory"] is True
|
||||
|
||||
logger.info(f"Created nested directory structure: {nested_path}")
|
||||
logger.info("Created nested directory structure: %s", nested_path)
|
||||
|
||||
finally:
|
||||
# Cleanup - delete from deepest to shallowest
|
||||
@@ -241,7 +241,7 @@ async def test_overwrite_existing_file(nc_client: NextcloudClient, test_base_pat
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == new_content
|
||||
|
||||
logger.info(f"Successfully overwrote file: {test_file}")
|
||||
logger.info("Successfully overwrote file: %s", test_file)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
@@ -270,4 +270,4 @@ async def test_list_root_directory(nc_client: NextcloudClient):
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Root directory contains {len(root_listing)} items")
|
||||
logger.info("Root directory contains %s items", len(root_listing))
|
||||
|
||||
@@ -47,16 +47,16 @@ async def test_search_setup(nc_client: NextcloudClient):
|
||||
for file_path, content, content_type in test_files:
|
||||
await nc_client.webdav.write_file(file_path, content, content_type)
|
||||
|
||||
logger.info(f"Created test directory with {len(test_files)} files: {test_dir}")
|
||||
logger.info("Created test directory with %s files: %s", len(test_files), test_dir)
|
||||
|
||||
yield test_dir
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
logger.info(f"Cleaned up test directory: {test_dir}")
|
||||
logger.info("Cleaned up test directory: %s", test_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup test directory {test_dir}: {e}")
|
||||
logger.warning("Failed to cleanup test directory %s: %s", test_dir, e)
|
||||
|
||||
|
||||
async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup: str):
|
||||
@@ -69,7 +69,7 @@ async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup:
|
||||
readme_files = [r for r in results if r.get("name") == "readme.md"]
|
||||
assert len(readme_files) >= 1, "Should find readme.md"
|
||||
|
||||
logger.info(f"Found {len(results)} files matching 'readme.md'")
|
||||
logger.info("Found %s files matching 'readme.md'", len(results))
|
||||
|
||||
|
||||
async def test_find_by_name_wildcard_extension(
|
||||
@@ -86,7 +86,7 @@ async def test_find_by_name_wildcard_extension(
|
||||
name = result.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
|
||||
logger.info(f"Found {len(results)} .txt files")
|
||||
logger.info("Found %s .txt files", len(results))
|
||||
|
||||
|
||||
async def test_find_by_name_wildcard_prefix(
|
||||
@@ -105,7 +105,7 @@ async def test_find_by_name_wildcard_prefix(
|
||||
f"Expected name to start with 'document', got {name}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} files starting with 'document'")
|
||||
logger.info("Found %s files starting with 'document'", len(results))
|
||||
|
||||
|
||||
async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup: str):
|
||||
@@ -122,7 +122,7 @@ async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup:
|
||||
f"Expected text/* type, got {content_type}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} text files")
|
||||
logger.info("Found %s text files", len(results))
|
||||
|
||||
|
||||
async def test_find_by_type_specific(
|
||||
@@ -143,7 +143,7 @@ async def test_find_by_type_specific(
|
||||
f"Expected application/pdf, got {content_type}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} PDF files")
|
||||
logger.info("Found %s PDF files", len(results))
|
||||
|
||||
|
||||
async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup: str):
|
||||
@@ -157,7 +157,7 @@ async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup:
|
||||
assert len(results) <= 2, f"Should return at most 2 results, got {len(results)}"
|
||||
assert len(results) > 0, "Should return at least 1 result"
|
||||
|
||||
logger.info(f"Found {len(results)} files with limit=2")
|
||||
logger.info("Found %s files with limit=2", len(results))
|
||||
|
||||
|
||||
async def test_search_files_combined_filters(
|
||||
@@ -198,7 +198,7 @@ async def test_search_files_combined_filters(
|
||||
f"Expected name to start with 'document', got {name}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} files matching combined filters")
|
||||
logger.info("Found %s files matching combined filters", len(results))
|
||||
|
||||
|
||||
async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup: str):
|
||||
@@ -210,7 +210,7 @@ async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup:
|
||||
# Should find at least the one we created
|
||||
assert len(results) >= 1, f"Should find at least 1 file named {unique_name}"
|
||||
|
||||
logger.info(f"Found {len(results)} files in root scope")
|
||||
logger.info("Found %s files in root scope", len(results))
|
||||
|
||||
|
||||
async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup: str):
|
||||
@@ -226,7 +226,7 @@ async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup
|
||||
nested_file = results[0]
|
||||
assert "nested.txt" in nested_file.get("name", ""), "Should find nested.txt"
|
||||
|
||||
logger.info(f"Found file in subdirectory: {nested_file.get('name')}")
|
||||
logger.info("Found file in subdirectory: %s", nested_file.get("name"))
|
||||
|
||||
|
||||
async def test_search_no_results(nc_client: NextcloudClient, test_search_setup: str):
|
||||
@@ -259,10 +259,10 @@ async def test_search_properties_returned(
|
||||
|
||||
# Optional properties that may be present
|
||||
optional_props = ["size", "content_type", "last_modified", "etag"]
|
||||
logger.info(f"Result properties: {list(result.keys())}")
|
||||
logger.info("Result properties: %s", list(result.keys()))
|
||||
|
||||
# At least some optional properties should be present
|
||||
present_optional = [prop for prop in optional_props if prop in result]
|
||||
assert len(present_optional) > 0, f"Should have at least one of {optional_props}"
|
||||
|
||||
logger.info(f"Search returned properties: {list(result.keys())}")
|
||||
logger.info("Search returned properties: %s", list(result.keys()))
|
||||
|
||||
+315
-230
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created OpenAI generation provider: model={generation_model}")
|
||||
logger.info("Created OpenAI generation provider: model=%s", generation_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "ollama":
|
||||
@@ -96,7 +96,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Ollama generation provider: model={generation_model}")
|
||||
logger.info("Created Ollama generation provider: model=%s", generation_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "anthropic":
|
||||
@@ -114,7 +114,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
|
||||
api_key=api_key,
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Anthropic generation provider: model={generation_model}")
|
||||
logger.info("Created Anthropic generation provider: model=%s", generation_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "bedrock":
|
||||
@@ -133,7 +133,7 @@ async def create_generation_provider(provider_name: str) -> Provider:
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Bedrock generation provider: model={generation_model}")
|
||||
logger.info("Created Bedrock generation provider: model=%s", generation_model)
|
||||
return provider
|
||||
|
||||
else:
|
||||
@@ -178,7 +178,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created OpenAI embedding provider: model={embedding_model}")
|
||||
logger.info("Created OpenAI embedding provider: model=%s", embedding_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "ollama":
|
||||
@@ -195,7 +195,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created Ollama embedding provider: model={embedding_model}")
|
||||
logger.info("Created Ollama embedding provider: model=%s", embedding_model)
|
||||
return provider
|
||||
|
||||
elif provider_name == "bedrock":
|
||||
@@ -214,7 +214,7 @@ async def create_embedding_provider(provider_name: str) -> Provider:
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created Bedrock embedding provider: model={embedding_model}")
|
||||
logger.info("Created Bedrock embedding provider: model=%s", embedding_model)
|
||||
return provider
|
||||
|
||||
else:
|
||||
|
||||
@@ -62,7 +62,7 @@ def create_sampling_callback(provider: Provider):
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
"""Handle sampling requests using the configured provider."""
|
||||
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
|
||||
logger.debug("Sampling callback invoked with %s messages", len(params.messages))
|
||||
|
||||
# Extract messages and build prompt
|
||||
messages_text = []
|
||||
@@ -77,7 +77,7 @@ def create_sampling_callback(provider: Provider):
|
||||
if params.systemPrompt:
|
||||
prompt = f"System: {params.systemPrompt}\n\n{prompt}"
|
||||
|
||||
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
|
||||
logger.debug("Generating response for prompt (%s chars)", len(prompt))
|
||||
|
||||
try:
|
||||
# Generate response using provider
|
||||
@@ -87,7 +87,9 @@ def create_sampling_callback(provider: Provider):
|
||||
max_tokens=params.maxTokens,
|
||||
)
|
||||
|
||||
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
|
||||
logger.info(
|
||||
"Sampling completed: %s chars from %s", len(response), model_name
|
||||
)
|
||||
|
||||
return types.CreateMessageResult(
|
||||
role="assistant",
|
||||
@@ -96,7 +98,7 @@ def create_sampling_callback(provider: Provider):
|
||||
stopReason="endTurn",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
|
||||
logger.error("Generation failed (%s): %s", provider.__class__.__name__, e)
|
||||
return types.ErrorData(
|
||||
code=types.INTERNAL_ERROR,
|
||||
message=f"Generation failed: {e!s}",
|
||||
|
||||
@@ -91,8 +91,10 @@ async def _poll_astrolabe_search_for_note(
|
||||
)
|
||||
if note_result is not None:
|
||||
logger.info(
|
||||
f"Note {note_id} surfaced in Astrolabe search after {attempts} "
|
||||
f"attempts (~{attempts * 2}s)"
|
||||
"Note %s surfaced in Astrolabe search after %s attempts (~%ss)",
|
||||
note_id,
|
||||
attempts,
|
||||
attempts * 2,
|
||||
)
|
||||
return note_result
|
||||
await anyio.sleep(2)
|
||||
@@ -245,7 +247,7 @@ async def test_chunk_context_endpoint_uses_app_password(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
except Exception as cleanup_err:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {cleanup_err}")
|
||||
logger.warning("Cleanup failed for note %s: %s", note_id, cleanup_err)
|
||||
await context.close()
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Logging in to Nextcloud as {username}...")
|
||||
logger.info("Logging in to Nextcloud as %s...", username)
|
||||
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
|
||||
|
||||
# Fill in login form
|
||||
@@ -68,7 +68,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
assert "/login" not in current_url, (
|
||||
f"Login failed for {username}, still on login page"
|
||||
)
|
||||
logger.info(f"✓ Successfully logged in as {username}")
|
||||
logger.info("✓ Successfully logged in as %s", username)
|
||||
|
||||
|
||||
async def navigate_to_astrolabe_settings(page: Page):
|
||||
@@ -80,7 +80,7 @@ async def navigate_to_astrolabe_settings(page: Page):
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
settings_url = f"{nextcloud_url}/settings/user/astrolabe"
|
||||
|
||||
logger.info(f"Navigating to Astrolabe settings: {settings_url}")
|
||||
logger.info("Navigating to Astrolabe settings: %s", settings_url)
|
||||
await page.goto(settings_url, wait_until="networkidle", timeout=30000)
|
||||
|
||||
# Verify we're on the settings page
|
||||
@@ -110,7 +110,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Authorizing search access (Step 1) for {username}...")
|
||||
logger.info("Authorizing search access (Step 1) for %s...", username)
|
||||
|
||||
# Check if already on Astrolabe settings page, if not navigate there
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
@@ -124,7 +124,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
# Check for "Active" badge (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ Already fully authorized for {username} (Active badge)")
|
||||
logger.info("✓ Already fully authorized for %s (Active badge)", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -136,7 +136,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 1 already complete for {username}")
|
||||
logger.info("✓ Step 1 already complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -146,36 +146,38 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
|
||||
try:
|
||||
await authorize_button.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"Found Authorize button for {username}")
|
||||
logger.info("Found Authorize button for %s", username)
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Could not find Authorize button for {username}. Screenshot: {screenshot_path}"
|
||||
"Could not find Authorize button for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"Authorize button not found for {username}")
|
||||
|
||||
# Click the Authorize button - this will redirect to OAuth provider
|
||||
# Use force=True to bypass stability check which can timeout due to CSS transitions
|
||||
await authorize_button.click(force=True)
|
||||
logger.info(f"Clicked Authorize button for {username}")
|
||||
logger.info("Clicked Authorize button for %s", username)
|
||||
|
||||
# Wait for OAuth redirect to complete
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info(f"After networkidle, current URL: {page.url}")
|
||||
logger.info("After networkidle, current URL: %s", page.url)
|
||||
|
||||
# Take screenshot to see current state
|
||||
await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png")
|
||||
logger.info(f"Screenshot saved: /tmp/astrolabe_after_authorize_{username}.png")
|
||||
logger.info("Screenshot saved: /tmp/astrolabe_after_authorize_%s.png", username)
|
||||
|
||||
# Handle OIDC consent screen if present
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
if consent_handled:
|
||||
logger.info(f"✓ OAuth consent granted for {username}")
|
||||
logger.info("✓ OAuth consent granted for %s", username)
|
||||
else:
|
||||
logger.info(
|
||||
f"No consent screen required for {username} (may be previously authorized)"
|
||||
"No consent screen required for %s (may be previously authorized)", username
|
||||
)
|
||||
|
||||
# Wait for redirect back to Astrolabe settings
|
||||
@@ -184,12 +186,12 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
await page.wait_for_url(
|
||||
f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000
|
||||
)
|
||||
logger.info(f"Redirected back to Astrolabe settings for {username}")
|
||||
logger.info("Redirected back to Astrolabe settings for %s", username)
|
||||
except Exception:
|
||||
# Check if we're already on settings page
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
logger.warning(
|
||||
f"Not redirected to Astrolabe settings, current URL: {page.url}"
|
||||
"Not redirected to Astrolabe settings, current URL: %s", page.url
|
||||
)
|
||||
# Navigate manually
|
||||
await page.goto(
|
||||
@@ -205,7 +207,9 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
# First check if "Active" badge is shown (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ OAuth authorization complete for {username} (Active badge)")
|
||||
logger.info(
|
||||
"✓ OAuth authorization complete for %s (Active badge)", username
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -217,7 +221,7 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"✓ Step 1 OAuth authorization complete for {username}")
|
||||
logger.info("✓ Step 1 OAuth authorization complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -226,7 +230,9 @@ async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Authorization badge not visible for {username}. Screenshot: {screenshot_path}"
|
||||
"Authorization badge not visible for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"OAuth authorization did not complete for {username}")
|
||||
|
||||
@@ -244,28 +250,28 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
True if consent was handled, False if no consent screen was found
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Checking for consent screen at URL: {page.url}")
|
||||
logger.info("Checking for consent screen at URL: %s", page.url)
|
||||
|
||||
# Check if consent screen is present - try multiple selectors
|
||||
# The consent screen may be #oidc-consent or use a different format
|
||||
consent_div = await page.query_selector("#oidc-consent")
|
||||
|
||||
if consent_div:
|
||||
logger.info(f"Consent screen detected via #oidc-consent for {username}")
|
||||
logger.info("Consent screen detected via #oidc-consent for %s", username)
|
||||
# Get consent screen data attributes for logging
|
||||
client_name = await consent_div.get_attribute("data-client-name")
|
||||
scopes_attr = await consent_div.get_attribute("data-scopes")
|
||||
logger.info(f" Client: {client_name}")
|
||||
logger.info(f" Requested scopes: {scopes_attr}")
|
||||
logger.info(" Client: %s", client_name)
|
||||
logger.info(" Requested scopes: %s", scopes_attr)
|
||||
else:
|
||||
# Check for Allow button directly (different consent screen format)
|
||||
allow_button = page.locator('button:has-text("Allow")')
|
||||
if await allow_button.count() > 0:
|
||||
logger.info(f"Consent screen detected via Allow button for {username}")
|
||||
logger.info("Consent screen detected via Allow button for %s", username)
|
||||
else:
|
||||
logger.info(f"No consent screen found for {username} at {page.url}")
|
||||
logger.info("No consent screen found for %s at %s", username, page.url)
|
||||
await page.screenshot(path=f"/tmp/no_consent_screen_{username}.png")
|
||||
logger.info(f"Screenshot: /tmp/no_consent_screen_{username}.png")
|
||||
logger.info("Screenshot: /tmp/no_consent_screen_%s.png", username)
|
||||
return False
|
||||
|
||||
# Wait for Vue.js to render the Allow button
|
||||
@@ -275,19 +281,19 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
except Exception as e:
|
||||
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f" Timeout waiting for Allow button: {e}")
|
||||
logger.error(" Timeout waiting for Allow button: %s", e)
|
||||
raise
|
||||
|
||||
# Check all scope checkboxes
|
||||
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
|
||||
if scope_checkboxes:
|
||||
logger.info(f" Found {len(scope_checkboxes)} scope checkboxes")
|
||||
logger.info(" Found %s scope checkboxes", len(scope_checkboxes))
|
||||
for i, checkbox in enumerate(scope_checkboxes):
|
||||
is_checked = await checkbox.is_checked()
|
||||
is_disabled = await checkbox.is_disabled()
|
||||
if not is_checked and not is_disabled:
|
||||
await checkbox.check()
|
||||
logger.info(f" ✓ Checked scope checkbox {i + 1}")
|
||||
logger.info(" ✓ Checked scope checkbox %s", i + 1)
|
||||
|
||||
# Click the Allow button using JavaScript (handles viewport issues)
|
||||
allow_button_locator = page.locator('button:has-text("Allow")')
|
||||
@@ -295,16 +301,16 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
# Debug: take screenshot before clicking Allow
|
||||
await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png")
|
||||
logger.info(
|
||||
f" Screenshot before Allow: /tmp/consent_before_allow_{username}.png"
|
||||
" Screenshot before Allow: /tmp/consent_before_allow_%s.png", username
|
||||
)
|
||||
|
||||
button_count = await allow_button_locator.count()
|
||||
logger.info(f" Found {button_count} Allow button(s)")
|
||||
logger.info(" Found %s Allow button(s)", button_count)
|
||||
|
||||
if button_count > 0:
|
||||
current_url = page.url
|
||||
logger.info(f" Current URL: {current_url}")
|
||||
logger.info(f" Clicking Allow button for {username}...")
|
||||
logger.info(" Current URL: %s", current_url)
|
||||
logger.info(" Clicking Allow button for %s...", username)
|
||||
|
||||
# Use JavaScript click to handle consent buttons (proven pattern from conftest.py)
|
||||
# This is more reliable than Playwright's click for Vue.js rendered buttons
|
||||
@@ -327,10 +333,10 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
lambda url: url != current_url,
|
||||
timeout=30000,
|
||||
)
|
||||
logger.info(f" URL changed to: {page.url}")
|
||||
logger.info(" URL changed to: %s", page.url)
|
||||
except Exception as wait_error:
|
||||
# If URL didn't change, check console for errors
|
||||
logger.warning(f" URL didn't change after click: {wait_error}")
|
||||
logger.warning(" URL didn't change after click: %s", wait_error)
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
|
||||
# Try alternative: manually POST consent and navigate
|
||||
@@ -356,21 +362,21 @@ async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
}
|
||||
"""
|
||||
)
|
||||
logger.info(f" Manual consent returned URL: {redirect_url}")
|
||||
logger.info(" Manual consent returned URL: %s", redirect_url)
|
||||
await page.goto(redirect_url, wait_until="networkidle")
|
||||
except Exception as manual_error:
|
||||
logger.error(f" Manual consent also failed: {manual_error}")
|
||||
logger.error(" Manual consent also failed: %s", manual_error)
|
||||
raise
|
||||
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
logger.info(f" Consent granted for {username}")
|
||||
logger.info(" Consent granted for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.error(f" Allow button not found for {username}")
|
||||
logger.error(" Allow button not found for %s", username)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling consent screen for {username}: {e}")
|
||||
logger.error("Error handling consent screen for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -387,7 +393,7 @@ async def generate_app_password(
|
||||
Returns:
|
||||
The generated app password string
|
||||
"""
|
||||
logger.info(f"Generating app password for {username}...")
|
||||
logger.info("Generating app password for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -398,7 +404,7 @@ async def generate_app_password(
|
||||
# Fill the app password input field (selector confirmed via Playwright MCP)
|
||||
app_password_input = page.locator('input[placeholder="App name"]')
|
||||
await app_password_input.fill(app_name)
|
||||
logger.info(f"Entered app name: {app_name}")
|
||||
logger.info("Entered app name: %s", app_name)
|
||||
|
||||
# Wait for Vue.js to react and enable the button (needs 1 second, not 0.5)
|
||||
await anyio.sleep(1.0)
|
||||
@@ -427,7 +433,7 @@ async def generate_app_password(
|
||||
# Debug screenshot after clicking create
|
||||
await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png")
|
||||
logger.info(
|
||||
f"Screenshot after create: /tmp/app_password_after_create_{username}.png"
|
||||
"Screenshot after create: /tmp/app_password_after_create_%s.png", username
|
||||
)
|
||||
|
||||
# Find the Login input field which should have the username value
|
||||
@@ -440,7 +446,7 @@ async def generate_app_password(
|
||||
|
||||
# Get all visible input elements
|
||||
all_inputs = await page.locator('input[type="text"]').all()
|
||||
logger.info(f"Found {len(all_inputs)} text input elements")
|
||||
logger.info("Found %s text input elements", len(all_inputs))
|
||||
|
||||
# Check each input to find the one with the app password
|
||||
for idx, input_elem in enumerate(all_inputs):
|
||||
@@ -449,15 +455,18 @@ async def generate_app_password(
|
||||
if value and "-" in value and len(value) > 20:
|
||||
app_password = value.strip()
|
||||
logger.info(
|
||||
f"Found app password in input {idx}: '{app_password}' (length: {len(app_password)})"
|
||||
"Found app password in input %s: '%s' (length: %s)",
|
||||
idx,
|
||||
app_password,
|
||||
len(app_password),
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get value from input {idx}: {e}")
|
||||
logger.debug("Could not get value from input %s: %s", idx, e)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find app password dialog or extract password: {e}")
|
||||
logger.error("Failed to find app password dialog or extract password: %s", e)
|
||||
|
||||
if not app_password:
|
||||
# Take screenshot for debugging
|
||||
@@ -474,9 +483,9 @@ async def generate_app_password(
|
||||
app_password,
|
||||
):
|
||||
logger.error(
|
||||
f"Extracted password does not match expected format: '{app_password}'"
|
||||
"Extracted password does not match expected format: '%s'", app_password
|
||||
)
|
||||
logger.error(f"Password repr: {repr(app_password)}")
|
||||
logger.error("Password repr: %s", repr(app_password))
|
||||
screenshot_path = f"/tmp/app_password_invalid_format_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
@@ -484,7 +493,9 @@ async def generate_app_password(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
|
||||
"✓ Generated app password for %s: %s... (validated)",
|
||||
username,
|
||||
app_password[:10],
|
||||
)
|
||||
|
||||
# Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks)
|
||||
@@ -509,7 +520,7 @@ async def enable_background_sync_via_app_password(
|
||||
Returns:
|
||||
True if background sync was enabled successfully
|
||||
"""
|
||||
logger.info(f"Enabling background sync via app password for {username}...")
|
||||
logger.info("Enabling background sync via app password for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -524,7 +535,7 @@ async def enable_background_sync_via_app_password(
|
||||
def log_response(resp):
|
||||
response_info = f"{resp.status} {resp.url}"
|
||||
network_responses.append(response_info)
|
||||
logger.info(f"Response: {response_info}")
|
||||
logger.info("Response: %s", response_info)
|
||||
|
||||
def log_console(msg):
|
||||
console_messages.append(f"[{msg.type}] {msg.text}")
|
||||
@@ -546,7 +557,7 @@ async def enable_background_sync_via_app_password(
|
||||
# First check for overall "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.info(f"✓ Background sync already active for {username}")
|
||||
logger.info("✓ Background sync already active for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -558,7 +569,7 @@ async def enable_background_sync_via_app_password(
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 (app password) already complete for {username}")
|
||||
logger.info("✓ Step 2 (app password) already complete for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -580,7 +591,7 @@ async def enable_background_sync_via_app_password(
|
||||
|
||||
# Enter the app password
|
||||
await app_password_input.fill(app_password)
|
||||
logger.info(f"Entered app password for {username}")
|
||||
logger.info("Entered app password for %s", username)
|
||||
|
||||
# Wait a moment for any validation to complete
|
||||
await anyio.sleep(0.5)
|
||||
@@ -588,14 +599,14 @@ async def enable_background_sync_via_app_password(
|
||||
# Take screenshot before clicking Save to check for warnings
|
||||
screenshot_path = f"/tmp/before_save_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.info(f"Screenshot taken before Save: {screenshot_path}")
|
||||
logger.info("Screenshot taken before Save: %s", screenshot_path)
|
||||
|
||||
# Find and click the Save button
|
||||
save_button = page.get_by_role("button", name="Save")
|
||||
|
||||
# Check if Save button is enabled
|
||||
is_disabled = await save_button.is_disabled()
|
||||
logger.info(f"Save button disabled state: {is_disabled}")
|
||||
logger.info("Save button disabled state: %s", is_disabled)
|
||||
|
||||
await save_button.click()
|
||||
logger.info("Clicked Save button")
|
||||
@@ -604,24 +615,24 @@ async def enable_background_sync_via_app_password(
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Log network requests after clicking Save
|
||||
logger.info(f"Network requests after Save for {username}:")
|
||||
logger.info("Network requests after Save for %s:", username)
|
||||
for req in network_requests[-10:]: # Last 10 requests
|
||||
logger.info(f" {req}")
|
||||
logger.info(" %s", req)
|
||||
|
||||
# Log network responses after clicking Save
|
||||
logger.info(f"Network responses after Save for {username}:")
|
||||
logger.info("Network responses after Save for %s:", username)
|
||||
for resp in network_responses[-10:]: # Last 10 responses
|
||||
logger.info(f" {resp}")
|
||||
logger.info(" %s", resp)
|
||||
|
||||
# Check specifically for the credentials POST response
|
||||
credentials_responses = [
|
||||
r for r in network_responses if "background-sync/credentials" in r
|
||||
]
|
||||
if credentials_responses:
|
||||
logger.info(f"Credentials endpoint response: {credentials_responses[-1]}")
|
||||
logger.info("Credentials endpoint response: %s", credentials_responses[-1])
|
||||
if "200" not in credentials_responses[-1]:
|
||||
logger.error(
|
||||
f"Credentials POST did not return 200 OK: {credentials_responses[-1]}"
|
||||
"Credentials POST did not return 200 OK: %s", credentials_responses[-1]
|
||||
)
|
||||
else:
|
||||
logger.warning("No response found for credentials endpoint!")
|
||||
@@ -633,16 +644,16 @@ async def enable_background_sync_via_app_password(
|
||||
|
||||
# Log any console messages
|
||||
if console_messages:
|
||||
logger.info(f"Console messages for {username}:")
|
||||
logger.info("Console messages for %s:", username)
|
||||
for msg in console_messages:
|
||||
logger.info(f" {msg}")
|
||||
logger.info(" %s", msg)
|
||||
|
||||
# Check for error notifications (toast messages)
|
||||
try:
|
||||
error_toast = page.locator(".toastify.toast-error, .toast-error")
|
||||
if await error_toast.count() > 0:
|
||||
error_text = await error_toast.first.text_content()
|
||||
logger.error(f"Error notification for {username}: {error_text}")
|
||||
logger.error("Error notification for %s: %s", username, error_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -653,7 +664,7 @@ async def enable_background_sync_via_app_password(
|
||||
if await active_text.count() > 0:
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Background sync enabled for {username} - Active badge visible"
|
||||
"✓ Background sync enabled for %s - Active badge visible", username
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
@@ -667,7 +678,8 @@ async def enable_background_sync_via_app_password(
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Step 2 (app password) enabled for {username} - Complete badge visible"
|
||||
"✓ Step 2 (app password) enabled for %s - Complete badge visible",
|
||||
username,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
@@ -677,8 +689,9 @@ async def enable_background_sync_via_app_password(
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Neither Active nor Complete badge appeared for {username}. "
|
||||
f"Screenshot: {screenshot_path}"
|
||||
"Neither Active nor Complete badge appeared for %s. Screenshot: %s",
|
||||
username,
|
||||
screenshot_path,
|
||||
)
|
||||
raise ValueError(f"Background sync setup did not complete for {username}")
|
||||
|
||||
@@ -702,7 +715,7 @@ async def complete_astrolabe_authorization(
|
||||
Returns:
|
||||
Dict with {"step1": bool, "step2": bool, "app_password": str | None}
|
||||
"""
|
||||
logger.info(f"Starting full Astrolabe authorization for {username}...")
|
||||
logger.info("Starting full Astrolabe authorization for %s...", username)
|
||||
|
||||
result = {"step1": False, "step2": False, "app_password": None}
|
||||
|
||||
@@ -712,9 +725,9 @@ async def complete_astrolabe_authorization(
|
||||
# Step 1: OAuth authorization
|
||||
try:
|
||||
result["step1"] = await authorize_search_access(page, username)
|
||||
logger.info(f"✓ Step 1 complete for {username}")
|
||||
logger.info("✓ Step 1 complete for %s", username)
|
||||
except Exception as e:
|
||||
logger.error(f"Step 1 failed for {username}: {e}")
|
||||
logger.error("Step 1 failed for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
# Navigate back to settings if needed (OAuth might have redirected elsewhere)
|
||||
@@ -728,7 +741,7 @@ async def complete_astrolabe_authorization(
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 already complete for {username}")
|
||||
logger.info("✓ Step 2 already complete for %s", username)
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
@@ -738,7 +751,7 @@ async def complete_astrolabe_authorization(
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.count() > 0 and await active_text.is_visible():
|
||||
logger.info(f"✓ Authorization already fully active for {username}")
|
||||
logger.info("✓ Authorization already fully active for %s", username)
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
@@ -752,12 +765,12 @@ async def complete_astrolabe_authorization(
|
||||
result["step2"] = await enable_background_sync_via_app_password(
|
||||
page, username, app_password
|
||||
)
|
||||
logger.info(f"✓ Step 2 complete for {username}")
|
||||
logger.info("✓ Step 2 complete for %s", username)
|
||||
except Exception as e:
|
||||
logger.error(f"Step 2 failed for {username}: {e}")
|
||||
logger.error("Step 2 failed for %s: %s", username, e)
|
||||
raise
|
||||
|
||||
logger.info(f"✓ Full Astrolabe authorization complete for {username}")
|
||||
logger.info("✓ Full Astrolabe authorization complete for %s", username)
|
||||
return result
|
||||
|
||||
|
||||
@@ -773,7 +786,7 @@ async def verify_app_password_created(username: str) -> bool:
|
||||
Returns:
|
||||
True if background sync app password exists
|
||||
"""
|
||||
logger.info(f"Verifying background sync app password for {username}...")
|
||||
logger.info("Verifying background sync app password for %s...", username)
|
||||
|
||||
# Query the database to check for background sync credentials
|
||||
# Astrolabe stores app passwords in oc_preferences, not oc_authtoken
|
||||
@@ -809,7 +822,7 @@ async def verify_app_password_created(username: str) -> bool:
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
logger.debug(f"Background sync credentials query result:\n{output}")
|
||||
logger.debug("Background sync credentials query result:\\n%s", output)
|
||||
|
||||
# Check if background sync credentials exist
|
||||
# We should see 3 rows: background_sync_password, background_sync_type, background_sync_provisioned_at
|
||||
@@ -818,19 +831,22 @@ async def verify_app_password_created(username: str) -> bool:
|
||||
if len(lines) >= 3: # Header + at least 2 data rows (password + type)
|
||||
# Verify background_sync_type is "app_password"
|
||||
if "app_password" in output:
|
||||
logger.info(f"✓ Background sync app password stored for {username}")
|
||||
logger.info("✓ Background sync app password stored for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Background sync credentials found but type is not app_password for {username}"
|
||||
"Background sync credentials found but type is not app_password for %s",
|
||||
username,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.warning(f"No background sync credentials found for {username}")
|
||||
logger.warning("No background sync credentials found for %s", username)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking background sync credentials for {username}: {e}")
|
||||
logger.error(
|
||||
"Error checking background sync credentials for %s: %s", username, e
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -892,10 +908,13 @@ def clear_stale_test_state(clear_preferences: bool = False) -> None:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
f"Failed to clear {label} (rc={result.returncode}): {result.stderr}"
|
||||
"Failed to clear %s (rc=%s): %s",
|
||||
label,
|
||||
result.returncode,
|
||||
result.stderr,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Cleared {label}")
|
||||
logger.debug("Cleared %s", label)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -946,7 +965,9 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
# Use nc_client to check if user exists
|
||||
user_details = await nc_client.users.get_user_details(username)
|
||||
logger.info(
|
||||
f"✓ Confirmed {username} exists (display name: {user_details.displayname})"
|
||||
"✓ Confirmed %s exists (display name: %s)",
|
||||
username,
|
||||
user_details.displayname,
|
||||
)
|
||||
except Exception as e:
|
||||
raise AssertionError(
|
||||
@@ -957,9 +978,9 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
results = {}
|
||||
|
||||
for username in test_users:
|
||||
logger.info(f"\n{'=' * 60}")
|
||||
logger.info(f"Testing background sync enablement for: {username}")
|
||||
logger.info(f"{'=' * 60}")
|
||||
logger.info("\\n%s", "=" * 60)
|
||||
logger.info("Testing background sync enablement for: %s", username)
|
||||
logger.info("%s", "=" * 60)
|
||||
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
@@ -994,17 +1015,20 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
"background_sync_active": sync_enabled and app_password_stored,
|
||||
}
|
||||
|
||||
logger.info(f"\n{username} results:")
|
||||
logger.info("\\n%s results:", username)
|
||||
logger.info(" Settings accessed: ✓")
|
||||
logger.info(f" App password generated: {'✓' if app_password else '✗'}")
|
||||
logger.info(f" Sync enabled: {'✓' if sync_enabled else '✗'}")
|
||||
logger.info(f" App password stored: {'✓' if app_password_stored else '✗'}")
|
||||
logger.info(" App password generated: %s", "✓" if app_password else "✗")
|
||||
logger.info(" Sync enabled: %s", "✓" if sync_enabled else "✗")
|
||||
logger.info(
|
||||
f" Background sync active: {'✓' if (sync_enabled and app_password_stored) else '✗'}"
|
||||
" App password stored: %s", "✓" if app_password_stored else "✗"
|
||||
)
|
||||
logger.info(
|
||||
" Background sync active: %s",
|
||||
"✓" if (sync_enabled and app_password_stored) else "✗",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during {username} test: {e}")
|
||||
logger.error("Error during %s test: %s", username, e)
|
||||
results[username] = {
|
||||
"settings_accessed": False,
|
||||
"app_password_generated": False,
|
||||
@@ -1018,18 +1042,18 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
await context.close()
|
||||
|
||||
# Verify all users succeeded
|
||||
logger.info(f"\n{'=' * 60}")
|
||||
logger.info("\\n%s", "=" * 60)
|
||||
logger.info("Test Summary")
|
||||
logger.info(f"{'=' * 60}")
|
||||
logger.info("%s", "=" * 60)
|
||||
|
||||
for username, result in results.items():
|
||||
logger.info(f"\n{username}:")
|
||||
logger.info("\\n%s:", username)
|
||||
for key, value in result.items():
|
||||
if key != "error":
|
||||
status = "✓" if value else "✗"
|
||||
logger.info(f" {key}: {status}")
|
||||
logger.info(" %s: %s", key, status)
|
||||
elif value:
|
||||
logger.info(f" error: {value}")
|
||||
logger.info(" error: %s", value)
|
||||
|
||||
# Assert all users successfully enabled background sync
|
||||
for username in test_users:
|
||||
@@ -1051,7 +1075,8 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"\n✓ All {len(test_users)} users successfully enabled background sync via app passwords!"
|
||||
"\\n✓ All %s users successfully enabled background sync via app passwords!",
|
||||
len(test_users),
|
||||
)
|
||||
|
||||
|
||||
@@ -1065,7 +1090,7 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
Returns:
|
||||
True if revocation was successful
|
||||
"""
|
||||
logger.info(f"Revoking background sync access for {username}...")
|
||||
logger.info("Revoking background sync access for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -1080,7 +1105,7 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
def log_response(resp):
|
||||
response_info = f"{resp.status} {resp.url}"
|
||||
network_responses.append(response_info)
|
||||
logger.info(f"Response: {response_info}")
|
||||
logger.info("Response: %s", response_info)
|
||||
|
||||
def log_console(msg):
|
||||
console_messages.append(f"[{msg.type}] {msg.text}")
|
||||
@@ -1102,11 +1127,11 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if not await active_text.is_visible(timeout=2000):
|
||||
logger.warning(
|
||||
f"Background sync not active for {username}, nothing to revoke"
|
||||
"Background sync not active for %s, nothing to revoke", username
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning(f"Could not find Active badge for {username}")
|
||||
logger.warning("Could not find Active badge for %s", username)
|
||||
return False
|
||||
|
||||
# Find the "Revoke Access" button
|
||||
@@ -1134,21 +1159,21 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Log network requests after clicking
|
||||
logger.info(f"Network requests after Revoke for {username}:")
|
||||
logger.info("Network requests after Revoke for %s:", username)
|
||||
for req in network_requests[-10:]:
|
||||
logger.info(f" {req}")
|
||||
logger.info(" %s", req)
|
||||
|
||||
# Log network responses
|
||||
logger.info(f"Network responses after Revoke for {username}:")
|
||||
logger.info("Network responses after Revoke for %s:", username)
|
||||
for resp in network_responses[-10:]:
|
||||
logger.info(f" {resp}")
|
||||
logger.info(" %s", resp)
|
||||
|
||||
# Check specifically for the revoke POST response
|
||||
revoke_responses = [r for r in network_responses if "credentials/revoke" in r]
|
||||
if revoke_responses:
|
||||
logger.info(f"Revoke endpoint response: {revoke_responses[-1]}")
|
||||
logger.info("Revoke endpoint response: %s", revoke_responses[-1])
|
||||
if "200" not in revoke_responses[-1]:
|
||||
logger.error(f"Revoke POST did not return 200 OK: {revoke_responses[-1]}")
|
||||
logger.error("Revoke POST did not return 200 OK: %s", revoke_responses[-1])
|
||||
return False
|
||||
else:
|
||||
logger.warning("No response found for credentials/revoke endpoint!")
|
||||
@@ -1159,16 +1184,16 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
|
||||
# Log any console messages
|
||||
if console_messages:
|
||||
logger.info(f"Console messages for {username}:")
|
||||
logger.info("Console messages for %s:", username)
|
||||
for msg in console_messages:
|
||||
logger.info(f" {msg}")
|
||||
logger.info(" %s", msg)
|
||||
|
||||
# Check for error notifications (toast messages)
|
||||
try:
|
||||
error_toast = page.locator(".toastify.toast-error, .toast-error")
|
||||
if await error_toast.count() > 0:
|
||||
error_text = await error_toast.first.text_content()
|
||||
logger.error(f"Error notification for {username}: {error_text}")
|
||||
logger.error("Error notification for %s: %s", username, error_text)
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1177,14 +1202,14 @@ async def revoke_background_sync_access(page: Page, username: str) -> bool:
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.error(f"Active badge still visible for {username} after revoke!")
|
||||
logger.error("Active badge still visible for %s after revoke!", username)
|
||||
screenshot_path = f"/tmp/astrolabe_revoke_still_active_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"✓ Background sync access revoked for {username}")
|
||||
logger.info("✓ Background sync access revoked for %s", username)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1197,7 +1222,7 @@ async def verify_app_password_deleted(username: str) -> bool:
|
||||
Returns:
|
||||
True if background sync credentials no longer exist
|
||||
"""
|
||||
logger.info(f"Verifying background sync credentials deleted for {username}...")
|
||||
logger.info("Verifying background sync credentials deleted for %s...", username)
|
||||
|
||||
query = f"""
|
||||
SELECT userid, configkey, configvalue
|
||||
@@ -1230,18 +1255,20 @@ async def verify_app_password_deleted(username: str) -> bool:
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
logger.debug(f"Background sync credentials query result:\n{output}")
|
||||
logger.debug("Background sync credentials query result:\\n%s", output)
|
||||
|
||||
# After deletion, we should NOT see background_sync_password
|
||||
if "background_sync_password" not in output:
|
||||
logger.info(f"✓ Background sync credentials deleted for {username}")
|
||||
logger.info("✓ Background sync credentials deleted for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Background sync credentials still exist for {username}")
|
||||
logger.warning("Background sync credentials still exist for %s", username)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking background sync credentials for {username}: {e}")
|
||||
logger.error(
|
||||
"Error checking background sync credentials for %s: %s", username, e
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -1316,7 +1343,9 @@ async def test_revoke_background_sync_access(
|
||||
f"Background sync credentials not deleted for {username}"
|
||||
)
|
||||
|
||||
logger.info(f"\n✓ Successfully revoked background sync access for {username}!")
|
||||
logger.info(
|
||||
"\\n✓ Successfully revoked background sync access for %s!", username
|
||||
)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
@@ -58,7 +58,7 @@ async def wait_for_vector_sync(
|
||||
while waited < timeout_seconds:
|
||||
sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||
if sync_status.isError:
|
||||
logger.warning(f"Vector sync status error: {sync_status}")
|
||||
logger.warning("Vector sync status error: %s", sync_status)
|
||||
return False, None
|
||||
|
||||
status_data = json.loads(sync_status.content[0].text)
|
||||
@@ -66,14 +66,18 @@ async def wait_for_vector_sync(
|
||||
pending_count = status_data.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Sync status at {waited}s: indexed={indexed_count}, "
|
||||
f"pending={pending_count}, status={status_data.get('status')}"
|
||||
"Sync status at %ss: indexed=%s, pending=%s, status=%s",
|
||||
waited,
|
||||
indexed_count,
|
||||
pending_count,
|
||||
status_data.get("status"),
|
||||
)
|
||||
|
||||
if indexed_count > initial_indexed_count and pending_count == 0:
|
||||
logger.info(
|
||||
f"✓ Sync complete: {indexed_count} documents indexed "
|
||||
f"(was {initial_indexed_count})"
|
||||
"✓ Sync complete: %s documents indexed (was %s)",
|
||||
indexed_count,
|
||||
initial_indexed_count,
|
||||
)
|
||||
return True, status_data
|
||||
|
||||
@@ -142,7 +146,7 @@ async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
# Phase 2: Complete full Astrolabe authorization (OAuth + app password)
|
||||
await login_to_nextcloud(page, username, password)
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
logger.info(f"Authorization result: {auth_result}")
|
||||
logger.info("Authorization result: %s", auth_result)
|
||||
|
||||
# Create MCP client session as alice - all MCP operations inside this block
|
||||
async with create_mcp_client_session(
|
||||
@@ -160,7 +164,7 @@ async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
|
||||
initial_data = json.loads(initial_sync.content[0].text)
|
||||
initial_count = initial_data.get("indexed_count", 0)
|
||||
logger.info(f"Initial indexed count: {initial_count}")
|
||||
logger.info("Initial indexed count: %s", initial_count)
|
||||
|
||||
# Create note with unique searchable term
|
||||
unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}"
|
||||
@@ -189,7 +193,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
|
||||
note_data = json.loads(note_response.content[0].text)
|
||||
note_id = note_data.get("id")
|
||||
logger.info(f"Created test note ID: {note_id}")
|
||||
logger.info("Created test note ID: %s", note_id)
|
||||
|
||||
# Phase 4: Wait for vector indexing
|
||||
sync_complete, status = await wait_for_vector_sync(
|
||||
@@ -205,7 +209,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
search_input = page.locator(".mcp-search-input input")
|
||||
await search_input.wait_for(timeout=10000, state="visible")
|
||||
await search_input.fill(unique_term)
|
||||
logger.info(f"Entered search query: {unique_term}")
|
||||
logger.info("Entered search query: %s", unique_term)
|
||||
|
||||
# Trigger search by pressing Enter on the input field
|
||||
# This is wired to performSearch via @keyup.enter in the Vue component
|
||||
@@ -246,7 +250,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
for attempt in range(60): # 60 attempts, 500ms each = 30s total
|
||||
if await error_note.count() > 0:
|
||||
error_text = await error_note.text_content()
|
||||
logger.error(f"Search error: {error_text}")
|
||||
logger.error("Search error: %s", error_text)
|
||||
pytest.fail(f"Search failed with error: {error_text}")
|
||||
|
||||
if await no_results_text.count() > 0:
|
||||
@@ -261,13 +265,13 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
|
||||
if await results_text_pattern.count() > 0:
|
||||
results_text = await results_text_pattern.first.text_content()
|
||||
logger.info(f"Found results: {results_text}")
|
||||
logger.info("Found results: %s", results_text)
|
||||
found_state = True
|
||||
break
|
||||
|
||||
if attempt % 10 == 0:
|
||||
logger.info(
|
||||
f"Waiting for results... (attempt {attempt + 1}/60)"
|
||||
"Waiting for results... (attempt %s/60)", attempt + 1
|
||||
)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
@@ -275,8 +279,8 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
if not found_state:
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
logger.error("Search state not resolved. Page URL: %s", page.url)
|
||||
logger.error("Page content snippet: %s", page_content[:2000])
|
||||
raise AssertionError("Search did not complete within timeout")
|
||||
|
||||
except AssertionError:
|
||||
@@ -285,8 +289,8 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
# Take another screenshot and get page content for debugging
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
logger.error("Search state not resolved. Page URL: %s", page.url)
|
||||
logger.error("Page content snippet: %s", page_content[:2000])
|
||||
raise AssertionError(f"Search did not complete: {e}")
|
||||
|
||||
logger.info("Results loaded")
|
||||
@@ -316,7 +320,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
result_items = page.locator(".mcp-result-item")
|
||||
result_count = await result_items.count()
|
||||
assert result_count > 0, "No search results displayed"
|
||||
logger.info(f"✓ Found {result_count} search result(s)")
|
||||
logger.info("✓ Found %s search result(s)", result_count)
|
||||
|
||||
# Verify our note appears in results
|
||||
found_note = False
|
||||
@@ -326,7 +330,7 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
title_text = await title_elem.text_content()
|
||||
if title_text and unique_term in title_text:
|
||||
found_note = True
|
||||
logger.info(f"✓ Found test note in results: {title_text}")
|
||||
logger.info("✓ Found test note in results: %s", title_text)
|
||||
break
|
||||
|
||||
assert found_note, f"Created note with '{unique_term}' not found in results"
|
||||
@@ -342,14 +346,14 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id}")
|
||||
logger.info("✓ Cleaned up test note %s", note_id)
|
||||
note_id = None # Mark as cleaned
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
"Failed to delete note %s: %s", note_id, delete_response
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
logger.warning("Cleanup failed for note %s: %s", note_id, e)
|
||||
|
||||
finally:
|
||||
# Cleanup note if not already cleaned (create new client for cleanup)
|
||||
@@ -364,13 +368,13 @@ The visualization should show this document as a point in PCA-reduced space.
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id} (finally)")
|
||||
logger.info("✓ Cleaned up test note %s (finally)", note_id)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
"Failed to delete note %s: %s", note_id, delete_response
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
logger.warning("Cleanup failed for note %s: %s", note_id, e)
|
||||
|
||||
# Close browser context
|
||||
await context.close()
|
||||
|
||||
@@ -45,7 +45,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Logging in to Nextcloud as {username}...")
|
||||
logger.info("Logging in to Nextcloud as %s...", username)
|
||||
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
|
||||
|
||||
# Fill in login form
|
||||
@@ -62,7 +62,7 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
assert "/login" not in current_url, (
|
||||
f"Login failed for {username}, still on login page"
|
||||
)
|
||||
logger.info(f"✓ Successfully logged in as {username}")
|
||||
logger.info("✓ Successfully logged in as %s", username)
|
||||
|
||||
|
||||
async def generate_app_password(
|
||||
@@ -78,7 +78,7 @@ async def generate_app_password(
|
||||
Returns:
|
||||
The generated app password string
|
||||
"""
|
||||
logger.info(f"Generating app password for {username}...")
|
||||
logger.info("Generating app password for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -89,7 +89,7 @@ async def generate_app_password(
|
||||
# Fill the app password input field
|
||||
app_password_input = page.locator('input[placeholder="App name"]')
|
||||
await app_password_input.fill(app_name)
|
||||
logger.info(f"Entered app name: {app_name}")
|
||||
logger.info("Entered app name: %s", app_name)
|
||||
|
||||
# Wait for Vue.js to react and enable the button
|
||||
await anyio.sleep(1.0)
|
||||
@@ -116,12 +116,12 @@ async def generate_app_password(
|
||||
value = await input_elem.input_value()
|
||||
if value and "-" in value and len(value) > 20:
|
||||
app_password = value.strip()
|
||||
logger.info(f"Found app password in input {idx}")
|
||||
logger.info("Found app password in input %s", idx)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find app password dialog: {e}")
|
||||
logger.error("Failed to find app password dialog: %s", e)
|
||||
|
||||
if not app_password:
|
||||
screenshot_path = f"/tmp/app_password_generation_{username}.png"
|
||||
@@ -137,7 +137,7 @@ async def generate_app_password(
|
||||
):
|
||||
raise ValueError(f"App password format validation failed: {app_password}")
|
||||
|
||||
logger.info(f"✓ Generated app password for {username}")
|
||||
logger.info("✓ Generated app password for %s", username)
|
||||
|
||||
# Close the dialog
|
||||
close_button = page.get_by_role("button", name="Close")
|
||||
@@ -163,7 +163,7 @@ async def save_app_password_in_astrolabe(
|
||||
Returns:
|
||||
True if the password was saved successfully (based on network response)
|
||||
"""
|
||||
logger.info(f"Saving app password in Astrolabe for {username}...")
|
||||
logger.info("Saving app password in Astrolabe for %s...", username)
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
@@ -174,7 +174,7 @@ async def save_app_password_in_astrolabe(
|
||||
nonlocal credentials_response_status
|
||||
if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url:
|
||||
credentials_response_status = resp.status
|
||||
logger.info(f"Credentials endpoint response: {resp.status} {resp.url}")
|
||||
logger.info("Credentials endpoint response: %s %s", resp.status, resp.url)
|
||||
|
||||
page.on("response", capture_response)
|
||||
|
||||
@@ -188,7 +188,7 @@ async def save_app_password_in_astrolabe(
|
||||
try:
|
||||
complete_badge = page.locator('text="Complete"').first
|
||||
if await complete_badge.is_visible(timeout=2000):
|
||||
logger.info(f"✓ App password already configured for {username}")
|
||||
logger.info("✓ App password already configured for %s", username)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -208,7 +208,7 @@ async def save_app_password_in_astrolabe(
|
||||
|
||||
# Enter the app password
|
||||
await app_password_input.fill(app_password)
|
||||
logger.info(f"Entered app password for {username}")
|
||||
logger.info("Entered app password for %s", username)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
@@ -223,11 +223,13 @@ async def save_app_password_in_astrolabe(
|
||||
|
||||
# Verify the save was successful by checking network response
|
||||
if credentials_response_status == 200:
|
||||
logger.info(f"✓ App password saved successfully for {username}")
|
||||
logger.info("✓ App password saved successfully for %s", username)
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"App password save failed for {username}, status: {credentials_response_status}"
|
||||
"App password save failed for %s, status: %s",
|
||||
username,
|
||||
credentials_response_status,
|
||||
)
|
||||
screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
@@ -284,7 +286,7 @@ def get_background_sync_credentials(username: str) -> dict | None:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting credentials for {username}: {e}")
|
||||
logger.error("Error getting credentials for %s: %s", username, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -325,11 +327,11 @@ def delete_user_credentials(username: str) -> bool:
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
logger.info(f"Deleted credentials for {username}")
|
||||
logger.info("Deleted credentials for %s", username)
|
||||
return result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting credentials for {username}: {e}")
|
||||
logger.error("Error deleting credentials for %s: %s", username, e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -489,7 +491,7 @@ async def test_credential_isolation_between_users(
|
||||
# Verify stored
|
||||
creds = get_background_sync_credentials(username)
|
||||
assert creds is not None, f"Credentials not stored for {username}"
|
||||
logger.info(f"✓ Credentials provisioned for {username}")
|
||||
logger.info("✓ Credentials provisioned for %s", username)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
@@ -26,7 +26,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
board_title = f"Reorder Test Board {unique_suffix}"
|
||||
board = None
|
||||
|
||||
logger.info(f"Creating board with two stacks: {board_title}")
|
||||
logger.info("Creating board with two stacks: %s", board_title)
|
||||
try:
|
||||
board = await nc_client.deck.create_board(board_title, "0000FF")
|
||||
board_id = board.id
|
||||
@@ -40,7 +40,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
"title": source_stack.title,
|
||||
"order": source_stack.order,
|
||||
}
|
||||
logger.info(f"Created source stack with ID: {source_stack.id}")
|
||||
logger.info("Created source stack with ID: %s", source_stack.id)
|
||||
|
||||
# Create target stack (stack 2)
|
||||
target_stack = await nc_client.deck.create_stack(
|
||||
@@ -51,7 +51,7 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
"title": target_stack.title,
|
||||
"order": target_stack.order,
|
||||
}
|
||||
logger.info(f"Created target stack with ID: {target_stack.id}")
|
||||
logger.info("Created target stack with ID: %s", target_stack.id)
|
||||
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
@@ -63,11 +63,11 @@ async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
|
||||
finally:
|
||||
if board:
|
||||
logger.info(f"Cleaning up board ID: {board.id}")
|
||||
logger.info("Cleaning up board ID: %s", board.id)
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up board: {e}")
|
||||
logger.warning("Error cleaning up board: %s", e)
|
||||
|
||||
|
||||
async def test_reorder_card_move_to_different_stack(
|
||||
@@ -90,7 +90,7 @@ async def test_reorder_card_move_to_different_stack(
|
||||
board_id, source_stack_id, card_title, description="Card to be moved"
|
||||
)
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
|
||||
logger.info("Created card ID: %s in source stack ID: %s", card_id, source_stack_id)
|
||||
|
||||
try:
|
||||
# Verify card is in source stack
|
||||
@@ -99,12 +99,14 @@ async def test_reorder_card_move_to_different_stack(
|
||||
f"Card should start in source stack {source_stack_id}, "
|
||||
f"but is in {card_before.stackId}"
|
||||
)
|
||||
logger.info(f"Verified card is in source stack: {source_stack_id}")
|
||||
logger.info("Verified card is in source stack: %s", source_stack_id)
|
||||
|
||||
# Move card to target stack
|
||||
logger.info(
|
||||
f"Moving card {card_id} from stack {source_stack_id} "
|
||||
f"to stack {target_stack_id}"
|
||||
"Moving card %s from stack %s to stack %s",
|
||||
card_id,
|
||||
source_stack_id,
|
||||
target_stack_id,
|
||||
)
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
@@ -122,7 +124,7 @@ async def test_reorder_card_move_to_different_stack(
|
||||
f"Card should have moved to target stack {target_stack_id}, "
|
||||
f"but is in {card_after.stackId}"
|
||||
)
|
||||
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
|
||||
logger.info("SUCCESS: Card moved to target stack %s", target_stack_id)
|
||||
|
||||
finally:
|
||||
# Clean up - try to delete from target stack first, then source
|
||||
@@ -132,7 +134,7 @@ async def test_reorder_card_move_to_different_stack(
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up card: {e}")
|
||||
logger.warning("Error cleaning up card: %s", e)
|
||||
|
||||
|
||||
async def test_reorder_card_within_same_stack(
|
||||
@@ -151,7 +153,7 @@ async def test_reorder_card_within_same_stack(
|
||||
card2 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
|
||||
)
|
||||
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
|
||||
logger.info("Created cards %s (order 0) and %s (order 1)", card1.id, card2.id)
|
||||
|
||||
try:
|
||||
# Reorder card1 to position after card2
|
||||
@@ -162,7 +164,7 @@ async def test_reorder_card_within_same_stack(
|
||||
order=2, # Move to position 2
|
||||
target_stack_id=source_stack_id, # Same stack
|
||||
)
|
||||
logger.info(f"Reordered card {card1.id} to order 2")
|
||||
logger.info("Reordered card %s to order 2", card1.id)
|
||||
|
||||
# Verify card is still in the same stack
|
||||
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
|
||||
@@ -174,4 +176,4 @@ async def test_reorder_card_within_same_stack(
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up cards: {e}")
|
||||
logger.warning("Error cleaning up cards: %s", e)
|
||||
|
||||
@@ -129,7 +129,7 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
|
||||
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
|
||||
|
||||
logger.info(f"Setting up indexed manual PDF: {manual_path}")
|
||||
logger.info("Setting up indexed manual PDF: %s", manual_path)
|
||||
|
||||
# Get file info to verify file exists and get file ID. After the
|
||||
# round-7 contract widening, get_file_info raises HTTPStatusError on
|
||||
@@ -145,16 +145,16 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
pytest.skip(f"Manual PDF unreadable at '{manual_path}' (malformed PROPFIND)")
|
||||
|
||||
file_id = file_info["id"]
|
||||
logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})")
|
||||
logger.info("Found manual PDF: %s (file_id=%s)", manual_path, file_id)
|
||||
|
||||
# Create or get the vector-index tag
|
||||
tag = await nc_client.webdav.get_or_create_tag("vector-index")
|
||||
tag_id = tag["id"]
|
||||
logger.info(f"Using tag 'vector-index' (tag_id={tag_id})")
|
||||
logger.info("Using tag 'vector-index' (tag_id=%s)", tag_id)
|
||||
|
||||
# Assign tag to file
|
||||
await nc_client.webdav.assign_tag_to_file(file_id, tag_id)
|
||||
logger.info(f"Tagged file {file_id} with vector-index tag")
|
||||
logger.info("Tagged file %s with vector-index tag", file_id)
|
||||
|
||||
# Wait for vector sync to complete indexing
|
||||
max_attempts = 60
|
||||
@@ -176,23 +176,26 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
pending = content.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Attempt {attempt}/{max_attempts}: "
|
||||
f"indexed={indexed}, pending={pending}"
|
||||
"Attempt %s/%s: indexed=%s, pending=%s",
|
||||
attempt,
|
||||
max_attempts,
|
||||
indexed,
|
||||
pending,
|
||||
)
|
||||
|
||||
if indexed > 0 and pending == 0:
|
||||
logger.info(
|
||||
f"Vector indexing complete: {indexed} documents indexed"
|
||||
"Vector indexing complete: %s documents indexed", indexed
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Attempt {attempt}: Error checking status: {e}")
|
||||
logger.warning("Attempt %s: Error checking status: %s", attempt, e)
|
||||
|
||||
if attempt < max_attempts:
|
||||
await anyio.sleep(poll_interval)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Vector indexing may not be complete after {max_attempts} attempts"
|
||||
"Vector indexing may not be complete after %s attempts", max_attempts
|
||||
)
|
||||
|
||||
yield {
|
||||
|
||||
@@ -57,7 +57,7 @@ async def test_unstructured_api_enabled_parsing(
|
||||
await nc_client.webdav.write_file(
|
||||
test_file, pdf_content, content_type="application/pdf"
|
||||
)
|
||||
logger.info(f"Uploaded PDF file: {test_file}")
|
||||
logger.info("Uploaded PDF file: %s", test_file)
|
||||
|
||||
# Read the PDF using MCP tool (should parse via Unstructured API)
|
||||
mcp_result = await nc_mcp_client.call_tool(
|
||||
@@ -123,7 +123,7 @@ async def test_unstructured_api_with_docx(
|
||||
docx_content,
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
logger.info(f"Uploaded DOCX file: {test_file}")
|
||||
logger.info("Uploaded DOCX file: %s", test_file)
|
||||
|
||||
# Read the file using MCP tool
|
||||
mcp_result = await nc_mcp_client.call_tool(
|
||||
|
||||
@@ -215,7 +215,7 @@ class BenchmarkMetrics:
|
||||
@asynccontextmanager
|
||||
async def create_mcp_session(url: str):
|
||||
"""Create an MCP client session with proper cleanup."""
|
||||
logger.info(f"Creating MCP client session for {url}")
|
||||
logger.info("Creating MCP client session for %s", url)
|
||||
streamable_context = streamablehttp_client(url)
|
||||
session_context = None
|
||||
|
||||
@@ -231,17 +231,17 @@ async def create_mcp_session(url: str):
|
||||
try:
|
||||
await session_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing session: {e}")
|
||||
logger.debug("Error closing session: %s", e)
|
||||
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing streamable context: {e}")
|
||||
logger.debug("Error closing streamable context: %s", e)
|
||||
|
||||
|
||||
async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool:
|
||||
"""Wait for MCP server to be ready."""
|
||||
logger.info(f"Waiting for MCP server at {url}...")
|
||||
logger.info("Waiting for MCP server at %s...", url)
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
@@ -252,10 +252,10 @@ async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool:
|
||||
return True
|
||||
except Exception as e:
|
||||
if attempt < max_attempts:
|
||||
logger.debug(f"Attempt {attempt}/{max_attempts}: {e}")
|
||||
logger.debug("Attempt %s/%s: %s", attempt, max_attempts, e)
|
||||
await anyio.sleep(2)
|
||||
else:
|
||||
logger.error(f"MCP server not ready after {max_attempts} attempts")
|
||||
logger.error("MCP server not ready after %s attempts", max_attempts)
|
||||
return False
|
||||
|
||||
return False
|
||||
@@ -269,7 +269,7 @@ async def benchmark_worker(
|
||||
stop_event: anyio.Event,
|
||||
):
|
||||
"""Single worker that runs operations for the specified duration."""
|
||||
logger.info(f"Worker {worker_id} starting...")
|
||||
logger.info("Worker %s starting...", worker_id)
|
||||
|
||||
try:
|
||||
async with create_mcp_session(url) as session:
|
||||
@@ -297,10 +297,10 @@ async def benchmark_worker(
|
||||
# Cleanup
|
||||
await ops.cleanup()
|
||||
|
||||
logger.info(f"Worker {worker_id} completed {operation_count} operations")
|
||||
logger.info("Worker %s completed %s operations", worker_id, operation_count)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Worker {worker_id} error: {e}", exc_info=True)
|
||||
logger.error("Worker %s error: %s", worker_id, e, exc_info=True)
|
||||
|
||||
|
||||
async def run_benchmark(
|
||||
|
||||
@@ -74,7 +74,7 @@ class OAuthCallbackServer:
|
||||
|
||||
if code and state:
|
||||
self.auth_states[state] = code
|
||||
logger.info(f"Captured auth code for state {state[:16]}...")
|
||||
logger.info("Captured auth code for state %s...", state[:16])
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
@@ -94,7 +94,9 @@ class OAuthCallbackServer:
|
||||
self.server = HTTPServer((self.host, self.port), CallbackHandler)
|
||||
|
||||
def run():
|
||||
logger.info(f"OAuth callback server listening on {self.host}:{self.port}")
|
||||
logger.info(
|
||||
"OAuth callback server listening on %s:%s", self.host, self.port
|
||||
)
|
||||
self.server.serve_forever()
|
||||
|
||||
self.thread = threading.Thread(target=run, daemon=True)
|
||||
@@ -135,7 +137,7 @@ async def discover_oidc_endpoints(nextcloud_host: str) -> dict[str, str]:
|
||||
"token_endpoint": config["token_endpoint"],
|
||||
"registration_endpoint": config["registration_endpoint"],
|
||||
}
|
||||
logger.info(f"Discovered endpoints: {endpoints}")
|
||||
logger.info("Discovered endpoints: %s", endpoints)
|
||||
return endpoints
|
||||
|
||||
|
||||
@@ -168,7 +170,7 @@ async def setup_oauth_client(
|
||||
redirect_uris=[callback_url],
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client setup complete (client_id: {client_info.client_id})")
|
||||
logger.info("OAuth client setup complete (client_id: %s)", client_info.client_id)
|
||||
return {
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
@@ -197,7 +199,7 @@ async def create_and_authenticate_user(
|
||||
Returns:
|
||||
OAuth access token for the user
|
||||
"""
|
||||
logger.info(f"Creating and authenticating user: {username}")
|
||||
logger.info("Creating and authenticating user: %s", username)
|
||||
|
||||
# Create Nextcloud user
|
||||
await user_pool.create_nextcloud_user(
|
||||
@@ -218,7 +220,7 @@ async def create_and_authenticate_user(
|
||||
auth_states=auth_states,
|
||||
)
|
||||
|
||||
logger.info(f"Successfully authenticated user: {username}")
|
||||
logger.info("Successfully authenticated user: %s", username)
|
||||
return token
|
||||
|
||||
|
||||
@@ -239,7 +241,7 @@ async def oauth_benchmark_worker(
|
||||
metrics: Metrics collector
|
||||
stop_event: Event to signal stop
|
||||
"""
|
||||
logger.info(f"Worker for {user_wrapper.username} starting...")
|
||||
logger.info("Worker for %s starting...", user_wrapper.username)
|
||||
|
||||
start_time = time.time()
|
||||
operation_count = 0
|
||||
@@ -265,18 +267,21 @@ async def oauth_benchmark_worker(
|
||||
await anyio.sleep(0.05)
|
||||
|
||||
logger.info(
|
||||
f"Worker for {user_wrapper.username} completed {operation_count} operations"
|
||||
"Worker for %s completed %s operations",
|
||||
user_wrapper.username,
|
||||
operation_count,
|
||||
)
|
||||
|
||||
except anyio.get_cancelled_exc_class():
|
||||
# Handle task cancellation gracefully (e.g., during benchmark shutdown)
|
||||
logger.info(
|
||||
f"Worker for {user_wrapper.username} was cancelled "
|
||||
f"(completed {operation_count} operations)"
|
||||
"Worker for %s was cancelled (completed %s operations)",
|
||||
user_wrapper.username,
|
||||
operation_count,
|
||||
)
|
||||
raise # Re-raise to allow proper cleanup
|
||||
except Exception as e:
|
||||
logger.error(f"Worker {user_wrapper.username} error: {e}", exc_info=True)
|
||||
logger.error("Worker %s error: %s", user_wrapper.username, e, exc_info=True)
|
||||
|
||||
|
||||
async def show_progress(
|
||||
@@ -432,7 +437,9 @@ async def run_oauth_benchmark(
|
||||
return (username, password, token)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create/authenticate user {username}: {e}")
|
||||
logger.error(
|
||||
"Failed to create/authenticate user %s: %s", username, e
|
||||
)
|
||||
return None
|
||||
|
||||
async with async_playwright() as p:
|
||||
@@ -452,7 +459,7 @@ async def run_oauth_benchmark(
|
||||
)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"User creation task failed: {e}")
|
||||
logger.error("User creation task failed: %s", e)
|
||||
results.append(e)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
@@ -462,7 +469,7 @@ async def run_oauth_benchmark(
|
||||
# Process results
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"User creation task failed: {result}")
|
||||
logger.error("User creation task failed: %s", result)
|
||||
continue
|
||||
if result is None:
|
||||
continue
|
||||
@@ -496,7 +503,7 @@ async def run_oauth_benchmark(
|
||||
print(f" ✓ Session created for '{username}'")
|
||||
return wrapper
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create session for {username}: {e}")
|
||||
logger.error("Failed to create session for %s: %s", username, e)
|
||||
return None
|
||||
|
||||
# Create all sessions concurrently using anyio task groups
|
||||
@@ -508,7 +515,7 @@ async def run_oauth_benchmark(
|
||||
result = await create_session_task(username)
|
||||
session_results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Session creation task failed: {e}")
|
||||
logger.error("Session creation task failed: %s", e)
|
||||
session_results.append(e)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
@@ -518,7 +525,7 @@ async def run_oauth_benchmark(
|
||||
# Process results
|
||||
for result in session_results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Session creation task failed: {result}")
|
||||
logger.error("Session creation task failed: %s", result)
|
||||
continue
|
||||
if result is not None:
|
||||
user_wrappers.append(result)
|
||||
@@ -573,7 +580,7 @@ async def run_oauth_benchmark(
|
||||
print("✓ All sessions closed\n")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Benchmark error: {e}", exc_info=True)
|
||||
logger.error("Benchmark error: %s", e, exc_info=True)
|
||||
# Don't re-raise here - we want cleanup to run
|
||||
|
||||
finally:
|
||||
@@ -583,7 +590,7 @@ async def run_oauth_benchmark(
|
||||
callback_server.stop()
|
||||
logger.info("OAuth callback server stopped")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping callback server: {e}")
|
||||
logger.warning("Error stopping callback server: %s", e)
|
||||
|
||||
# Cleanup test users
|
||||
if cleanup and created_users:
|
||||
@@ -596,10 +603,10 @@ async def run_oauth_benchmark(
|
||||
await cleanup_client.users.delete_user(userid=username)
|
||||
print(f" ✓ Deleted user '{username}'")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete user {username}: {e}")
|
||||
logger.warning("Failed to delete user %s: %s", username, e)
|
||||
print("✓ Cleanup complete\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during user cleanup: {e}")
|
||||
logger.error("Error during user cleanup: %s", e)
|
||||
print(
|
||||
"⚠️ Failed to cleanup users. Please run cleanup script manually.\n"
|
||||
)
|
||||
|
||||
+21
-21
@@ -92,7 +92,7 @@ class OAuthUserPool:
|
||||
Returns:
|
||||
OAuth access token
|
||||
"""
|
||||
logger.info(f"Exchanging auth code for access token (user: {username})...")
|
||||
logger.info("Exchanging auth code for access token (user: %s)...", username)
|
||||
|
||||
if not self._http_client:
|
||||
raise RuntimeError(
|
||||
@@ -118,7 +118,7 @@ class OAuthUserPool:
|
||||
if not access_token:
|
||||
raise ValueError(f"No access token in response for {username}")
|
||||
|
||||
logger.info(f"Successfully acquired OAuth token for {username}")
|
||||
logger.info("Successfully acquired OAuth token for %s", username)
|
||||
return access_token
|
||||
|
||||
async def add_user(self, username: str, password: str, token: str) -> UserProfile:
|
||||
@@ -134,11 +134,11 @@ class OAuthUserPool:
|
||||
UserProfile for the added user
|
||||
"""
|
||||
if username in self.users:
|
||||
logger.warning(f"User {username} already in pool, updating token")
|
||||
logger.warning("User %s already in pool, updating token", username)
|
||||
|
||||
profile = UserProfile(username=username, password=password, token=token)
|
||||
self.users[username] = profile
|
||||
logger.info(f"Added user {username} to pool (total: {len(self.users)})")
|
||||
logger.info("Added user %s to pool (total: %s)", username, len(self.users))
|
||||
return profile
|
||||
|
||||
async def create_user_session(
|
||||
@@ -177,7 +177,7 @@ class OAuthUserPool:
|
||||
# Store both session and context for proper cleanup
|
||||
profile.session = session
|
||||
profile.streamable_context = streamable_context
|
||||
logger.info(f"Created MCP session for {username}")
|
||||
logger.info("Created MCP session for %s", username)
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
@@ -185,7 +185,7 @@ class OAuthUserPool:
|
||||
try:
|
||||
await streamable_context.__aexit__(None, None, None)
|
||||
except Exception as cleanup_error:
|
||||
logger.debug(f"Error during cleanup: {cleanup_error}")
|
||||
logger.debug("Error during cleanup: %s", cleanup_error)
|
||||
raise e
|
||||
|
||||
async def close_user_session(self, username: str):
|
||||
@@ -200,7 +200,7 @@ class OAuthUserPool:
|
||||
try:
|
||||
await profile.session.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing session for {username}: {e}")
|
||||
logger.debug("Error closing session for %s: %s", username, e)
|
||||
profile.session = None
|
||||
|
||||
# Close streamable context
|
||||
@@ -208,7 +208,7 @@ class OAuthUserPool:
|
||||
try:
|
||||
await profile.streamable_context.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing streamable context for {username}: {e}")
|
||||
logger.debug("Error closing streamable context for %s: %s", username, e)
|
||||
profile.streamable_context = None
|
||||
|
||||
async def close_all_sessions(self):
|
||||
@@ -270,7 +270,7 @@ class OAuthUserPool:
|
||||
Raises:
|
||||
HTTPStatusError: If user creation fails
|
||||
"""
|
||||
logger.info(f"Creating Nextcloud user: {username}")
|
||||
logger.info("Creating Nextcloud user: %s", username)
|
||||
|
||||
await self.admin_client.users.create_user(
|
||||
userid=username,
|
||||
@@ -279,7 +279,7 @@ class OAuthUserPool:
|
||||
email=email or f"{username}@benchmark.local",
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created Nextcloud user: {username}")
|
||||
logger.info("Successfully created Nextcloud user: %s", username)
|
||||
|
||||
return UserConfig(
|
||||
username=username,
|
||||
@@ -296,13 +296,13 @@ class OAuthUserPool:
|
||||
Args:
|
||||
username: Username to delete
|
||||
"""
|
||||
logger.info(f"Deleting Nextcloud user: {username}")
|
||||
logger.info("Deleting Nextcloud user: %s", username)
|
||||
|
||||
try:
|
||||
await self.admin_client.users.delete_user(userid=username)
|
||||
logger.info(f"Successfully deleted Nextcloud user: {username}")
|
||||
logger.info("Successfully deleted Nextcloud user: %s", username)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete user {username}: {e}")
|
||||
logger.warning("Failed to delete user %s: %s", username, e)
|
||||
|
||||
async def acquire_token_playwright(
|
||||
self,
|
||||
@@ -338,8 +338,8 @@ class OAuthUserPool:
|
||||
ValueError: If token exchange fails
|
||||
"""
|
||||
|
||||
logger.info(f"Starting Playwright OAuth flow for {username}...")
|
||||
logger.debug(f"Using state: {state[:16]}...")
|
||||
logger.info("Starting Playwright OAuth flow for %s...", username)
|
||||
logger.debug("Using state: %s...", state[:16])
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
@@ -363,7 +363,7 @@ class OAuthUserPool:
|
||||
|
||||
# Login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info(f"Logging in as {username}...")
|
||||
logger.info("Logging in as %s...", username)
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
@@ -382,7 +382,7 @@ class OAuthUserPool:
|
||||
await authorize_button.click()
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
except Exception as e:
|
||||
logger.debug(f"No authorization needed: {e}")
|
||||
logger.debug("No authorization needed: %s", e)
|
||||
|
||||
# Wait for callback server to receive auth code
|
||||
logger.info("Waiting for OAuth callback...")
|
||||
@@ -392,20 +392,20 @@ class OAuthUserPool:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
screenshot_path = f"/tmp/oauth_timeout_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Screenshot saved to {screenshot_path}")
|
||||
logger.error("Screenshot saved to %s", screenshot_path)
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback for {username}"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Received auth code for {username}")
|
||||
logger.info("Received auth code for %s", username)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.info(f"Exchanging auth code for access token ({username})...")
|
||||
logger.info("Exchanging auth code for access token (%s)...", username)
|
||||
token_response = await self._http_client.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
@@ -424,7 +424,7 @@ class OAuthUserPool:
|
||||
if not access_token:
|
||||
raise ValueError(f"No access token for {username}: {token_data}")
|
||||
|
||||
logger.info(f"Successfully acquired OAuth token for {username}")
|
||||
logger.info("Successfully acquired OAuth token for %s", username)
|
||||
return access_token
|
||||
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ class Workflow(ABC):
|
||||
return step_result
|
||||
except Exception as e:
|
||||
duration = time.time() - start
|
||||
logger.error(f"Step {step_name} failed for user {user.username}: {e}")
|
||||
logger.error("Step %s failed for user %s: %s", step_name, user.username, e)
|
||||
step_result = WorkflowStepResult(
|
||||
step_name=step_name,
|
||||
user=user.username,
|
||||
@@ -236,7 +236,7 @@ class NoteShareWorkflow(Workflow):
|
||||
return self._finish(success=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Note share workflow failed: {e}")
|
||||
logger.error("Note share workflow failed: %s", e)
|
||||
return self._finish(False, error=str(e))
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ class CollaborativeEditWorkflow(Workflow):
|
||||
return self._finish(success=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Collaborative edit workflow failed: {e}")
|
||||
logger.error("Collaborative edit workflow failed: %s", e)
|
||||
return self._finish(False, error=str(e))
|
||||
|
||||
|
||||
@@ -424,7 +424,7 @@ class FileShareAndDownloadWorkflow(Workflow):
|
||||
return self._finish(success=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"File share workflow failed: {e}")
|
||||
logger.error("File share workflow failed: %s", e)
|
||||
return self._finish(False, error=str(e))
|
||||
|
||||
|
||||
|
||||
@@ -182,12 +182,12 @@ class WorkloadOperations:
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up any resources created during testing."""
|
||||
logger.info(f"Cleaning up {len(self._created_notes)} test notes...")
|
||||
logger.info("Cleaning up %s test notes...", len(self._created_notes))
|
||||
for note_id in self._created_notes[:]:
|
||||
try:
|
||||
await self.delete_note(note_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete note {note_id}: {e}")
|
||||
logger.warning("Failed to delete note %s: %s", note_id, e)
|
||||
|
||||
|
||||
class MixedWorkload:
|
||||
@@ -210,7 +210,7 @@ class MixedWorkload:
|
||||
|
||||
async def warmup(self, count: int = 10):
|
||||
"""Create initial notes for read/update operations."""
|
||||
logger.info(f"Warming up with {count} test notes...")
|
||||
logger.info("Warming up with %s test notes...", count)
|
||||
for _ in range(count):
|
||||
result = await self.ops.create_note()
|
||||
if result.success and self.ops._created_notes:
|
||||
@@ -225,7 +225,7 @@ class MixedWorkload:
|
||||
etag = note_data.get("etag", "")
|
||||
self._warmup_note_ids.append((note_id, etag))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get etag for note {note_id}: {e}")
|
||||
logger.warning("Failed to get etag for note %s: %s", note_id, e)
|
||||
|
||||
async def run_operation(self) -> OperationResult:
|
||||
"""Execute one random operation based on the workload distribution."""
|
||||
|
||||
@@ -81,7 +81,7 @@ async def login_flow_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Login Flow OAuth client ready: {client_info.client_id[:16]}...")
|
||||
logger.info("Login Flow OAuth client ready: %s...", client_info.client_id[:16])
|
||||
|
||||
yield (
|
||||
client_info.client_id,
|
||||
@@ -101,10 +101,10 @@ async def login_flow_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
logger.info(
|
||||
f"Cleaned up Login Flow OAuth client: {client_info.client_id[:16]}..."
|
||||
"Cleaned up Login Flow OAuth client: %s...", client_info.client_id[:16]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up Login Flow OAuth client: {e}")
|
||||
logger.warning("Failed to clean up Login Flow OAuth client: %s", e)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -137,7 +137,7 @@ async def login_flow_oauth_token(
|
||||
)
|
||||
resource_id = resource_metadata.get("resource")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch resource metadata from port 8004: {e}")
|
||||
logger.warning("Failed to fetch resource metadata from port 8004: %s", e)
|
||||
resource_id = None
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
@@ -242,9 +242,9 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None:
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
logger.info(f"Opening Login Flow v2 URL: {login_url[:80]}...")
|
||||
logger.info("Opening Login Flow v2 URL: %s...", login_url[:80])
|
||||
await page.goto(login_url, wait_until="networkidle", timeout=60000)
|
||||
logger.info(f"Step 1 - Current URL: {page.url}")
|
||||
logger.info("Step 1 - Current URL: %s", page.url)
|
||||
|
||||
# Step 1: "Connect to your account" page - click "Log in"
|
||||
login_btn = page.get_by_role("button", name="Log in")
|
||||
@@ -256,7 +256,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None:
|
||||
except Exception:
|
||||
logger.info("No 'Log in' button - may already be on login/grant page")
|
||||
|
||||
logger.info(f"Step 2 - Current URL: {page.url}")
|
||||
logger.info("Step 2 - Current URL: %s", page.url)
|
||||
|
||||
# Step 2: Login form (only if not already logged in)
|
||||
# If the user has an active session, they skip straight to the grant page.
|
||||
@@ -267,7 +267,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None:
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
await page.get_by_role("button", name="Log in", exact=True).click()
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
logger.info(f"After login: {page.url}")
|
||||
logger.info("After login: %s", page.url)
|
||||
else:
|
||||
logger.info("No login form - already logged in via session")
|
||||
|
||||
@@ -278,7 +278,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None:
|
||||
await grant_btn.click()
|
||||
logger.info("Clicked 'Grant access'")
|
||||
except Exception as e:
|
||||
logger.warning(f"No Grant access button: {e}")
|
||||
logger.warning("No Grant access button: %s", e)
|
||||
await page.screenshot(path="/tmp/login_flow_no_grant.png")
|
||||
|
||||
# Step 4: Password confirmation dialog
|
||||
@@ -310,7 +310,7 @@ async def _complete_login_flow_v2(browser, login_url: str) -> None:
|
||||
except Exception:
|
||||
# The grant may have completed without the success page being visible
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
logger.info(f"Login Flow v2 done. Final URL: {page.url}")
|
||||
logger.info("Login Flow v2 done. Final URL: %s", page.url)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
@@ -347,14 +347,14 @@ async def nc_mcp_login_flow_client(
|
||||
completes the Login Flow v2 browser login.
|
||||
"""
|
||||
message = params.message
|
||||
logger.info(f"Elicitation received: {message[:100]}...")
|
||||
logger.info("Elicitation received: %s...", message[:100])
|
||||
|
||||
# Extract login URL from elicitation message
|
||||
for line in message.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("http") and "/login/v2/" in stripped:
|
||||
login_url_holder["url"] = stripped
|
||||
logger.info(f"Extracted login URL: {stripped[:80]}...")
|
||||
logger.info("Extracted login URL: %s...", stripped[:80])
|
||||
break
|
||||
|
||||
if "url" in login_url_holder:
|
||||
@@ -381,7 +381,7 @@ async def nc_mcp_login_flow_client(
|
||||
)
|
||||
|
||||
provision_data = json.loads(provision_result.content[0].text)
|
||||
logger.info(f"Provision result: {provision_data.get('status')}")
|
||||
logger.info("Provision result: %s", provision_data.get("status"))
|
||||
|
||||
# If elicitation didn't fire (client doesn't support it),
|
||||
# extract URL from the response and complete flow manually
|
||||
@@ -398,11 +398,12 @@ async def nc_mcp_login_flow_client(
|
||||
status_result = await session.call_tool("nc_auth_check_status", {})
|
||||
status_data = json.loads(status_result.content[0].text)
|
||||
status = status_data.get("status")
|
||||
logger.info(f"Status check {attempt + 1}/{max_attempts}: {status}")
|
||||
logger.info("Status check %s/%s: %s", attempt + 1, max_attempts, status)
|
||||
|
||||
if status == "provisioned":
|
||||
logger.info(
|
||||
f"Login Flow v2 provisioned! Username: {status_data.get('username')}"
|
||||
"Login Flow v2 provisioned! Username: %s",
|
||||
status_data.get("username"),
|
||||
)
|
||||
break
|
||||
|
||||
@@ -698,8 +699,10 @@ async def all_login_flow_user_tokens(
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
f"Fetched {len(results)} login-flow tokens in {elapsed:.1f}s "
|
||||
f"(~{elapsed / len(results):.1f}s per user)"
|
||||
"Fetched %s login-flow tokens in %ss (~%ss per user)",
|
||||
len(results),
|
||||
format(elapsed, ".1f"),
|
||||
format(elapsed / len(results), ".1f"),
|
||||
)
|
||||
return results # type: ignore[return-value]
|
||||
|
||||
@@ -756,8 +759,9 @@ async def _provision_login_flow_mcp_client(
|
||||
status_data = json.loads(status_result.content[0].text)
|
||||
if status_data.get("status") == "provisioned":
|
||||
logger.info(
|
||||
f"Login Flow v2 provisioned for {username}: "
|
||||
f"{status_data.get('username')}"
|
||||
"Login Flow v2 provisioned for %s: %s",
|
||||
username,
|
||||
status_data.get("username"),
|
||||
)
|
||||
break
|
||||
if status_data.get("status") in ("not_initiated", "error"):
|
||||
@@ -795,44 +799,44 @@ async def _complete_login_flow_v2_as_user(
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
logger.info(f"[{username}] Opening Login Flow v2 URL: {login_url[:80]}...")
|
||||
logger.info("[%s] Opening Login Flow v2 URL: %s...", username, login_url[:80])
|
||||
await page.goto(login_url, wait_until="networkidle", timeout=60000)
|
||||
logger.info(f"[{username}] Step 1 - Current URL: {page.url}")
|
||||
logger.info("[%s] Step 1 - Current URL: %s", username, page.url)
|
||||
|
||||
# Step 1: "Connect to your account" page - click "Log in"
|
||||
login_btn = page.get_by_role("button", name="Log in")
|
||||
try:
|
||||
await login_btn.wait_for(timeout=10000)
|
||||
await login_btn.click()
|
||||
logger.info(f"[{username}] Clicked 'Log in' on Connect page")
|
||||
logger.info("[%s] Clicked 'Log in' on Connect page", username)
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
except Exception:
|
||||
logger.info(
|
||||
f"[{username}] No 'Log in' button - may already be on login/grant page"
|
||||
"[%s] No 'Log in' button - may already be on login/grant page", username
|
||||
)
|
||||
|
||||
logger.info(f"[{username}] Step 2 - Current URL: {page.url}")
|
||||
logger.info("[%s] Step 2 - Current URL: %s", username, page.url)
|
||||
|
||||
# Step 2: Login form (only if not already logged in)
|
||||
user_field = page.locator('input[name="user"]')
|
||||
if await user_field.count() > 0:
|
||||
logger.info(f"[{username}] Login form detected, filling credentials...")
|
||||
logger.info("[%s] Login form detected, filling credentials...", username)
|
||||
await user_field.fill(username)
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
await page.get_by_role("button", name="Log in", exact=True).click()
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
logger.info(f"[{username}] After login: {page.url}")
|
||||
logger.info("[%s] After login: %s", username, page.url)
|
||||
else:
|
||||
logger.info(f"[{username}] No login form - already logged in via session")
|
||||
logger.info("[%s] No login form - already logged in via session", username)
|
||||
|
||||
# Step 3: "Account access" grant page - click "Grant access"
|
||||
grant_btn = page.get_by_role("button", name="Grant access")
|
||||
try:
|
||||
await grant_btn.wait_for(timeout=15000)
|
||||
await grant_btn.click()
|
||||
logger.info(f"[{username}] Clicked 'Grant access'")
|
||||
logger.info("[%s] Clicked 'Grant access'", username)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{username}] No Grant access button: {e}")
|
||||
logger.warning("[%s] No Grant access button: %s", username, e)
|
||||
await page.screenshot(path=f"/tmp/login_flow_no_grant_{username}.png")
|
||||
|
||||
# Step 4: Password confirmation dialog
|
||||
@@ -841,27 +845,27 @@ async def _complete_login_flow_v2_as_user(
|
||||
)
|
||||
try:
|
||||
await confirm_password.wait_for(timeout=10000)
|
||||
logger.info(f"[{username}] Password confirmation dialog detected")
|
||||
logger.info("[%s] Password confirmation dialog detected", username)
|
||||
await confirm_password.fill(password)
|
||||
confirm_btn = page.get_by_role("dialog").get_by_role(
|
||||
"button", name="Confirm"
|
||||
)
|
||||
await confirm_btn.wait_for(timeout=5000)
|
||||
await confirm_btn.click()
|
||||
logger.info(f"[{username}] Clicked 'Confirm' in password dialog")
|
||||
logger.info("[%s] Clicked 'Confirm' in password dialog", username)
|
||||
except Exception:
|
||||
logger.info(
|
||||
f"[{username}] No password confirmation dialog "
|
||||
"(may have been auto-confirmed)"
|
||||
"[%s] No password confirmation dialog (may have been auto-confirmed)",
|
||||
username,
|
||||
)
|
||||
|
||||
# Step 5: Wait for "Account connected" success page
|
||||
try:
|
||||
await page.get_by_text("Account connected").wait_for(timeout=15000)
|
||||
logger.info(f"[{username}] Login Flow v2 completed: Account connected!")
|
||||
logger.info("[%s] Login Flow v2 completed: Account connected!", username)
|
||||
except Exception:
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
logger.info(f"[{username}] Login Flow v2 done. Final URL: {page.url}")
|
||||
logger.info("[%s] Login Flow v2 done. Final URL: %s", username, page.url)
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
@@ -986,7 +990,9 @@ async def login_flow_static_client_credentials(anyio_backend, oauth_callback_ser
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
logger.info(f"Creating static OIDC client {client_id} with callback {callback_url}")
|
||||
logger.info(
|
||||
"Creating static OIDC client %s with callback %s", client_id, callback_url
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
|
||||
@@ -61,9 +61,9 @@ async def test_dcr_deletion_authentication_methods(
|
||||
)
|
||||
|
||||
deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_info.client_id}"
|
||||
logger.info(f"\nTesting deletion endpoint: {deletion_endpoint}")
|
||||
logger.info(f"Client ID: {client_info.client_id}")
|
||||
logger.info(f"Client Secret (first 16 chars): {client_info.client_secret[:16]}...")
|
||||
logger.info("\\nTesting deletion endpoint: %s", deletion_endpoint)
|
||||
logger.info("Client ID: %s", client_info.client_id)
|
||||
logger.info("Client Secret (first 16 chars): %s...", client_info.client_secret[:16])
|
||||
|
||||
results = {}
|
||||
|
||||
@@ -79,11 +79,11 @@ async def test_dcr_deletion_authentication_methods(
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
logger.info("Status: %s", response.status_code)
|
||||
logger.info("Body: %s", response.text[:200])
|
||||
except Exception as e:
|
||||
results["basic_auth"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
logger.error("Error: %s", e)
|
||||
|
||||
# Method 2: Credentials in JSON body
|
||||
logger.info("\n=== Method 2: Credentials in JSON Body ===")
|
||||
@@ -99,11 +99,11 @@ async def test_dcr_deletion_authentication_methods(
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
logger.info("Status: %s", response.status_code)
|
||||
logger.info("Body: %s", response.text[:200])
|
||||
except Exception as e:
|
||||
results["json_body"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
logger.error("Error: %s", e)
|
||||
|
||||
# Method 3: Credentials in query parameters
|
||||
logger.info("\n=== Method 3: Credentials in Query Parameters ===")
|
||||
@@ -119,11 +119,11 @@ async def test_dcr_deletion_authentication_methods(
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
logger.info("Status: %s", response.status_code)
|
||||
logger.info("Body: %s", response.text[:200])
|
||||
except Exception as e:
|
||||
results["query_params"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
logger.error("Error: %s", e)
|
||||
|
||||
# Method 4: No authentication (baseline)
|
||||
logger.info("\n=== Method 4: No Authentication (Baseline) ===")
|
||||
@@ -133,11 +133,11 @@ async def test_dcr_deletion_authentication_methods(
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
logger.info("Status: %s", response.status_code)
|
||||
logger.info("Body: %s", response.text[:200])
|
||||
except Exception as e:
|
||||
results["no_auth"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
logger.error("Error: %s", e)
|
||||
|
||||
# Print summary
|
||||
logger.info("\n" + "=" * 70)
|
||||
@@ -146,7 +146,7 @@ async def test_dcr_deletion_authentication_methods(
|
||||
|
||||
for method, result in results.items():
|
||||
status = result.get("status", "unknown")
|
||||
logger.info(f"{method:20s} → Status: {status}")
|
||||
logger.info("%s → Status: %s", format(method, "20s"), status)
|
||||
|
||||
# Analysis
|
||||
logger.info("\n" + "=" * 70)
|
||||
@@ -170,11 +170,11 @@ async def test_dcr_deletion_authentication_methods(
|
||||
logger.info("✓ At least one authentication method succeeded (204 No Content)")
|
||||
for method, result in results.items():
|
||||
if result.get("status") == 204:
|
||||
logger.info(f" Working method: {method}")
|
||||
logger.info(" Working method: %s", method)
|
||||
else:
|
||||
logger.info("? Mixed results - further investigation needed")
|
||||
for method, result in results.items():
|
||||
logger.info(f" {method}: {result.get('status')}")
|
||||
logger.info(" %s: %s", method, result.get("status"))
|
||||
|
||||
# Document the finding
|
||||
assert all_401 or any_204, (
|
||||
|
||||
@@ -101,7 +101,7 @@ async def get_oauth_token_with_client(
|
||||
try:
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
logger.debug("No consent screen or already authorized: %s", e)
|
||||
|
||||
# Wait for callback
|
||||
logger.info("Waiting for OAuth callback...")
|
||||
@@ -115,7 +115,7 @@ async def get_oauth_token_with_client(
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||
logger.info("Got auth code: %s...", auth_code[:20])
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
@@ -200,8 +200,8 @@ async def test_dcr_register_and_delete_lifecycle(
|
||||
reg_response.raise_for_status()
|
||||
full_client_info = reg_response.json()
|
||||
|
||||
logger.info(f"Full registration response keys: {list(full_client_info.keys())}")
|
||||
logger.info(f"Registration response: {full_client_info}")
|
||||
logger.info("Full registration response keys: %s", list(full_client_info.keys()))
|
||||
logger.info("Registration response: %s", full_client_info)
|
||||
|
||||
# Use the register_client function for the ClientInfo object
|
||||
client_info = await register_client(
|
||||
@@ -217,13 +217,13 @@ async def test_dcr_register_and_delete_lifecycle(
|
||||
registration_access_token = full_client_info.get("registration_access_token")
|
||||
registration_client_uri = full_client_info.get("registration_client_uri")
|
||||
logger.info(
|
||||
f"Registration access token present: {registration_access_token is not None}"
|
||||
"Registration access token present: %s", registration_access_token is not None
|
||||
)
|
||||
logger.info(
|
||||
f"Registration client URI present: {registration_client_uri is not None}"
|
||||
"Registration client URI present: %s", registration_client_uri is not None
|
||||
)
|
||||
|
||||
logger.info(f"✅ Client registered: {client_info.client_id[:16]}...")
|
||||
logger.info("✅ Client registered: %s...", client_info.client_id[:16])
|
||||
|
||||
# Step 2: Obtain token and verify client works
|
||||
logger.info("Step 2: Obtaining OAuth token with registered client...")
|
||||
@@ -239,14 +239,15 @@ async def test_dcr_register_and_delete_lifecycle(
|
||||
)
|
||||
|
||||
assert access_token, "Failed to obtain access token"
|
||||
logger.info(f"✅ Access token obtained: {access_token[:30]}...")
|
||||
logger.info("✅ Access token obtained: %s...", access_token[:30])
|
||||
|
||||
# Step 3: Delete the client using RFC 7592
|
||||
logger.info("Step 3: Deleting OAuth client...")
|
||||
logger.info(f"Client ID: {client_info.client_id}")
|
||||
logger.info(f"Client secret (first 16 chars): {client_info.client_secret[:16]}...")
|
||||
logger.info("Client ID: %s", client_info.client_id)
|
||||
logger.info("Client secret (first 16 chars): %s...", client_info.client_secret[:16])
|
||||
logger.info(
|
||||
f"Registration access token: {registration_access_token[:16] if registration_access_token else 'None'}..."
|
||||
"Registration access token: %s...",
|
||||
registration_access_token[:16] if registration_access_token else "None",
|
||||
)
|
||||
|
||||
# Use delete_client() which prefers RFC 7592 Bearer token, falls back to Basic Auth
|
||||
@@ -261,7 +262,7 @@ async def test_dcr_register_and_delete_lifecycle(
|
||||
assert success, (
|
||||
"Client deletion should succeed with RFC 7592 Bearer token or Basic Auth"
|
||||
)
|
||||
logger.info(f"✅ Client deleted successfully: {client_info.client_id[:16]}...")
|
||||
logger.info("✅ Client deleted successfully: %s...", client_info.client_id[:16])
|
||||
|
||||
# Step 4: Verify deleted client cannot obtain new tokens
|
||||
logger.info("Step 4: Verifying deleted client cannot obtain new tokens...")
|
||||
@@ -284,7 +285,8 @@ async def test_dcr_register_and_delete_lifecycle(
|
||||
# Accept either 400 (Bad Request) or 401 (Unauthorized) as valid rejection
|
||||
if token_response.status_code in [400, 401]:
|
||||
logger.info(
|
||||
f"✅ Deleted client correctly rejected ({token_response.status_code})"
|
||||
"✅ Deleted client correctly rejected (%s)",
|
||||
token_response.status_code,
|
||||
)
|
||||
else:
|
||||
# Unexpected success - client should be deleted
|
||||
@@ -343,7 +345,7 @@ async def test_dcr_delete_with_wrong_credentials(
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
||||
logger.info("Client registered: %s...", client_info.client_id[:16])
|
||||
|
||||
# Try to delete with wrong registration_access_token (RFC 7592 Bearer token)
|
||||
logger.info("Attempting deletion with wrong registration_access_token...")
|
||||
@@ -392,7 +394,7 @@ async def test_dcr_delete_nonexistent_client(
|
||||
fake_client_id = "nonexistent_" + secrets.token_urlsafe(16)
|
||||
fake_client_secret = secrets.token_urlsafe(32)
|
||||
|
||||
logger.info(f"Attempting to delete non-existent client: {fake_client_id[:16]}...")
|
||||
logger.info("Attempting to delete non-existent client: %s...", fake_client_id[:16])
|
||||
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
@@ -442,7 +444,7 @@ async def test_dcr_deletion_is_idempotent(
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
||||
logger.info("Client registered: %s...", client_info.client_id[:16])
|
||||
|
||||
# First deletion with RFC 7592 Bearer token
|
||||
logger.info("First deletion attempt...")
|
||||
|
||||
@@ -63,28 +63,28 @@ async def test_new_dcr_registration_includes_access_token(
|
||||
registration_data = response.json()
|
||||
|
||||
# Log the full response
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("\\n%s", "=" * 70)
|
||||
logger.info("REGISTRATION RESPONSE")
|
||||
logger.info(f"{'=' * 70}")
|
||||
logger.info(f"Response keys: {sorted(registration_data.keys())}")
|
||||
logger.info("%s", "=" * 70)
|
||||
logger.info("Response keys: %s", sorted(registration_data.keys()))
|
||||
logger.info("\nFull response:")
|
||||
for key, value in sorted(registration_data.items()):
|
||||
if key in ["client_secret", "registration_access_token"]:
|
||||
# Truncate secrets for security
|
||||
logger.info(f" {key}: {value[:20]}... (truncated)")
|
||||
logger.info(" %s: %s... (truncated)", key, value[:20])
|
||||
else:
|
||||
logger.info(f" {key}: {value}")
|
||||
logger.info(" %s: %s", key, value)
|
||||
|
||||
# Check for RFC 7592 required fields
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("\\n%s", "=" * 70)
|
||||
logger.info("RFC 7592 COMPLIANCE CHECK")
|
||||
logger.info(f"{'=' * 70}")
|
||||
logger.info("%s", "=" * 70)
|
||||
|
||||
has_token = "registration_access_token" in registration_data
|
||||
has_uri = "registration_client_uri" in registration_data
|
||||
|
||||
logger.info(f"registration_access_token present: {has_token}")
|
||||
logger.info(f"registration_client_uri present: {has_uri}")
|
||||
logger.info("registration_access_token present: %s", has_token)
|
||||
logger.info("registration_client_uri present: %s", has_uri)
|
||||
|
||||
if has_token and has_uri:
|
||||
logger.info(
|
||||
@@ -100,15 +100,15 @@ async def test_new_dcr_registration_includes_access_token(
|
||||
registration_client_uri = registration_data.get("registration_client_uri")
|
||||
|
||||
# Now test deletion with the registration_access_token
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("\\n%s", "=" * 70)
|
||||
logger.info("TESTING DCR DELETION WITH REGISTRATION_ACCESS_TOKEN")
|
||||
logger.info(f"{'=' * 70}")
|
||||
logger.info("%s", "=" * 70)
|
||||
|
||||
deletion_endpoint = (
|
||||
registration_client_uri
|
||||
or f"{nextcloud_host}/apps/oidc/register/{client_id}"
|
||||
)
|
||||
logger.info(f"Deletion endpoint: {deletion_endpoint}")
|
||||
logger.info("Deletion endpoint: %s", deletion_endpoint)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Try deletion with Bearer token (RFC 7592 standard)
|
||||
@@ -118,8 +118,8 @@ async def test_new_dcr_registration_includes_access_token(
|
||||
headers={"Authorization": f"Bearer {registration_access_token}"},
|
||||
)
|
||||
|
||||
logger.info(f"Response status: {delete_response.status_code}")
|
||||
logger.info(f"Response body: {delete_response.text[:200]}")
|
||||
logger.info("Response status: %s", delete_response.status_code)
|
||||
logger.info("Response body: %s", delete_response.text[:200])
|
||||
|
||||
if delete_response.status_code == 204:
|
||||
logger.info(
|
||||
@@ -139,7 +139,7 @@ async def test_new_dcr_registration_includes_access_token(
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"\n? UNEXPECTED: Got status {delete_response.status_code}"
|
||||
"\\n? UNEXPECTED: Got status %s", delete_response.status_code
|
||||
)
|
||||
pytest.fail(
|
||||
f"Unexpected status code: {delete_response.status_code}, body: {delete_response.text[:500]}"
|
||||
@@ -204,10 +204,10 @@ async def test_dcr_deletion_with_basic_auth_new_impl(
|
||||
client_secret = reg_data["client_secret"]
|
||||
deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_id}"
|
||||
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("\\n%s", "=" * 70)
|
||||
logger.info("TESTING DCR DELETION WITH HTTP BASIC AUTH")
|
||||
logger.info(f"{'=' * 70}")
|
||||
logger.info(f"Endpoint: {deletion_endpoint}")
|
||||
logger.info("%s", "=" * 70)
|
||||
logger.info("Endpoint: %s", deletion_endpoint)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
@@ -215,8 +215,8 @@ async def test_dcr_deletion_with_basic_auth_new_impl(
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
logger.info("Status: %s", response.status_code)
|
||||
logger.info("Body: %s", response.text[:200])
|
||||
|
||||
if response.status_code == 204:
|
||||
logger.info("\n✓ SUCCESS: HTTP Basic Auth works for deletion!")
|
||||
@@ -225,7 +225,7 @@ async def test_dcr_deletion_with_basic_auth_new_impl(
|
||||
"\n✗ HTTP Basic Auth not supported - use registration_access_token instead"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"\n? Unexpected status: {response.status_code}")
|
||||
logger.warning("\\n? Unexpected status: %s", response.status_code)
|
||||
|
||||
# This test is informational - we don't fail if Basic Auth doesn't work
|
||||
# as long as Bearer token works
|
||||
|
||||
@@ -157,7 +157,7 @@ async def get_oauth_token_with_client(
|
||||
try:
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
logger.debug("No consent screen or already authorized: %s", e)
|
||||
|
||||
# Wait for callback
|
||||
logger.info("Waiting for OAuth callback...")
|
||||
@@ -171,7 +171,7 @@ async def get_oauth_token_with_client(
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||
logger.info("Got auth code: %s...", auth_code[:20])
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
@@ -245,7 +245,7 @@ async def test_dcr_respects_jwt_token_type(
|
||||
token_type="jwt",
|
||||
)
|
||||
|
||||
logger.info(f"Registered JWT client: {client_info.client_id[:16]}...")
|
||||
logger.info("Registered JWT client: %s...", client_info.client_id[:16])
|
||||
|
||||
# Obtain token via OAuth flow
|
||||
access_token = await get_oauth_token_with_client(
|
||||
@@ -280,8 +280,8 @@ async def test_dcr_respects_jwt_token_type(
|
||||
assert "notes.write" in scopes, "JWT scope claim missing notes.write"
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=jwt works correctly! "
|
||||
f"Token is JWT format with scope claim: {payload['scope']}"
|
||||
"✅ DCR with token_type=jwt works correctly! Token is JWT format with scope claim: %s",
|
||||
payload["scope"],
|
||||
)
|
||||
|
||||
|
||||
@@ -329,7 +329,7 @@ async def test_dcr_respects_bearer_token_type(
|
||||
token_type="opaque",
|
||||
)
|
||||
|
||||
logger.info(f"Registered Opaque token client: {client_info.client_id[:16]}...")
|
||||
logger.info("Registered Opaque token client: %s...", client_info.client_id[:16])
|
||||
|
||||
# Obtain token via OAuth flow
|
||||
access_token = await get_oauth_token_with_client(
|
||||
@@ -357,8 +357,8 @@ async def test_dcr_respects_bearer_token_type(
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=opaque works correctly! "
|
||||
f"Token is opaque (not JWT format): {access_token[:30]}..."
|
||||
"✅ DCR with token_type=opaque works correctly! Token is opaque (not JWT format): %s...",
|
||||
access_token[:30],
|
||||
)
|
||||
|
||||
|
||||
@@ -390,8 +390,8 @@ async def test_jwt_tokens_embed_scopes_in_payload():
|
||||
# but we document the behavior explicitly here for reference
|
||||
|
||||
logger.info(
|
||||
"✅ JWT token scope embedding verified. "
|
||||
f"Expected scopes in JWT payload: {DEFAULT_FULL_SCOPES}"
|
||||
"✅ JWT token scope embedding verified. Expected scopes in JWT payload: %s",
|
||||
DEFAULT_FULL_SCOPES,
|
||||
)
|
||||
|
||||
# This test primarily serves as documentation
|
||||
|
||||
@@ -82,7 +82,7 @@ async def test_oauth_clients(
|
||||
token_type="Bearer", # Use opaque tokens for this test
|
||||
)
|
||||
clients["clientA"] = (client_a.client_id, client_a.client_secret)
|
||||
logger.info(f"Created client A: {client_a.client_id[:16]}...")
|
||||
logger.info("Created client A: %s...", client_a.client_id[:16])
|
||||
|
||||
# Create client B (will attempt to introspect client A's tokens)
|
||||
logger.info("Creating OAuth client B for introspection testing")
|
||||
@@ -95,7 +95,7 @@ async def test_oauth_clients(
|
||||
token_type="Bearer",
|
||||
)
|
||||
clients["clientB"] = (client_b.client_id, client_b.client_secret)
|
||||
logger.info(f"Created client B: {client_b.client_id[:16]}...")
|
||||
logger.info("Created client B: %s...", client_b.client_id[:16])
|
||||
|
||||
# Create client C (third party, should not be able to introspect)
|
||||
logger.info("Creating OAuth client C for introspection testing")
|
||||
@@ -108,7 +108,7 @@ async def test_oauth_clients(
|
||||
token_type="Bearer",
|
||||
)
|
||||
clients["clientC"] = (client_c.client_id, client_c.client_secret)
|
||||
logger.info(f"Created client C: {client_c.client_id[:16]}...")
|
||||
logger.info("Created client C: %s...", client_c.client_id[:16])
|
||||
|
||||
yield clients
|
||||
|
||||
@@ -146,7 +146,7 @@ async def test_introspection_requires_client_authentication(
|
||||
)
|
||||
assert response.status_code == 401, "Should return 401 with invalid credentials"
|
||||
data = response.json()
|
||||
logger.info(f"Invalid client response: {data}")
|
||||
logger.info("Invalid client response: %s", data)
|
||||
# Response may be either {"error": "invalid_client"} or {"message": "..."}
|
||||
# Both are acceptable as long as we get 401
|
||||
assert "error" in data or "message" in data, "Should return error information"
|
||||
@@ -191,19 +191,21 @@ async def _obtain_token_for_client(
|
||||
|
||||
auth_url = "".join(auth_url_parts)
|
||||
|
||||
logger.info(f"Obtaining token for client {client_id[:16]}... with scopes={scope}")
|
||||
logger.info(
|
||||
"Obtaining token for client %s... with scopes=%s", client_id[:16], scope
|
||||
)
|
||||
if resource:
|
||||
logger.info(f" Resource parameter: {resource[:16]}...")
|
||||
logger.info(" Resource parameter: %s...", resource[:16])
|
||||
|
||||
# Browser automation (same pattern as conftest.py)
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
logger.debug(f"Navigating to: {auth_url[:100]}...")
|
||||
logger.debug("Navigating to: %s...", auth_url[:100])
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
logger.debug(f"Current URL after navigation: {current_url}")
|
||||
logger.debug("Current URL after navigation: %s", current_url)
|
||||
|
||||
# Handle login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
@@ -214,24 +216,24 @@ async def _obtain_token_for_client(
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
logger.info(f"After login: {current_url}")
|
||||
logger.info("After login: %s", current_url)
|
||||
|
||||
# Wait a bit for page to fully render after login
|
||||
await anyio.sleep(2)
|
||||
current_url = page.url
|
||||
logger.info(f"After waiting, current URL: {current_url}")
|
||||
logger.info("After waiting, current URL: %s", current_url)
|
||||
|
||||
# Check page content for debugging
|
||||
page_content = await page.content()
|
||||
has_consent_div = "#oidc-consent" in page_content
|
||||
logger.info(f"Page has #oidc-consent div: {has_consent_div}")
|
||||
logger.info("Page has #oidc-consent div: %s", has_consent_div)
|
||||
|
||||
# Handle consent screen using the helper from conftest
|
||||
try:
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
logger.info(f"Consent screen handled: {consent_handled}")
|
||||
logger.info("Consent screen handled: %s", consent_handled)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error handling consent screen: {e}")
|
||||
logger.warning("Error handling consent screen: %s", e)
|
||||
# Take screenshot for debugging
|
||||
await page.screenshot(path=f"/tmp/consent_error_{state[:8]}.png")
|
||||
logger.error("Consent error screenshot saved")
|
||||
@@ -247,15 +249,15 @@ async def _obtain_token_for_client(
|
||||
f"/tmp/oauth_introspection_test_timeout_{state[:8]}.png"
|
||||
)
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Timeout! Screenshot saved to {screenshot_path}")
|
||||
logger.error(f"Current URL: {page.url}")
|
||||
logger.error("Timeout! Screenshot saved to %s", screenshot_path)
|
||||
logger.error("Current URL: %s", page.url)
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Successfully received auth code: {auth_code[:20]}...")
|
||||
logger.info("Successfully received auth code: %s...", auth_code[:20])
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
@@ -311,10 +313,10 @@ async def test_client_cannot_introspect_other_clients_tokens(
|
||||
different_client_id, different_client_secret = test_oauth_clients["clientB"]
|
||||
|
||||
logger.info(
|
||||
f"Testing introspection with shared client token: {access_token[:16]}..."
|
||||
"Testing introspection with shared client token: %s...", access_token[:16]
|
||||
)
|
||||
logger.info(f"Shared client ID: {shared_client_id[:16]}...")
|
||||
logger.info(f"Different client ID: {different_client_id[:16]}...")
|
||||
logger.info("Shared client ID: %s...", shared_client_id[:16])
|
||||
logger.info("Different client ID: %s...", different_client_id[:16])
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test 1: The owning client (shared client) can introspect its own token
|
||||
@@ -325,7 +327,7 @@ async def test_client_cannot_introspect_other_clients_tokens(
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Owner client introspection response: {data}")
|
||||
logger.info("Owner client introspection response: %s", data)
|
||||
assert data.get("active") is True, (
|
||||
"Owner client should be able to introspect its own token"
|
||||
)
|
||||
@@ -338,7 +340,7 @@ async def test_client_cannot_introspect_other_clients_tokens(
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Different client introspection response: {data}")
|
||||
logger.info("Different client introspection response: %s", data)
|
||||
assert data.get("active") is False, (
|
||||
"Different client should NOT be able to introspect another client's token"
|
||||
)
|
||||
@@ -387,11 +389,13 @@ async def test_introspection_with_resource_parameter(
|
||||
resource=client_b_id, # Set client B as the resource server
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to obtain token with resource parameter: {e}")
|
||||
logger.error("Failed to obtain token with resource parameter: %s", e)
|
||||
pytest.skip(f"Cannot obtain test token with resource parameter: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Obtained access token from client A with resource={client_b_id}: {access_token[:16]}..."
|
||||
"Obtained access token from client A with resource=%s: %s...",
|
||||
client_b_id,
|
||||
access_token[:16],
|
||||
)
|
||||
|
||||
# Test introspection
|
||||
@@ -404,7 +408,7 @@ async def test_introspection_with_resource_parameter(
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client A (owner) introspection response: {data}")
|
||||
logger.info("Client A (owner) introspection response: %s", data)
|
||||
assert data.get("active") is True, (
|
||||
"Client A (owner) should be able to introspect its own token"
|
||||
)
|
||||
@@ -417,13 +421,13 @@ async def test_introspection_with_resource_parameter(
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client B (resource server) introspection response: {data}")
|
||||
logger.info("Client B (resource server) introspection response: %s", data)
|
||||
assert data.get("active") is True, (
|
||||
"Client B (resource server) should be able to introspect token intended for it"
|
||||
)
|
||||
|
||||
# Verify the resource field in the response matches client B
|
||||
logger.info(f"Full introspection response from Client B: {data}")
|
||||
logger.info("Full introspection response from Client B: %s", data)
|
||||
|
||||
# Test 3: Client C CANNOT introspect the token (not owner, not resource server)
|
||||
response = await client.post(
|
||||
@@ -433,7 +437,7 @@ async def test_introspection_with_resource_parameter(
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client C (third party) introspection response: {data}")
|
||||
logger.info("Client C (third party) introspection response: %s", data)
|
||||
assert data.get("active") is False, (
|
||||
"Client C should NOT be able to introspect token (not owner or resource server)"
|
||||
)
|
||||
@@ -464,7 +468,7 @@ async def test_introspection_returns_inactive_for_invalid_token(
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Introspection response for fake token: {data}")
|
||||
logger.info("Introspection response for fake token: %s", data)
|
||||
assert data.get("active") is False, (
|
||||
"Should return active=false for invalid token"
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestLoginFlowAuthTools:
|
||||
# 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']}")
|
||||
logger.info("Provisioned as: %s, scopes: %s", data["username"], data["scopes"])
|
||||
|
||||
async def test_provision_access_already_provisioned(
|
||||
self, nc_mcp_login_flow_client: ClientSession
|
||||
@@ -100,7 +100,7 @@ class TestLoginFlowNotes:
|
||||
note = json.loads(create_result.content[0].text)
|
||||
note_id = note["id"]
|
||||
etag = note["etag"]
|
||||
logger.info(f"Created note {note_id}")
|
||||
logger.info("Created note %s", note_id)
|
||||
|
||||
try:
|
||||
# Read
|
||||
@@ -152,7 +152,7 @@ class TestLoginFlowNotes:
|
||||
await nc_mcp_login_flow_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
logger.info(f"Deleted note {note_id}")
|
||||
logger.info("Deleted note %s", note_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -176,7 +176,7 @@ class TestLoginFlowCalendarEvents:
|
||||
calendars = cal_data.get("calendars", [])
|
||||
assert len(calendars) > 0
|
||||
calendar_name = calendars[0].get("name", "personal")
|
||||
logger.info(f"Using calendar: {calendar_name}")
|
||||
logger.info("Using calendar: %s", calendar_name)
|
||||
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
event_title = f"LoginFlow Event {suffix}"
|
||||
@@ -197,7 +197,7 @@ class TestLoginFlowCalendarEvents:
|
||||
)
|
||||
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}")
|
||||
logger.info("Created event: %s", event_uid)
|
||||
|
||||
try:
|
||||
# Get event
|
||||
@@ -213,7 +213,7 @@ class TestLoginFlowCalendarEvents:
|
||||
"nc_calendar_delete_event",
|
||||
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||
)
|
||||
logger.info(f"Deleted event {event_uid}")
|
||||
logger.info("Deleted event %s", event_uid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -254,7 +254,7 @@ class TestLoginFlowCalendarTodos:
|
||||
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}")
|
||||
logger.info("Created todo: %s", todo_uid)
|
||||
|
||||
try:
|
||||
# List todos
|
||||
@@ -280,7 +280,7 @@ class TestLoginFlowCalendarTodos:
|
||||
"nc_calendar_delete_todo",
|
||||
{"calendar_name": calendar_name, "todo_uid": todo_uid},
|
||||
)
|
||||
logger.info(f"Deleted todo {todo_uid}")
|
||||
logger.info("Deleted todo %s", todo_uid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -312,7 +312,7 @@ class TestLoginFlowContacts:
|
||||
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}")
|
||||
logger.info("Created address book: %s", ab_name)
|
||||
|
||||
try:
|
||||
# Create contact (requires addressbook, uid, contact_data dict)
|
||||
@@ -330,7 +330,7 @@ class TestLoginFlowContacts:
|
||||
assert create_result.isError is False, (
|
||||
f"Create contact failed: {create_result.content[0].text}"
|
||||
)
|
||||
logger.info(f"Created contact: {contact_uid}")
|
||||
logger.info("Created contact: %s", contact_uid)
|
||||
|
||||
# List contacts in our clean addressbook
|
||||
# Note: may fail due to server-side Pydantic bug where ContactField.value
|
||||
@@ -343,7 +343,7 @@ class TestLoginFlowContacts:
|
||||
error_text = list_result.content[0].text
|
||||
if "ContactField" in error_text:
|
||||
logger.warning(
|
||||
f"Known server bug: ContactField validation: {error_text}"
|
||||
"Known server bug: ContactField validation: %s", error_text
|
||||
)
|
||||
else:
|
||||
raise AssertionError(f"List contacts failed: {error_text}")
|
||||
@@ -360,7 +360,7 @@ class TestLoginFlowContacts:
|
||||
"nc_contacts_delete_contact",
|
||||
{"addressbook": ab_name, "uid": contact_uid},
|
||||
)
|
||||
logger.info(f"Deleted contact {contact_uid}")
|
||||
logger.info("Deleted contact %s", contact_uid)
|
||||
|
||||
finally:
|
||||
# Always clean up the temporary address book
|
||||
@@ -368,7 +368,7 @@ class TestLoginFlowContacts:
|
||||
"nc_contacts_delete_addressbook",
|
||||
{"name": ab_name},
|
||||
)
|
||||
logger.info(f"Deleted address book {ab_name}")
|
||||
logger.info("Deleted address book %s", ab_name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -393,7 +393,7 @@ class TestLoginFlowFiles:
|
||||
assert mkdir_result.isError is False, (
|
||||
f"Create dir failed: {mkdir_result.content[0].text}"
|
||||
)
|
||||
logger.info(f"Created directory: {dir_path}")
|
||||
logger.info("Created directory: %s", dir_path)
|
||||
|
||||
try:
|
||||
# Write file
|
||||
@@ -436,7 +436,7 @@ class TestLoginFlowFiles:
|
||||
await nc_mcp_login_flow_client.call_tool(
|
||||
"nc_webdav_delete_resource", {"path": dir_path}
|
||||
)
|
||||
logger.info(f"Cleaned up {dir_path}")
|
||||
logger.info("Cleaned up %s", dir_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -467,7 +467,7 @@ class TestLoginFlowDeck:
|
||||
)
|
||||
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}")
|
||||
logger.info("Created board: %s", board_id)
|
||||
|
||||
# List boards (tool name is deck_get_boards)
|
||||
list_result = await nc_mcp_login_flow_client.call_tool(
|
||||
@@ -499,9 +499,11 @@ class TestLoginFlowDeck:
|
||||
resp = await client.delete(
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}"
|
||||
)
|
||||
logger.info(f"Board cleanup: {board_id} → {resp.status_code}")
|
||||
logger.info(
|
||||
"Board cleanup: %s → %s", board_id, resp.status_code
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Board cleanup failed: {e}")
|
||||
logger.warning("Board cleanup failed: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -521,7 +523,7 @@ class TestLoginFlowTables:
|
||||
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}")
|
||||
logger.info("Tables: %s", data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -569,7 +571,7 @@ class TestLoginFlowCookbook:
|
||||
)
|
||||
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}")
|
||||
logger.info("Created recipe: %s", recipe_id)
|
||||
|
||||
try:
|
||||
# Get recipe (may fail due to server-side Pydantic bug with recipeYield=None)
|
||||
@@ -580,7 +582,8 @@ class TestLoginFlowCookbook:
|
||||
error_text = get_result.content[0].text
|
||||
if "recipeYield" in error_text:
|
||||
logger.warning(
|
||||
f"Known server bug: Recipe.recipeYield validation: {error_text}"
|
||||
"Known server bug: Recipe.recipeYield validation: %s",
|
||||
error_text,
|
||||
)
|
||||
else:
|
||||
raise AssertionError(f"Get recipe failed: {error_text}")
|
||||
@@ -590,7 +593,7 @@ class TestLoginFlowCookbook:
|
||||
await nc_mcp_login_flow_client.call_tool(
|
||||
"nc_cookbook_delete_recipe", {"recipe_id": recipe_id}
|
||||
)
|
||||
logger.info(f"Deleted recipe {recipe_id}")
|
||||
logger.info("Deleted recipe %s", recipe_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -655,4 +658,4 @@ class TestLoginFlowConnectivity:
|
||||
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)}")
|
||||
logger.info("Resource templates: %s", len(templates.resourceTemplates))
|
||||
|
||||
@@ -537,4 +537,4 @@ class TestMultiUserSmoke:
|
||||
]:
|
||||
tools = await client.list_tools()
|
||||
assert len(tools.tools) > 0, f"{name} MCP client has no tools"
|
||||
logger.info(f"{name} MCP client working ({len(tools.tools)} tools)")
|
||||
logger.info("%s MCP client working (%s tools)", name, len(tools.tools))
|
||||
|
||||
@@ -73,7 +73,7 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Read-only token sees {len(tool_names)} tools")
|
||||
logger.info("Read-only token sees %s tools", len(tool_names))
|
||||
|
||||
# Verify read tools are present (only for apps with :read scopes)
|
||||
# Read-only token has: notes.read, calendar.read, contacts.read,
|
||||
@@ -104,8 +104,8 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Read-only token properly filters tools: {len(tool_names)} read tools visible, "
|
||||
f"write tools hidden"
|
||||
"✅ Read-only token properly filters tools: %s read tools visible, write tools hidden",
|
||||
len(tool_names),
|
||||
)
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Write-only token sees {len(tool_names)} tools")
|
||||
logger.info("Write-only token sees %s tools", len(tool_names))
|
||||
|
||||
# Verify write tools are present
|
||||
# Write-only token has: notes.write, calendar.write, contacts.write,
|
||||
@@ -153,8 +153,8 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Write-only token properly filters tools: {len(tool_names)} write tools visible, "
|
||||
f"read tools hidden"
|
||||
"✅ Write-only token properly filters tools: %s write tools visible, read tools hidden",
|
||||
len(tool_names),
|
||||
)
|
||||
|
||||
|
||||
@@ -171,8 +171,8 @@ async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_a
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"Full access token sees {len(tool_names)} tools")
|
||||
logger.info(f"Tools: {sorted(tool_names)}")
|
||||
logger.info("Full access token sees %s tools", len(tool_names))
|
||||
logger.info("Tools: %s", sorted(tool_names))
|
||||
|
||||
# Verify both read and write tools are present
|
||||
# Full access has all *read and *write scopes
|
||||
@@ -197,7 +197,7 @@ async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_a
|
||||
assert len(tool_names) >= 90
|
||||
|
||||
logger.info(
|
||||
f"✅ Full access token sees all tools: {len(tool_names)} total (read + write)"
|
||||
"✅ Full access token sees all tools: %s total (read + write)", len(tool_names)
|
||||
)
|
||||
|
||||
|
||||
@@ -415,7 +415,8 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(
|
||||
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 7 auth tools)"
|
||||
"JWT token with no custom scopes sees %s tools (should be 7 auth tools)",
|
||||
len(tool_names),
|
||||
)
|
||||
|
||||
# Only auth/provisioning tools should be visible (they require 'openid' scope)
|
||||
@@ -435,8 +436,8 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ JWT token with only openid scope correctly shows {len(tool_names)} auth tools, "
|
||||
"resource tools filtered out"
|
||||
"✅ JWT token with only openid scope correctly shows %s auth tools, resource tools filtered out",
|
||||
len(tool_names),
|
||||
)
|
||||
|
||||
|
||||
@@ -457,7 +458,7 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_login_flow_client_read_onl
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"JWT with nc:read consent sees {len(tool_names)} tools")
|
||||
logger.info("JWT with nc:read consent sees %s tools", len(tool_names))
|
||||
|
||||
# Verify read tools are present
|
||||
read_tools = ["nc_notes_get_note", "nc_notes_search_notes", "nc_webdav_read_file"]
|
||||
@@ -474,7 +475,8 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_login_flow_client_read_onl
|
||||
assert tool not in tool_names, f"Write tool {tool} should be filtered out"
|
||||
|
||||
logger.info(
|
||||
f"✅ JWT with nc:read consent: {len(tool_names)} read tools visible, write tools filtered"
|
||||
"✅ JWT with nc:read consent: %s read tools visible, write tools filtered",
|
||||
len(tool_names),
|
||||
)
|
||||
|
||||
|
||||
@@ -495,7 +497,7 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_login_flow_client_write_o
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"JWT with nc:write consent sees {len(tool_names)} tools")
|
||||
logger.info("JWT with nc:write consent sees %s tools", len(tool_names))
|
||||
|
||||
# Verify write tools are present
|
||||
write_tools = [
|
||||
@@ -512,7 +514,8 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_login_flow_client_write_o
|
||||
assert tool not in tool_names, f"Read-only tool {tool} should be filtered out"
|
||||
|
||||
logger.info(
|
||||
f"✅ JWT with nc:write consent: {len(tool_names)} write tools visible, read-only tools filtered"
|
||||
"✅ JWT with nc:write consent: %s write tools visible, read-only tools filtered",
|
||||
len(tool_names),
|
||||
)
|
||||
|
||||
|
||||
@@ -533,7 +536,7 @@ async def test_jwt_consent_scenarios_full_access(nc_mcp_login_flow_client_full_a
|
||||
assert len(result.tools) > 0
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(f"JWT with full consent sees {len(tool_names)} tools")
|
||||
logger.info("JWT with full consent sees %s tools", len(tool_names))
|
||||
|
||||
# Verify both read and write tools are present
|
||||
read_tools = ["nc_notes_get_note", "nc_webdav_read_file"]
|
||||
@@ -549,7 +552,7 @@ async def test_jwt_consent_scenarios_full_access(nc_mcp_login_flow_client_full_a
|
||||
assert len(tool_names) >= 90, f"Expected 90+ tools but got {len(tool_names)}"
|
||||
|
||||
logger.info(
|
||||
f"✅ JWT with full consent: {len(tool_names)} tools visible (all read + write)"
|
||||
"✅ JWT with full consent: %s tools visible (all read + write)", len(tool_names)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ async def test_mcp_update_event_extended_fields(
|
||||
|
||||
result_data = json.loads(create_result.content[0].text)
|
||||
event_uid = result_data["uid"]
|
||||
logger.info(f"Created base event via MCP: {event_uid}")
|
||||
logger.info("Created base event via MCP: %s", event_uid)
|
||||
|
||||
# 2. Update with all four extended fields via MCP
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
|
||||
@@ -24,7 +24,7 @@ async def test_mcp_todo_complete_workflow(
|
||||
|
||||
try:
|
||||
# 1. Create todo via MCP
|
||||
logger.info(f"Creating todo in {calendar_name} via MCP")
|
||||
logger.info("Creating todo in %s via MCP", calendar_name)
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
@@ -46,7 +46,7 @@ async def test_mcp_todo_complete_workflow(
|
||||
|
||||
result_json = json.loads(result_data)
|
||||
todo_uid = result_json["uid"]
|
||||
logger.info(f"Created todo with UID: {todo_uid}")
|
||||
logger.info("Created todo with UID: %s", todo_uid)
|
||||
|
||||
# 2. Verify todo creation via client
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
@@ -57,7 +57,7 @@ async def test_mcp_todo_complete_workflow(
|
||||
assert created_todo["priority"] == 3
|
||||
|
||||
# 3. List todos via MCP
|
||||
logger.info(f"Listing todos in {calendar_name} via MCP")
|
||||
logger.info("Listing todos in %s via MCP", calendar_name)
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_todos",
|
||||
{"calendar_name": calendar_name},
|
||||
@@ -69,7 +69,7 @@ async def test_mcp_todo_complete_workflow(
|
||||
assert any(t["uid"] == todo_uid for t in list_data["todos"])
|
||||
|
||||
# 4. Update todo via MCP
|
||||
logger.info(f"Updating todo {todo_uid} via MCP")
|
||||
logger.info("Updating todo %s via MCP", todo_uid)
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_todo",
|
||||
{
|
||||
@@ -92,7 +92,7 @@ async def test_mcp_todo_complete_workflow(
|
||||
assert updated_todo["percent_complete"] == 50
|
||||
|
||||
# 6. Delete todo via MCP
|
||||
logger.info(f"Deleting todo {todo_uid} via MCP")
|
||||
logger.info("Deleting todo %s via MCP", todo_uid)
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_delete_todo",
|
||||
{"calendar_name": calendar_name, "todo_uid": todo_uid},
|
||||
|
||||
@@ -27,7 +27,7 @@ async def temporary_collective(nc_mcp_client: ClientSession):
|
||||
assert result.isError is False, f"Failed to create collective: {result.content}"
|
||||
data = json.loads(result.content[0].text)
|
||||
collective_id = data["id"]
|
||||
logger.info(f"Created temporary collective: {name} (ID: {collective_id})")
|
||||
logger.info("Created temporary collective: %s (ID: %s)", name, collective_id)
|
||||
|
||||
# Get the landing page ID — filter by parentId == 0 (root page)
|
||||
pages_result = await nc_mcp_client.call_tool(
|
||||
@@ -55,9 +55,9 @@ async def temporary_collective(nc_mcp_client: ClientSession):
|
||||
"collectives_delete_collective",
|
||||
{"collective_id": collective_id},
|
||||
)
|
||||
logger.info(f"Cleaned up collective: {collective_id}")
|
||||
logger.info("Cleaned up collective: %s", collective_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup of collective {collective_id} failed: {e}")
|
||||
logger.warning("Cleanup of collective %s failed: %s", collective_id, e)
|
||||
|
||||
|
||||
# --- Tool Discovery ---
|
||||
@@ -96,7 +96,7 @@ async def test_collectives_tools_available(nc_mcp_client: ClientSession):
|
||||
f"Expected tool '{expected}' not found in available tools"
|
||||
)
|
||||
|
||||
logger.info(f"All {len(expected_tools)} Collectives tools registered")
|
||||
logger.info("All %s Collectives tools registered", len(expected_tools))
|
||||
|
||||
|
||||
# --- Collective CRUD ---
|
||||
@@ -115,7 +115,7 @@ async def test_collectives_list(
|
||||
|
||||
collective_ids = [c["id"] for c in data["collectives"]]
|
||||
assert temporary_collective["id"] in collective_ids
|
||||
logger.info(f"Found {data['total']} collectives")
|
||||
logger.info("Found %s collectives", data["total"])
|
||||
|
||||
|
||||
async def test_collectives_set_collective_emoji(
|
||||
@@ -179,7 +179,7 @@ async def test_collectives_page_workflow(
|
||||
page_id = create_data["id"]
|
||||
assert create_data["collective_id"] == cid
|
||||
assert create_data["parent_id"] == landing_id
|
||||
logger.info(f"Created page: {unique_title} (ID: {page_id})")
|
||||
logger.info("Created page: %s (ID: %s)", unique_title, page_id)
|
||||
|
||||
# 2. List pages — should include the new page
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
@@ -190,7 +190,7 @@ async def test_collectives_page_workflow(
|
||||
list_data = json.loads(list_result.content[0].text)
|
||||
page_ids = [p["id"] for p in list_data["pages"]]
|
||||
assert page_id in page_ids
|
||||
logger.info(f"Page found in list ({list_data['total']} pages)")
|
||||
logger.info("Page found in list (%s pages)", list_data["total"])
|
||||
|
||||
# 3. Get page with content
|
||||
get_result = await nc_mcp_client.call_tool(
|
||||
@@ -269,7 +269,7 @@ async def test_collectives_get_landing_page_content(
|
||||
"Landing page should have auto-generated content"
|
||||
)
|
||||
assert len(data["content"]) > 0, "Landing page should have non-empty content"
|
||||
logger.info(f"Landing page content: {len(data['content'])} bytes")
|
||||
logger.info("Landing page content: %s bytes", len(data["content"]))
|
||||
|
||||
|
||||
async def test_collectives_move_page(
|
||||
@@ -307,7 +307,7 @@ async def test_collectives_move_page(
|
||||
assert data["page_id"] == page_id
|
||||
assert "moved" in data["message"]
|
||||
assert new_title in data["message"]
|
||||
logger.info(f"Page renamed to: {new_title}")
|
||||
logger.info("Page renamed to: %s", new_title)
|
||||
|
||||
# Cleanup
|
||||
await nc_mcp_client.call_tool(
|
||||
@@ -337,7 +337,7 @@ async def test_collectives_tag_workflow(
|
||||
tag_id = tag_data["id"]
|
||||
assert tag_data["name"] == tag_name
|
||||
assert tag_data["color"] == "FF5733"
|
||||
logger.info(f"Created tag: {tag_name} (ID: {tag_id})")
|
||||
logger.info("Created tag: %s (ID: %s)", tag_name, tag_id)
|
||||
|
||||
# 2. List tags — should include the new tag
|
||||
list_tags_result = await nc_mcp_client.call_tool(
|
||||
@@ -348,7 +348,7 @@ async def test_collectives_tag_workflow(
|
||||
tags_data = json.loads(list_tags_result.content[0].text)
|
||||
tag_ids = [t["id"] for t in tags_data["tags"]]
|
||||
assert tag_id in tag_ids
|
||||
logger.info(f"Tag found in list ({tags_data['total']} tags)")
|
||||
logger.info("Tag found in list (%s tags)", tags_data["total"])
|
||||
|
||||
# 3. Create a page to tag
|
||||
page_result = await nc_mcp_client.call_tool(
|
||||
@@ -368,7 +368,7 @@ async def test_collectives_tag_workflow(
|
||||
{"collective_id": cid, "page_id": page_id, "tag_id": tag_id},
|
||||
)
|
||||
assert assign_result.isError is False
|
||||
logger.info(f"Tag {tag_id} assigned to page {page_id}")
|
||||
logger.info("Tag %s assigned to page %s", tag_id, page_id)
|
||||
|
||||
# 5. Remove tag from page
|
||||
remove_result = await nc_mcp_client.call_tool(
|
||||
@@ -376,7 +376,7 @@ async def test_collectives_tag_workflow(
|
||||
{"collective_id": cid, "page_id": page_id, "tag_id": tag_id},
|
||||
)
|
||||
assert remove_result.isError is False
|
||||
logger.info(f"Tag {tag_id} removed from page {page_id}")
|
||||
logger.info("Tag %s removed from page %s", tag_id, page_id)
|
||||
|
||||
# Cleanup
|
||||
await nc_mcp_client.call_tool(
|
||||
@@ -406,7 +406,7 @@ async def test_collectives_search(
|
||||
assert data["query"] == "Welcome"
|
||||
assert data["collective_id"] == cid
|
||||
# Search may or may not find results depending on indexing timing
|
||||
logger.info(f"Search returned {data['total']} results for 'Welcome'")
|
||||
logger.info("Search returned %s results for 'Welcome'", data["total"])
|
||||
|
||||
|
||||
# --- Collective Trash / Restore / Delete ---
|
||||
@@ -425,7 +425,7 @@ async def test_collectives_trash_restore_delete_workflow(
|
||||
assert create_result.isError is False
|
||||
created = json.loads(create_result.content[0].text)
|
||||
cid = created["id"]
|
||||
logger.info(f"Created collective {name} (ID: {cid})")
|
||||
logger.info("Created collective %s (ID: %s)", name, cid)
|
||||
|
||||
# Trash the collective
|
||||
trash_result = await nc_mcp_client.call_tool(
|
||||
@@ -444,7 +444,7 @@ async def test_collectives_trash_restore_delete_workflow(
|
||||
trash_data = json.loads(list_trash_result.content[0].text)
|
||||
trashed_ids = [c["id"] for c in trash_data["collectives"]]
|
||||
assert cid in trashed_ids
|
||||
logger.info(f"Found {trash_data['total']} trashed collectives")
|
||||
logger.info("Found %s trashed collectives", trash_data["total"])
|
||||
|
||||
# Restore the collective
|
||||
restore_result = await nc_mcp_client.call_tool(
|
||||
|
||||
@@ -28,7 +28,7 @@ async def test_mcp_contacts_workflow(
|
||||
|
||||
try:
|
||||
# 1. Create address book via MCP
|
||||
logger.info(f"Creating address book via MCP: {addressbook_name}")
|
||||
logger.info("Creating address book via MCP: %s", addressbook_name)
|
||||
create_ab_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_create_addressbook",
|
||||
{"name": addressbook_name, "display_name": f"MCP Test {addressbook_name}"},
|
||||
@@ -40,7 +40,7 @@ async def test_mcp_contacts_workflow(
|
||||
assert any(ab["name"] == addressbook_name for ab in addressbooks)
|
||||
|
||||
# 3. Create contact via MCP
|
||||
logger.info(f"Creating contact in {addressbook_name} via MCP")
|
||||
logger.info("Creating contact in %s via MCP", addressbook_name)
|
||||
create_c_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_create_contact",
|
||||
{
|
||||
@@ -56,7 +56,7 @@ async def test_mcp_contacts_workflow(
|
||||
assert any(c["vcard_id"] == contact_uid for c in contacts)
|
||||
|
||||
# 5. Delete contact via MCP
|
||||
logger.info(f"Deleting contact {contact_uid} via MCP")
|
||||
logger.info("Deleting contact %s via MCP", contact_uid)
|
||||
delete_c_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_delete_contact",
|
||||
{"addressbook": addressbook_name, "uid": contact_uid},
|
||||
@@ -68,7 +68,7 @@ async def test_mcp_contacts_workflow(
|
||||
assert not any(c["vcard_id"] == contact_uid for c in contacts)
|
||||
|
||||
# 7. Delete address book via MCP
|
||||
logger.info(f"Deleting address book {addressbook_name} via MCP")
|
||||
logger.info("Deleting address book %s via MCP", addressbook_name)
|
||||
delete_ab_result = await nc_mcp_client.call_tool(
|
||||
"nc_contacts_delete_addressbook", {"name": addressbook_name}
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ async def test_mcp_cookbook_create_and_read_recipe(
|
||||
|
||||
try:
|
||||
# 1. Create recipe via MCP
|
||||
logger.info(f"Creating recipe via MCP: {recipe_name}")
|
||||
logger.info("Creating recipe via MCP: %s", recipe_name)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_create_recipe",
|
||||
{
|
||||
@@ -59,7 +59,7 @@ async def test_mcp_cookbook_create_and_read_recipe(
|
||||
|
||||
create_response = json.loads(create_result.content[0].text)
|
||||
created_recipe_id = create_response["id"]
|
||||
logger.info(f"Recipe created via MCP with ID: {created_recipe_id}")
|
||||
logger.info("Recipe created via MCP with ID: %s", created_recipe_id)
|
||||
|
||||
# 2. Verify creation via direct NextcloudClient
|
||||
direct_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
@@ -70,7 +70,7 @@ async def test_mcp_cookbook_create_and_read_recipe(
|
||||
assert direct_recipe["recipeCategory"] == "MCPTesting"
|
||||
|
||||
# 3. Read recipe via MCP
|
||||
logger.info(f"Reading recipe via MCP: {created_recipe_id}")
|
||||
logger.info("Reading recipe via MCP: %s", created_recipe_id)
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipe", {"recipe_id": created_recipe_id}
|
||||
)
|
||||
@@ -84,16 +84,16 @@ async def test_mcp_cookbook_create_and_read_recipe(
|
||||
assert read_recipe["description"] == "A test recipe created via MCP tools"
|
||||
assert len(read_recipe["recipeIngredient"]) == 3
|
||||
|
||||
logger.info(f"Successfully verified recipe {created_recipe_id} via MCP")
|
||||
logger.info("Successfully verified recipe %s via MCP", created_recipe_id)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
logger.info("Cleaned up recipe %s", created_recipe_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
logger.warning("Failed to cleanup recipe: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_cookbook_update_recipe(
|
||||
@@ -115,11 +115,11 @@ async def test_mcp_cookbook_update_recipe(
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for update test: {recipe_name}")
|
||||
logger.info("Creating recipe for update test: %s", recipe_name)
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Update recipe via MCP (tool handles fetching current recipe internally)
|
||||
logger.info(f"Updating recipe via MCP: {created_recipe_id}")
|
||||
logger.info("Updating recipe via MCP: %s", created_recipe_id)
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_update_recipe",
|
||||
{
|
||||
@@ -143,16 +143,16 @@ async def test_mcp_cookbook_update_recipe(
|
||||
assert len(updated_recipe["recipeInstructions"]) == 2
|
||||
assert updated_recipe["recipeCategory"] == "Updated"
|
||||
|
||||
logger.info(f"Successfully updated recipe {created_recipe_id} via MCP")
|
||||
logger.info("Successfully updated recipe %s via MCP", created_recipe_id)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
logger.info("Cleaned up recipe %s", created_recipe_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
logger.warning("Failed to cleanup recipe: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_cookbook_delete_recipe(
|
||||
@@ -173,11 +173,11 @@ async def test_mcp_cookbook_delete_recipe(
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for delete test: {recipe_name}")
|
||||
logger.info("Creating recipe for delete test: %s", recipe_name)
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Delete recipe via MCP
|
||||
logger.info(f"Deleting recipe via MCP: {created_recipe_id}")
|
||||
logger.info("Deleting recipe via MCP: %s", created_recipe_id)
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_delete_recipe", {"recipe_id": created_recipe_id}
|
||||
)
|
||||
@@ -192,7 +192,9 @@ async def test_mcp_cookbook_delete_recipe(
|
||||
pytest.fail("Recipe should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - recipe should be deleted
|
||||
logger.info(f"Successfully verified recipe {created_recipe_id} was deleted")
|
||||
logger.info(
|
||||
"Successfully verified recipe %s was deleted", created_recipe_id
|
||||
)
|
||||
created_recipe_id = None # Mark as cleaned up
|
||||
|
||||
finally:
|
||||
@@ -200,9 +202,9 @@ async def test_mcp_cookbook_delete_recipe(
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
logger.info("Cleaned up recipe %s", created_recipe_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
logger.warning("Failed to cleanup recipe: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_cookbook_import_recipe_from_url(
|
||||
@@ -221,7 +223,7 @@ async def test_mcp_cookbook_import_recipe_from_url(
|
||||
|
||||
try:
|
||||
# 1. Import recipe via MCP
|
||||
logger.info(f"Importing recipe from nginx container via MCP: {test_url}")
|
||||
logger.info("Importing recipe from nginx container via MCP: %s", test_url)
|
||||
import_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_import_recipe", {"url": test_url}
|
||||
)
|
||||
@@ -234,7 +236,7 @@ async def test_mcp_cookbook_import_recipe_from_url(
|
||||
created_recipe_id = int(import_response["recipe_id"])
|
||||
imported_recipe = import_response["recipe"]
|
||||
|
||||
logger.info(f"Successfully imported recipe via MCP: {imported_recipe['name']}")
|
||||
logger.info("Successfully imported recipe via MCP: %s", imported_recipe["name"])
|
||||
|
||||
# 2. Verify basic recipe structure
|
||||
assert imported_recipe["name"] == "Black Pepper Tofu"
|
||||
@@ -247,16 +249,16 @@ async def test_mcp_cookbook_import_recipe_from_url(
|
||||
# 3. Verify we can read it back via direct NextcloudClient
|
||||
retrieved = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
assert retrieved["name"] == imported_recipe["name"]
|
||||
logger.info(f"Verified imported recipe ID: {created_recipe_id}")
|
||||
logger.info("Verified imported recipe ID: %s", created_recipe_id)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up imported recipe {created_recipe_id}")
|
||||
logger.info("Cleaned up imported recipe %s", created_recipe_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup imported recipe: {e}")
|
||||
logger.warning("Failed to cleanup imported recipe: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_cookbook_search_recipes(
|
||||
@@ -278,14 +280,14 @@ async def test_mcp_cookbook_search_recipes(
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
|
||||
logger.info("Creating recipe for search test with keyword: %s", unique_keyword)
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await anyio.sleep(2)
|
||||
|
||||
# 3. Search for the recipe via MCP
|
||||
logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
|
||||
logger.info("Searching for recipes via MCP with keyword: %s", unique_keyword)
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_search_recipes", {"query": unique_keyword}
|
||||
)
|
||||
@@ -304,7 +306,7 @@ async def test_mcp_cookbook_search_recipes(
|
||||
found = any(str(r.get("id")) == str(created_recipe_id) for r in search_results)
|
||||
assert found, f"Recipe {created_recipe_id} not found in search results"
|
||||
logger.info(
|
||||
f"Successfully found recipe {created_recipe_id} in MCP search results"
|
||||
"Successfully found recipe %s in MCP search results", created_recipe_id
|
||||
)
|
||||
|
||||
finally:
|
||||
@@ -312,9 +314,9 @@ async def test_mcp_cookbook_search_recipes(
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
logger.info("Cleaned up recipe %s", created_recipe_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
logger.warning("Failed to cleanup recipe: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_cookbook_list_recipes(
|
||||
@@ -333,7 +335,7 @@ async def test_mcp_cookbook_list_recipes(
|
||||
recipes = list_response["recipes"]
|
||||
|
||||
assert isinstance(recipes, list)
|
||||
logger.info(f"Found {len(recipes)} recipes via MCP")
|
||||
logger.info("Found %s recipes via MCP", len(recipes))
|
||||
|
||||
|
||||
async def test_mcp_cookbook_categories_workflow(
|
||||
@@ -354,7 +356,7 @@ async def test_mcp_cookbook_categories_workflow(
|
||||
|
||||
try:
|
||||
# 1. Create recipe in test category
|
||||
logger.info(f"Creating recipe in category: {unique_category}")
|
||||
logger.info("Creating recipe in category: %s", unique_category)
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
@@ -374,10 +376,10 @@ async def test_mcp_cookbook_categories_workflow(
|
||||
categories = categories_response["categories"]
|
||||
|
||||
assert isinstance(categories, list)
|
||||
logger.info(f"Found {len(categories)} categories via MCP")
|
||||
logger.info("Found %s categories via MCP", len(categories))
|
||||
|
||||
# 4. Get recipes in this category via MCP
|
||||
logger.info(f"Getting recipes in category via MCP: {unique_category}")
|
||||
logger.info("Getting recipes in category via MCP: %s", unique_category)
|
||||
category_recipes_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipes_in_category", {"category": unique_category}
|
||||
)
|
||||
@@ -399,16 +401,16 @@ async def test_mcp_cookbook_categories_workflow(
|
||||
assert found, (
|
||||
f"Recipe {created_recipe_id} not found in category {unique_category}"
|
||||
)
|
||||
logger.info(f"Successfully found recipe in category {unique_category} via MCP")
|
||||
logger.info("Successfully found recipe in category %s via MCP", unique_category)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
logger.info("Cleaned up recipe %s", created_recipe_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
logger.warning("Failed to cleanup recipe: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_cookbook_keywords_workflow(
|
||||
@@ -429,7 +431,7 @@ async def test_mcp_cookbook_keywords_workflow(
|
||||
|
||||
try:
|
||||
# 1. Create recipe with test keywords
|
||||
logger.info(f"Creating recipe with keyword: {unique_keyword}")
|
||||
logger.info("Creating recipe with keyword: %s", unique_keyword)
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow extra time for indexing and trigger reindex
|
||||
@@ -449,10 +451,10 @@ async def test_mcp_cookbook_keywords_workflow(
|
||||
keywords = keywords_response["keywords"]
|
||||
|
||||
assert isinstance(keywords, list)
|
||||
logger.info(f"Found {len(keywords)} keywords via MCP")
|
||||
logger.info("Found %s keywords via MCP", len(keywords))
|
||||
|
||||
# 4. Get recipes with this keyword via MCP
|
||||
logger.info(f"Getting recipes with keyword via MCP: {unique_keyword}")
|
||||
logger.info("Getting recipes with keyword via MCP: %s", unique_keyword)
|
||||
keyword_recipes_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipes_with_keywords", {"keywords": [unique_keyword]}
|
||||
)
|
||||
@@ -475,15 +477,17 @@ async def test_mcp_cookbook_keywords_workflow(
|
||||
)
|
||||
if found:
|
||||
logger.info(
|
||||
f"Successfully found recipe with keyword {unique_keyword} via MCP"
|
||||
"Successfully found recipe with keyword %s via MCP", unique_keyword
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Recipe {created_recipe_id} not in keyword results via MCP, but other recipes found"
|
||||
"Recipe %s not in keyword results via MCP, but other recipes found",
|
||||
created_recipe_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No recipes found with keyword {unique_keyword} via MCP - may be indexing delay"
|
||||
"No recipes found with keyword %s via MCP - may be indexing delay",
|
||||
unique_keyword,
|
||||
)
|
||||
|
||||
finally:
|
||||
@@ -491,9 +495,9 @@ async def test_mcp_cookbook_keywords_workflow(
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
logger.info("Cleaned up recipe %s", created_recipe_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
logger.warning("Failed to cleanup recipe: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_cookbook_config_and_version(
|
||||
@@ -509,7 +513,7 @@ async def test_mcp_cookbook_config_and_version(
|
||||
version_response = json.loads(version_result.contents[0].text)
|
||||
assert "cookbook_version" in version_response
|
||||
assert "api_version" in version_response
|
||||
logger.info(f"Cookbook version from MCP: {version_response}")
|
||||
logger.info("Cookbook version from MCP: %s", version_response)
|
||||
|
||||
# 2. Verify version via direct NextcloudClient
|
||||
direct_version = await nc_client.cookbook.get_version()
|
||||
@@ -526,7 +530,7 @@ async def test_mcp_cookbook_config_and_version(
|
||||
assert len(config_result.contents) > 0
|
||||
config_response = json.loads(config_result.contents[0].text)
|
||||
assert isinstance(config_response, dict)
|
||||
logger.info(f"Cookbook config from MCP: {config_response}")
|
||||
logger.info("Cookbook config from MCP: %s", config_response)
|
||||
|
||||
# 4. Verify config via direct NextcloudClient
|
||||
direct_config = await nc_client.cookbook.get_config()
|
||||
@@ -551,4 +555,4 @@ async def test_mcp_cookbook_reindex(
|
||||
|
||||
reindex_response = json.loads(reindex_result.content[0].text)
|
||||
assert isinstance(reindex_response["message"], str)
|
||||
logger.info(f"Reindex result from MCP: {reindex_response['message']}")
|
||||
logger.info("Reindex result from MCP: %s", reindex_response["message"])
|
||||
|
||||
@@ -21,7 +21,7 @@ async def test_deck_stack_mcp_tools(
|
||||
stack_order = 1
|
||||
|
||||
# 1. Create stack via MCP tool
|
||||
logger.info(f"Creating stack via MCP: {stack_title}")
|
||||
logger.info("Creating stack via MCP: %s", stack_title)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_stack",
|
||||
{"board_id": board_id, "title": stack_title, "order": stack_order},
|
||||
@@ -34,11 +34,11 @@ async def test_deck_stack_mcp_tools(
|
||||
stack_id = created_stack_response["id"]
|
||||
assert created_stack_response["title"] == stack_title
|
||||
assert created_stack_response["order"] == stack_order
|
||||
logger.info(f"Stack created via MCP with ID: {stack_id}")
|
||||
logger.info("Stack created via MCP with ID: %s", stack_id)
|
||||
|
||||
try:
|
||||
# 2. Get stack via MCP resource
|
||||
logger.info(f"Getting stack via MCP resource: {stack_id}")
|
||||
logger.info("Getting stack via MCP resource: %s", stack_id)
|
||||
get_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
|
||||
)
|
||||
@@ -51,7 +51,7 @@ async def test_deck_stack_mcp_tools(
|
||||
# 3. Update stack via MCP tool
|
||||
updated_title = f"Updated {stack_title}"
|
||||
updated_order = 2
|
||||
logger.info(f"Updating stack via MCP tool: {stack_id}")
|
||||
logger.info("Updating stack via MCP tool: %s", stack_id)
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"deck_update_stack",
|
||||
{
|
||||
@@ -86,10 +86,10 @@ async def test_deck_stack_mcp_tools(
|
||||
# Verify our stack is in the list
|
||||
stack_ids = [stack["id"] for stack in stacks_data]
|
||||
assert stack_id in stack_ids, "Updated stack not found in list"
|
||||
logger.info(f"Stack {stack_id} found in stacks list")
|
||||
logger.info("Stack %s found in stacks list", stack_id)
|
||||
|
||||
# 6. Read stack via MCP resource
|
||||
logger.info(f"Reading stack via MCP resource: {stack_id}")
|
||||
logger.info("Reading stack via MCP resource: %s", stack_id)
|
||||
read_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
|
||||
)
|
||||
@@ -100,7 +100,7 @@ async def test_deck_stack_mcp_tools(
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.deck.delete_stack(board_id, stack_id)
|
||||
logger.info(f"Cleaned up stack ID: {stack_id}")
|
||||
logger.info("Cleaned up stack ID: %s", stack_id)
|
||||
|
||||
|
||||
# Card MCP Tools Tests
|
||||
@@ -117,7 +117,7 @@ async def test_deck_card_mcp_tools(
|
||||
card_description = f"Test description for {card_title}"
|
||||
|
||||
# 1. Create card via MCP tool
|
||||
logger.info(f"Creating card via MCP: {card_title}")
|
||||
logger.info("Creating card via MCP: %s", card_title)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_card",
|
||||
{
|
||||
@@ -137,11 +137,11 @@ async def test_deck_card_mcp_tools(
|
||||
card_id = created_card_response["id"]
|
||||
assert created_card_response["title"] == card_title
|
||||
assert created_card_response["description"] == card_description
|
||||
logger.info(f"Card created via MCP with ID: {card_id}")
|
||||
logger.info("Card created via MCP with ID: %s", card_id)
|
||||
|
||||
try:
|
||||
# 2. Get card via MCP resource
|
||||
logger.info(f"Getting card via MCP resource: {card_id}")
|
||||
logger.info("Getting card via MCP resource: %s", card_id)
|
||||
get_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
|
||||
)
|
||||
@@ -154,7 +154,7 @@ async def test_deck_card_mcp_tools(
|
||||
# 3. Update card via MCP tool
|
||||
updated_title = f"Updated {card_title}"
|
||||
updated_description = f"Updated description for {card_title}"
|
||||
logger.info(f"Updating card via MCP tool: {card_id}")
|
||||
logger.info("Updating card via MCP tool: %s", card_id)
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"deck_update_card",
|
||||
{
|
||||
@@ -178,7 +178,7 @@ async def test_deck_card_mcp_tools(
|
||||
logger.info("Card update verified via direct client")
|
||||
|
||||
# 5. Archive/unarchive card via MCP tools
|
||||
logger.info(f"Archiving card via MCP tool: {card_id}")
|
||||
logger.info("Archiving card via MCP tool: %s", card_id)
|
||||
archive_result = await nc_mcp_client.call_tool(
|
||||
"deck_archive_card",
|
||||
{"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
|
||||
@@ -189,7 +189,7 @@ async def test_deck_card_mcp_tools(
|
||||
)
|
||||
logger.info("Card archived via MCP tool successfully")
|
||||
|
||||
logger.info(f"Unarchiving card via MCP tool: {card_id}")
|
||||
logger.info("Unarchiving card via MCP tool: %s", card_id)
|
||||
unarchive_result = await nc_mcp_client.call_tool(
|
||||
"deck_unarchive_card",
|
||||
{"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
|
||||
@@ -201,7 +201,7 @@ async def test_deck_card_mcp_tools(
|
||||
logger.info("Card unarchived via MCP tool successfully")
|
||||
|
||||
# 6. Move card to different position via MCP tool
|
||||
logger.info(f"Reordering card via MCP tool: {card_id}")
|
||||
logger.info("Reordering card via MCP tool: %s", card_id)
|
||||
reorder_result = await nc_mcp_client.call_tool(
|
||||
"deck_reorder_card",
|
||||
{
|
||||
@@ -219,7 +219,7 @@ async def test_deck_card_mcp_tools(
|
||||
logger.info("Card reordered via MCP tool successfully")
|
||||
|
||||
# 7. Read card via MCP resource
|
||||
logger.info(f"Reading card via MCP resource: {card_id}")
|
||||
logger.info("Reading card via MCP resource: %s", card_id)
|
||||
read_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
|
||||
)
|
||||
@@ -230,7 +230,7 @@ async def test_deck_card_mcp_tools(
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Cleaned up card ID: {card_id}")
|
||||
logger.info("Cleaned up card ID: %s", card_id)
|
||||
|
||||
|
||||
# Label MCP Tools Tests
|
||||
@@ -243,7 +243,7 @@ async def test_deck_label_mcp_tools(
|
||||
label_color = "FF0000" # Red
|
||||
|
||||
# 1. Create label via MCP tool
|
||||
logger.info(f"Creating label via MCP: {label_title}")
|
||||
logger.info("Creating label via MCP: %s", label_title)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_label",
|
||||
{"board_id": board_id, "title": label_title, "color": label_color},
|
||||
@@ -256,11 +256,11 @@ async def test_deck_label_mcp_tools(
|
||||
label_id = created_label_response["id"]
|
||||
assert created_label_response["title"] == label_title
|
||||
assert created_label_response["color"] == label_color
|
||||
logger.info(f"Label created via MCP with ID: {label_id}")
|
||||
logger.info("Label created via MCP with ID: %s", label_id)
|
||||
|
||||
try:
|
||||
# 2. Get label via MCP resource
|
||||
logger.info(f"Getting label via MCP resource: {label_id}")
|
||||
logger.info("Getting label via MCP resource: %s", label_id)
|
||||
get_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/labels/{label_id}"
|
||||
)
|
||||
@@ -273,7 +273,7 @@ async def test_deck_label_mcp_tools(
|
||||
# 3. Update label via MCP tool
|
||||
updated_title = f"Updated {label_title}"
|
||||
updated_color = "00FF00" # Green
|
||||
logger.info(f"Updating label via MCP tool: {label_id}")
|
||||
logger.info("Updating label via MCP tool: %s", label_id)
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"deck_update_label",
|
||||
{
|
||||
@@ -296,7 +296,7 @@ async def test_deck_label_mcp_tools(
|
||||
logger.info("Label update verified via direct client")
|
||||
|
||||
# 5. Read label via MCP resource
|
||||
logger.info(f"Reading label via MCP resource: {label_id}")
|
||||
logger.info("Reading label via MCP resource: %s", label_id)
|
||||
read_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/labels/{label_id}"
|
||||
)
|
||||
@@ -307,7 +307,7 @@ async def test_deck_label_mcp_tools(
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.deck.delete_label(board_id, label_id)
|
||||
logger.info(f"Cleaned up label ID: {label_id}")
|
||||
logger.info("Cleaned up label ID: %s", label_id)
|
||||
|
||||
|
||||
# Label-Card Assignment Tests
|
||||
@@ -330,7 +330,7 @@ async def test_deck_card_label_assignment_mcp_tools(
|
||||
|
||||
try:
|
||||
# 1. Assign label to card via MCP tool
|
||||
logger.info(f"Assigning label {label_id} to card {card_id} via MCP")
|
||||
logger.info("Assigning label %s to card %s via MCP", label_id, card_id)
|
||||
assign_result = await nc_mcp_client.call_tool(
|
||||
"deck_assign_label_to_card",
|
||||
{
|
||||
@@ -354,7 +354,7 @@ async def test_deck_card_label_assignment_mcp_tools(
|
||||
logger.info("Label assignment verified via direct client")
|
||||
|
||||
# 3. Remove label from card via MCP tool
|
||||
logger.info(f"Removing label {label_id} from card {card_id} via MCP")
|
||||
logger.info("Removing label %s from card %s via MCP", label_id, card_id)
|
||||
remove_result = await nc_mcp_client.call_tool(
|
||||
"deck_remove_label_from_card",
|
||||
{
|
||||
@@ -382,7 +382,7 @@ async def test_deck_card_label_assignment_mcp_tools(
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.deck.delete_label(board_id, label_id)
|
||||
logger.info(f"Cleaned up label ID: {label_id}")
|
||||
logger.info("Cleaned up label ID: %s", label_id)
|
||||
|
||||
|
||||
# User Assignment Tests
|
||||
@@ -401,7 +401,7 @@ async def test_deck_card_user_assignment_mcp_tools(
|
||||
user_id = "admin"
|
||||
|
||||
# 1. Assign user to card via MCP tool
|
||||
logger.info(f"Assigning user {user_id} to card {card_id} via MCP")
|
||||
logger.info("Assigning user %s to card %s via MCP", user_id, card_id)
|
||||
assign_result = await nc_mcp_client.call_tool(
|
||||
"deck_assign_user_to_card",
|
||||
{
|
||||
@@ -432,7 +432,7 @@ async def test_deck_card_user_assignment_mcp_tools(
|
||||
logger.info("User assignment verified via direct client")
|
||||
|
||||
# 3. Unassign user from card via MCP tool
|
||||
logger.info(f"Unassigning user {user_id} from card {card_id} via MCP")
|
||||
logger.info("Unassigning user %s from card %s via MCP", user_id, card_id)
|
||||
unassign_result = await nc_mcp_client.call_tool(
|
||||
"deck_unassign_user_from_card",
|
||||
{
|
||||
@@ -521,7 +521,7 @@ async def test_deck_mcp_resource_templates(nc_mcp_client: ClientSession):
|
||||
assert expected_template in template_uris, (
|
||||
f"Expected template '{expected_template}' not found"
|
||||
)
|
||||
logger.info(f"Found expected deck resource template: {expected_template}")
|
||||
logger.info("Found expected deck resource template: %s", expected_template)
|
||||
|
||||
|
||||
# Listing resource tests
|
||||
@@ -534,7 +534,7 @@ async def test_deck_mcp_listing_resources(
|
||||
stack_id = stack_data["id"]
|
||||
|
||||
# 1. Test listing stacks resource
|
||||
logger.info(f"Reading stacks list via MCP resource for board {board_id}")
|
||||
logger.info("Reading stacks list via MCP resource for board %s", board_id)
|
||||
stacks_resource_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks"
|
||||
)
|
||||
@@ -547,7 +547,7 @@ async def test_deck_mcp_listing_resources(
|
||||
logger.info("Stack found in stacks resource list")
|
||||
|
||||
# 2. Test listing cards resource
|
||||
logger.info(f"Reading cards list via MCP resource for stack {stack_id}")
|
||||
logger.info("Reading cards list via MCP resource for stack %s", stack_id)
|
||||
cards_resource_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards"
|
||||
)
|
||||
@@ -560,7 +560,7 @@ async def test_deck_mcp_listing_resources(
|
||||
logger.info("Card found in cards resource list")
|
||||
|
||||
# 3. Test listing labels resource
|
||||
logger.info(f"Reading labels list via MCP resource for board {board_id}")
|
||||
logger.info("Reading labels list via MCP resource for board %s", board_id)
|
||||
labels_resource_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/labels"
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
assert expected_tool in tool_names, (
|
||||
f"Expected deck tool '{expected_tool}' not found in available tools"
|
||||
)
|
||||
logger.info(f"Found expected deck tool: {expected_tool}")
|
||||
logger.info("Found expected deck tool: %s", expected_tool)
|
||||
|
||||
# List available resource templates
|
||||
templates = await nc_mcp_client.list_resource_templates()
|
||||
@@ -40,7 +40,7 @@ async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
assert expected_template in template_uris, (
|
||||
f"Expected deck template '{expected_template}' not found"
|
||||
)
|
||||
logger.info(f"Found expected deck resource template: {expected_template}")
|
||||
logger.info("Found expected deck resource template: %s", expected_template)
|
||||
|
||||
# List available resources
|
||||
resources = await nc_mcp_client.list_resources()
|
||||
@@ -55,7 +55,7 @@ async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
assert expected_resource in resource_uris, (
|
||||
f"Expected deck resource '{expected_resource}' not found"
|
||||
)
|
||||
logger.info(f"Found expected deck resource: {expected_resource}")
|
||||
logger.info("Found expected deck resource: %s", expected_resource)
|
||||
|
||||
|
||||
async def test_deck_board_crud_workflow_mcp(
|
||||
@@ -68,7 +68,7 @@ async def test_deck_board_crud_workflow_mcp(
|
||||
board_color = "0000FF" # Blue
|
||||
|
||||
# 1. Create board via MCP
|
||||
logger.info(f"Creating board via MCP: {board_title}")
|
||||
logger.info("Creating board via MCP: %s", board_title)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_board",
|
||||
{"title": board_title, "color": board_color},
|
||||
@@ -81,7 +81,7 @@ async def test_deck_board_crud_workflow_mcp(
|
||||
created_board_response = json.loads(created_board_json)
|
||||
board_id = created_board_response["id"]
|
||||
|
||||
logger.info(f"Board created via MCP with ID: {board_id}")
|
||||
logger.info("Board created via MCP with ID: %s", board_id)
|
||||
assert created_board_response["title"] == board_title
|
||||
assert created_board_response["color"] == board_color
|
||||
|
||||
@@ -94,7 +94,7 @@ async def test_deck_board_crud_workflow_mcp(
|
||||
logger.info("Board creation verified via direct client")
|
||||
|
||||
# 3. Read board via MCP resource
|
||||
logger.info(f"Reading board via MCP resource: {board_id}")
|
||||
logger.info("Reading board via MCP resource: %s", board_id)
|
||||
read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}")
|
||||
assert len(read_result.contents) == 1, "Expected exactly one content item"
|
||||
read_board_data = json.loads(read_result.contents[0].text)
|
||||
@@ -104,7 +104,7 @@ async def test_deck_board_crud_workflow_mcp(
|
||||
logger.info("Board read via MCP resource successfully")
|
||||
|
||||
# 4. Verify board via direct read of resource
|
||||
logger.info(f"Verifying board via resource read: {board_id}")
|
||||
logger.info("Verifying board via resource read: %s", board_id)
|
||||
# This was already done in step 3, so we'll just log confirmation
|
||||
logger.info("Board structure verified successfully")
|
||||
|
||||
@@ -124,7 +124,7 @@ async def test_deck_board_crud_workflow_mcp(
|
||||
|
||||
# Clean up - delete board
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
logger.info(f"Cleaned up board ID: {board_id}")
|
||||
logger.info("Cleaned up board ID: %s", board_id)
|
||||
|
||||
|
||||
async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSession):
|
||||
@@ -143,7 +143,7 @@ async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSes
|
||||
logger.info("Invalid board creation correctly failed via MCP tool")
|
||||
|
||||
# Test read non-existent board via MCP resource
|
||||
logger.info(f"Testing read non-existent board via MCP resource: {non_existent_id}")
|
||||
logger.info("Testing read non-existent board via MCP resource: %s", non_existent_id)
|
||||
try:
|
||||
read_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{non_existent_id}"
|
||||
@@ -153,7 +153,7 @@ async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSes
|
||||
"Expected empty content for non-existent board"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(f"Read non-existent board correctly failed via MCP resource: {e}")
|
||||
logger.info("Read non-existent board correctly failed via MCP resource: %s", e)
|
||||
|
||||
|
||||
async def test_deck_board_creation_validation_mcp(nc_mcp_client: ClientSession):
|
||||
@@ -185,11 +185,11 @@ async def test_deck_board_creation_success_mcp(
|
||||
assert create_result.isError is False, "Valid board creation should succeed"
|
||||
created_board = json.loads(create_result.content[0].text)
|
||||
board_id = created_board["id"]
|
||||
logger.info(f"Valid board created successfully with ID: {board_id}")
|
||||
logger.info("Valid board created successfully with ID: %s", board_id)
|
||||
|
||||
# Clean up - delete board
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
logger.info(f"Cleaned up board ID: {board_id}")
|
||||
logger.info("Cleaned up board ID: %s", board_id)
|
||||
|
||||
|
||||
async def test_deck_workflow_integration_mcp(
|
||||
@@ -202,7 +202,7 @@ async def test_deck_workflow_integration_mcp(
|
||||
board_title = board_data["title"]
|
||||
|
||||
# 1. Read board via MCP to verify the structure
|
||||
logger.info(f"Reading board via MCP resource: {board_id}")
|
||||
logger.info("Reading board via MCP resource: %s", board_id)
|
||||
read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}")
|
||||
board_mcp_data = json.loads(read_result.contents[0].text)
|
||||
|
||||
@@ -219,7 +219,7 @@ async def test_deck_workflow_integration_mcp(
|
||||
logger.info("Board found in boards list")
|
||||
|
||||
# 3. Verify board data matches via resource (already done in step 1)
|
||||
logger.info(f"Board data verification completed for board: {board_id}")
|
||||
logger.info("Board data verification completed for board: %s", board_id)
|
||||
logger.info("Board structure and data verified successfully")
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ async def test_deck_card_comment_crud_workflow_mcp(
|
||||
assert comment["objectId"] == card_id
|
||||
assert comment["message"] == "Initial comment"
|
||||
assert comment["replyTo"] is None
|
||||
logger.info(f"Created comment ID {comment_id} on card {card_id}")
|
||||
logger.info("Created comment ID %s on card %s", comment_id, card_id)
|
||||
|
||||
# 2. List comments via MCP — verify the new comment is present
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
|
||||
@@ -40,7 +40,7 @@ async def test_search_with_empty_query(nc_mcp_client: ClientSession):
|
||||
# Search with empty query
|
||||
response = await nc_mcp_client.call_tool("nc_notes_search_notes", {"query": ""})
|
||||
|
||||
logger.info(f"Empty search query response: {response}")
|
||||
logger.info("Empty search query response: %s", response)
|
||||
|
||||
# Should return successful response with empty or valid results
|
||||
assert response is not None
|
||||
@@ -54,7 +54,7 @@ async def test_tool_missing_required_parameters(nc_mcp_client: ClientSession):
|
||||
"nc_notes_create_note",
|
||||
{"title": "Test"}, # Missing content and category
|
||||
)
|
||||
logger.info(f"Missing params response: {response}")
|
||||
logger.info("Missing params response: %s", response)
|
||||
|
||||
# Should return error response for missing required parameters
|
||||
assert response is not None
|
||||
@@ -108,7 +108,7 @@ async def test_calendar_missing_calendar_error(nc_mcp_client: ClientSession):
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f"Non-existent calendar response: {response}")
|
||||
logger.info("Non-existent calendar response: %s", response)
|
||||
|
||||
# Should return structured error response
|
||||
assert response is not None
|
||||
@@ -131,7 +131,7 @@ async def test_webdav_read_missing_file_error(nc_mcp_client: ClientSession):
|
||||
"nc_webdav_read_file", {"path": "non-existent-file.txt"}
|
||||
)
|
||||
|
||||
logger.info(f"Missing file response: {response}")
|
||||
logger.info("Missing file response: %s", response)
|
||||
|
||||
# Should return structured error response
|
||||
assert response is not None
|
||||
@@ -154,7 +154,7 @@ async def test_tables_missing_table_error(nc_mcp_client: ClientSession):
|
||||
"nc_tables_get_schema", {"table_id": 999999}
|
||||
)
|
||||
|
||||
logger.info(f"Missing table response: {response}")
|
||||
logger.info("Missing table response: %s", response)
|
||||
|
||||
# Should return structured error response
|
||||
assert response is not None
|
||||
|
||||
+42
-42
@@ -19,7 +19,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
logger.info("Available MCP tools:")
|
||||
tool_names = []
|
||||
for tool in tools.tools:
|
||||
logger.info(f" - {tool.name}: {tool.description}")
|
||||
logger.info(" - %s: %s", tool.name, tool.description)
|
||||
tool_names.append(tool.name)
|
||||
|
||||
# Verify expected tools are present
|
||||
@@ -88,7 +88,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
logger.info("\nAvailable resource templates:")
|
||||
template_uris = []
|
||||
for template in templates.resourceTemplates:
|
||||
logger.info(f" - {template.uriTemplate}")
|
||||
logger.info(" - %s", template.uriTemplate)
|
||||
template_uris.append(template.uriTemplate)
|
||||
|
||||
# Verify expected resource templates
|
||||
@@ -105,7 +105,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
logger.info("\nAvailable resources:")
|
||||
resource_uris = []
|
||||
for resource in resources.resources:
|
||||
logger.info(f" - {resource.uri}: {resource.name}")
|
||||
logger.info(" - %s: %s", resource.uri, resource.name)
|
||||
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
||||
|
||||
# Verify expected resources
|
||||
@@ -126,7 +126,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
prompts = await nc_mcp_client.list_prompts()
|
||||
logger.info("\nAvailable prompts:")
|
||||
for prompt in prompts.prompts:
|
||||
logger.info(f" - {prompt.name}")
|
||||
logger.info(" - %s", prompt.name)
|
||||
|
||||
|
||||
async def test_mcp_notes_crud_workflow(
|
||||
@@ -143,7 +143,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
|
||||
try:
|
||||
# 1. Create note via MCP
|
||||
logger.info(f"Creating note via MCP: {test_title}")
|
||||
logger.info("Creating note via MCP: %s", test_title)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{"title": test_title, "content": test_content, "category": test_category},
|
||||
@@ -157,7 +157,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
note_id = note_data["id"]
|
||||
create_etag = note_data["etag"] # Verify create response includes ETag
|
||||
|
||||
logger.info(f"Note created via MCP with ID: {note_id}, ETag: {create_etag}")
|
||||
logger.info("Note created via MCP with ID: %s, ETag: %s", note_id, create_etag)
|
||||
assert "etag" in note_data, "Create response should include ETag"
|
||||
assert create_etag, "Create ETag should not be empty"
|
||||
|
||||
@@ -170,7 +170,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
assert direct_note["category"] == test_category, "Category mismatch"
|
||||
|
||||
# 3. Read note via MCP
|
||||
logger.info(f"Reading note via MCP: {note_id}")
|
||||
logger.info("Reading note via MCP: %s", note_id)
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_get_note", {"note_id": note_id}
|
||||
)
|
||||
@@ -186,7 +186,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
updated_content = f"Updated content: {test_content}"
|
||||
etag = read_note_data["etag"]
|
||||
|
||||
logger.info(f"Updating note via MCP: {note_id}")
|
||||
logger.info("Updating note via MCP: %s", note_id)
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
@@ -206,7 +206,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
updated_note_data = json.loads(update_result.content[0].text)
|
||||
update_etag = updated_note_data["etag"]
|
||||
|
||||
logger.info(f"Note updated via MCP, new ETag: {update_etag}")
|
||||
logger.info("Note updated via MCP, new ETag: %s", update_etag)
|
||||
assert "etag" in updated_note_data, "Update response should include ETag"
|
||||
assert update_etag, "Update ETag should not be empty"
|
||||
assert update_etag != etag, "ETag should change after update"
|
||||
@@ -218,7 +218,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
|
||||
# 6. Append content via MCP
|
||||
append_content = "\n\nThis is appended content via MCP."
|
||||
logger.info(f"Appending content to note via MCP: {note_id}")
|
||||
logger.info("Appending content to note via MCP: %s", note_id)
|
||||
append_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_append_content", {"note_id": note_id, "content": append_content}
|
||||
)
|
||||
@@ -231,7 +231,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
appended_note_data = json.loads(append_result.content[0].text)
|
||||
append_etag = appended_note_data["etag"]
|
||||
|
||||
logger.info(f"Content appended via MCP, new ETag: {append_etag}")
|
||||
logger.info("Content appended via MCP, new ETag: %s", append_etag)
|
||||
assert "etag" in appended_note_data, "Append response should include ETag"
|
||||
assert append_etag, "Append ETag should not be empty"
|
||||
assert append_etag != update_etag, "ETag should change after append"
|
||||
@@ -241,7 +241,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
assert append_content in appended_direct_note["content"]
|
||||
|
||||
# 8. Search for note via MCP
|
||||
logger.info(f"Searching for note via MCP with query: {unique_suffix}")
|
||||
logger.info("Searching for note via MCP with query: %s", unique_suffix)
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", {"query": unique_suffix}
|
||||
)
|
||||
@@ -250,7 +250,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
f"MCP note search failed: {search_result.content}"
|
||||
)
|
||||
search_notes_text = search_result.content[0].text
|
||||
logger.info(f"Search result text: {search_notes_text}")
|
||||
logger.info("Search result text: %s", search_notes_text)
|
||||
search_response = json.loads(search_notes_text)
|
||||
|
||||
# Expect structured response with Pydantic format
|
||||
@@ -282,7 +282,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
assert found_note["title"] == updated_title
|
||||
|
||||
# 9. Delete note via MCP
|
||||
logger.info(f"Deleting note via MCP: {note_id}")
|
||||
logger.info("Deleting note via MCP: %s", note_id)
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
@@ -297,7 +297,7 @@ async def test_mcp_notes_crud_workflow(
|
||||
pytest.fail("Note should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - note should be deleted
|
||||
logger.info(f"Successfully verified note {note_id} was deleted")
|
||||
logger.info("Successfully verified note %s was deleted", note_id)
|
||||
created_note = None # Mark as cleaned up
|
||||
|
||||
finally:
|
||||
@@ -306,9 +306,9 @@ async def test_mcp_notes_crud_workflow(
|
||||
try:
|
||||
note_data = json.loads(created_note)
|
||||
await nc_client.notes.delete_note(note_data["id"])
|
||||
logger.info(f"Cleaned up note {note_data['id']} after test failure")
|
||||
logger.info("Cleaned up note %s after test failure", note_data["id"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup note: {e}")
|
||||
logger.warning("Failed to cleanup note: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_notes_etag_conflict(
|
||||
@@ -325,7 +325,7 @@ async def test_mcp_notes_etag_conflict(
|
||||
|
||||
try:
|
||||
# 1. Create note via MCP
|
||||
logger.info(f"Creating note for ETag conflict test: {test_title}")
|
||||
logger.info("Creating note for ETag conflict test: %s", test_title)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{"title": test_title, "content": test_content, "category": test_category},
|
||||
@@ -355,7 +355,7 @@ async def test_mcp_notes_etag_conflict(
|
||||
assert new_etag != original_etag, "ETag should have changed after update"
|
||||
|
||||
# 3. Try to update with the stale (original) ETag - this should fail
|
||||
logger.info(f"Attempting update with stale ETag: {original_etag}")
|
||||
logger.info("Attempting update with stale ETag: %s", original_etag)
|
||||
conflict_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
{
|
||||
@@ -382,9 +382,9 @@ async def test_mcp_notes_etag_conflict(
|
||||
if created_note is not None:
|
||||
try:
|
||||
await nc_client.notes.delete_note(created_note["id"])
|
||||
logger.info(f"Cleaned up test note {created_note['id']}")
|
||||
logger.info("Cleaned up test note %s", created_note["id"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup test note: {e}")
|
||||
logger.warning("Failed to cleanup test note: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_webdav_workflow(
|
||||
@@ -400,7 +400,7 @@ async def test_mcp_webdav_workflow(
|
||||
|
||||
try:
|
||||
# 1. Create directory via MCP
|
||||
logger.info(f"Creating directory via MCP: {test_dir}")
|
||||
logger.info("Creating directory via MCP: %s", test_dir)
|
||||
create_dir_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_create_directory", {"path": test_dir}
|
||||
)
|
||||
@@ -415,7 +415,7 @@ async def test_mcp_webdav_workflow(
|
||||
assert test_dir in dir_names, f"Directory {test_dir} not found in root listing"
|
||||
|
||||
# 3. Write file via MCP
|
||||
logger.info(f"Writing file via MCP: {test_file_path}")
|
||||
logger.info("Writing file via MCP: %s", test_file_path)
|
||||
write_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
{
|
||||
@@ -437,7 +437,7 @@ async def test_mcp_webdav_workflow(
|
||||
)
|
||||
|
||||
# 5. Read file via MCP
|
||||
logger.info(f"Reading file via MCP: {test_file_path}")
|
||||
logger.info("Reading file via MCP: %s", test_file_path)
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", {"path": test_file_path}
|
||||
)
|
||||
@@ -458,7 +458,7 @@ async def test_mcp_webdav_workflow(
|
||||
assert direct_content.decode("utf-8") == test_content
|
||||
|
||||
# 7. List directory via MCP
|
||||
logger.info(f"Listing directory via MCP: {test_dir}")
|
||||
logger.info("Listing directory via MCP: %s", test_dir)
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_list_directory", {"path": test_dir}
|
||||
)
|
||||
@@ -467,7 +467,7 @@ async def test_mcp_webdav_workflow(
|
||||
f"MCP directory listing failed: {list_result.content}"
|
||||
)
|
||||
listing_text = list_result.content[0].text
|
||||
logger.info(f"Directory listing response: {listing_text}")
|
||||
logger.info("Directory listing response: %s", listing_text)
|
||||
listing_data = json.loads(listing_text)
|
||||
|
||||
# Extract files from DirectoryListing response
|
||||
@@ -498,17 +498,17 @@ async def test_mcp_webdav_workflow(
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
logger.info(f"Cleaning up test file: {test_file_path}")
|
||||
logger.info("Cleaning up test file: %s", test_file_path)
|
||||
await nc_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", {"path": test_file_path}
|
||||
)
|
||||
|
||||
logger.info(f"Cleaning up test directory: {test_dir}")
|
||||
logger.info("Cleaning up test directory: %s", test_dir)
|
||||
await nc_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", {"path": test_dir}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup WebDAV resources: {e}")
|
||||
logger.warning("Failed to cleanup WebDAV resources: %s", e)
|
||||
|
||||
|
||||
async def test_mcp_resources_access(
|
||||
@@ -576,8 +576,8 @@ async def test_mcp_calendar_workflow(
|
||||
calendars_response = json.loads(calendars_result.content[0].text)
|
||||
|
||||
# Debug output to understand the structure
|
||||
logger.info(f"calendars_response type: {type(calendars_response)}")
|
||||
logger.info(f"calendars_response content: {calendars_response}")
|
||||
logger.info("calendars_response type: %s", type(calendars_response))
|
||||
logger.info("calendars_response content: %s", calendars_response)
|
||||
|
||||
# Expect structured response with Pydantic format
|
||||
assert isinstance(calendars_response, dict), (
|
||||
@@ -600,7 +600,7 @@ async def test_mcp_calendar_workflow(
|
||||
|
||||
# Use the first available calendar
|
||||
calendar_name = calendars_list[0]["name"]
|
||||
logger.info(f"Using calendar: {calendar_name}")
|
||||
logger.info("Using calendar: %s", calendar_name)
|
||||
|
||||
# 2. Create event via MCP
|
||||
from datetime import datetime, timedelta
|
||||
@@ -621,7 +621,7 @@ async def test_mcp_calendar_workflow(
|
||||
"priority": 5,
|
||||
}
|
||||
|
||||
logger.info(f"Creating event via MCP: {test_event_title}")
|
||||
logger.info("Creating event via MCP: %s", test_event_title)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_event", event_data
|
||||
)
|
||||
@@ -634,7 +634,7 @@ async def test_mcp_calendar_workflow(
|
||||
event_uid = created_event_data["uid"]
|
||||
created_event = {"uid": event_uid, "calendar_name": calendar_name}
|
||||
|
||||
logger.info(f"Event created via MCP with UID: {event_uid}")
|
||||
logger.info("Event created via MCP with UID: %s", event_uid)
|
||||
|
||||
# 3. Verify creation via direct NextcloudClient
|
||||
direct_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
@@ -643,7 +643,7 @@ async def test_mcp_calendar_workflow(
|
||||
assert "testing" in direct_event.get("categories", "")
|
||||
|
||||
# 4. Get event via MCP
|
||||
logger.info(f"Getting event via MCP: {event_uid}")
|
||||
logger.info("Getting event via MCP: %s", event_uid)
|
||||
get_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_get_event",
|
||||
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||
@@ -686,8 +686,8 @@ async def test_mcp_calendar_workflow(
|
||||
events_response = json.loads(list_result.content[0].text)
|
||||
|
||||
# Debug output to understand what nc_calendar_list_events returns
|
||||
logger.info(f"list_events result type: {type(events_response)}")
|
||||
logger.info(f"list_events result content: {events_response}")
|
||||
logger.info("list_events result type: %s", type(events_response))
|
||||
logger.info("list_events result content: %s", events_response)
|
||||
|
||||
# Response is now a ListEventsResponse with an "events" field
|
||||
assert isinstance(events_response, dict), "Expected response dict"
|
||||
@@ -748,7 +748,7 @@ async def test_mcp_calendar_workflow(
|
||||
"priority": 1,
|
||||
}
|
||||
|
||||
logger.info(f"Updating event via MCP: {event_uid}")
|
||||
logger.info("Updating event via MCP: %s", event_uid)
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event", update_data
|
||||
)
|
||||
@@ -784,7 +784,7 @@ async def test_mcp_calendar_workflow(
|
||||
assert isinstance(upcoming_events, list), "Expected upcoming events list"
|
||||
|
||||
# 10. Delete event via MCP
|
||||
logger.info(f"Deleting event via MCP: {event_uid}")
|
||||
logger.info("Deleting event via MCP: %s", event_uid)
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_delete_event",
|
||||
{"calendar_name": calendar_name, "event_uid": event_uid},
|
||||
@@ -800,7 +800,7 @@ async def test_mcp_calendar_workflow(
|
||||
pytest.fail("Event should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - event should be deleted
|
||||
logger.info(f"Successfully verified event {event_uid} was deleted")
|
||||
logger.info("Successfully verified event %s was deleted", event_uid)
|
||||
created_event = None # Mark as cleaned up
|
||||
|
||||
except Exception as e:
|
||||
@@ -818,7 +818,7 @@ async def test_mcp_calendar_workflow(
|
||||
created_event["calendar_name"], created_event["uid"]
|
||||
)
|
||||
logger.info(
|
||||
f"Cleaned up event {created_event['uid']} after test failure"
|
||||
"Cleaned up event %s after test failure", created_event["uid"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup event: {e}")
|
||||
logger.warning("Failed to cleanup event: %s", e)
|
||||
|
||||
@@ -53,7 +53,7 @@ async def test_talk_send_and_read_workflow(
|
||||
assert posted["message"] == "Hello from MCP integration test"
|
||||
assert posted["token"] == token
|
||||
posted_id = posted["id"]
|
||||
logger.info(f"Posted message id={posted_id} into token={token}")
|
||||
logger.info("Posted message id=%s into token=%s", posted_id, token)
|
||||
|
||||
# 2. Cross-check via direct client
|
||||
direct_messages, _ = await nc_client.talk.get_messages(token, limit=10)
|
||||
|
||||
@@ -53,16 +53,16 @@ async def search_test_files(nc_client: NextcloudClient):
|
||||
for file_path, content, content_type in test_files:
|
||||
await nc_client.webdav.write_file(file_path, content, content_type)
|
||||
|
||||
logger.info(f"Created {len(test_files)} test files in {test_dir}")
|
||||
logger.info("Created %s test files in %s", len(test_files), test_dir)
|
||||
|
||||
yield test_dir
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
logger.info(f"Cleaned up test directory: {test_dir}")
|
||||
logger.info("Cleaned up test directory: %s", test_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup {test_dir}: {e}")
|
||||
logger.warning("Failed to cleanup %s: %s", test_dir, e)
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_name(
|
||||
@@ -82,7 +82,7 @@ async def test_nc_webdav_find_by_name(
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files matching 'search_%.txt'")
|
||||
logger.info("Found %s files matching 'search_%%.txt'", len(files))
|
||||
|
||||
# Should find at least 3 .txt files
|
||||
assert len(files) >= 3, f"Expected at least 3 .txt files, got {len(files)}"
|
||||
@@ -113,7 +113,7 @@ async def test_nc_webdav_find_by_name_with_limit(
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files with limit=2")
|
||||
logger.info("Found %s files with limit=2", len(files))
|
||||
|
||||
# Should return at most 2 results
|
||||
assert len(files) <= 2, f"Expected at most 2 files, got {len(files)}"
|
||||
@@ -136,7 +136,7 @@ async def test_nc_webdav_find_by_type_images(
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} image files")
|
||||
logger.info("Found %s image files", len(files))
|
||||
|
||||
# Should find at least 2 image files (jpg and png)
|
||||
assert len(files) >= 2, f"Expected at least 2 image files, got {len(files)}"
|
||||
@@ -165,7 +165,7 @@ async def test_nc_webdav_find_by_type_specific(
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} PDF files")
|
||||
logger.info("Found %s PDF files", len(files))
|
||||
|
||||
# Should find at least 1 PDF
|
||||
assert len(files) >= 1, f"Expected at least 1 PDF file, got {len(files)}"
|
||||
@@ -194,7 +194,7 @@ async def test_nc_webdav_search_files_basic(
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} markdown files")
|
||||
logger.info("Found %s markdown files", len(files))
|
||||
|
||||
# Should find at least 2 .md files
|
||||
assert len(files) >= 2, f"Expected at least 2 .md files, got {len(files)}"
|
||||
@@ -222,7 +222,7 @@ async def test_nc_webdav_search_files_combined(
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files matching combined filters")
|
||||
logger.info("Found %s files matching combined filters", len(files))
|
||||
|
||||
# Should find search_test1.txt and search_test2.txt
|
||||
assert len(files) >= 2, f"Expected at least 2 files, got {len(files)}"
|
||||
@@ -255,7 +255,7 @@ async def test_nc_webdav_search_files_with_limit(
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files with limit=3")
|
||||
logger.info("Found %s files with limit=3", len(files))
|
||||
|
||||
# Should return at most 3 results
|
||||
assert len(files) <= 3, f"Expected at most 3 files, got {len(files)}"
|
||||
@@ -318,5 +318,5 @@ async def test_search_result_properties(
|
||||
extended_props = ["file_id", "etag", "size", "content_type", "last_modified"]
|
||||
present_props = [prop for prop in extended_props if prop in file]
|
||||
|
||||
logger.info(f"Search result properties: {list(file.keys())}")
|
||||
logger.info("Search result properties: %s", list(file.keys()))
|
||||
assert len(present_props) > 0, f"Should have at least one of {extended_props}"
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_current_broken_format():
|
||||
assert "+" not in current_format
|
||||
assert "-" not in current_format[-6:] # Check last 6 chars for timezone
|
||||
|
||||
logger.info(f"Current broken format: {current_format}")
|
||||
logger.info("Current broken format: %s", current_format)
|
||||
logger.info(
|
||||
"This format causes MCP validation errors because it lacks timezone information"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user