"""Unit tests for Pydantic response models."""
import pytest
from nextcloud_mcp_server.models.contacts import (
Contact,
ListContactsResponse,
)
from nextcloud_mcp_server.models.notes import (
CreateNoteResponse,
Note,
NoteSearchResult,
SearchNotesResponse,
)
from nextcloud_mcp_server.models.semantic import (
SamplingSearchResponse,
SemanticSearchResult,
)
from nextcloud_mcp_server.server.calendar import _event_dict_to_summary
from nextcloud_mcp_server.server.contacts import _raw_contact_to_model
@pytest.mark.unit
def test_note_model_creation():
"""Test creating a Note model with required fields."""
note = Note(
id=123,
title="Test Note",
content="# Test Content",
modified=1700000000,
etag="abc123",
)
assert note.id == 123
assert note.title == "Test Note"
assert note.content == "# Test Content"
assert note.category == "" # default value
assert note.favorite is False # default value
assert note.etag == "abc123"
@pytest.mark.unit
def test_note_modified_datetime_property():
"""Test that Note.modified_datetime converts Unix timestamp correctly."""
note = Note(
id=1,
title="Test",
content="Content",
modified=1700000000,
etag="etag",
)
dt = note.modified_datetime
assert dt.year == 2023 # Nov 14, 2023
assert dt.month == 11
@pytest.mark.unit
def test_create_note_response_serialization():
"""Test CreateNoteResponse can serialize to JSON."""
response = CreateNoteResponse(
id=42,
title="New Note",
category="Work",
etag="xyz789",
)
# Test serialization
data = response.model_dump()
assert data["id"] == 42
assert data["title"] == "New Note"
assert data["category"] == "Work"
assert data["etag"] == "xyz789"
@pytest.mark.unit
def test_search_notes_response_wraps_results():
"""Test SearchNotesResponse wraps list of results correctly.
This is critical - FastMCP mangles raw List[Dict] responses,
so we must wrap them in a response model.
"""
results = [
NoteSearchResult(id=1, title="First Note", category="Work"),
NoteSearchResult(id=2, title="Second Note", category="Personal"),
]
response = SearchNotesResponse(
results=results,
query="test query",
total_found=2,
)
# Verify the response structure
assert len(response.results) == 2
assert response.results[0].id == 1
assert response.results[1].title == "Second Note"
assert response.query == "test query"
assert response.total_found == 2
# Verify it serializes correctly
data = response.model_dump()
assert "results" in data
assert isinstance(data["results"], list)
assert len(data["results"]) == 2
assert data["results"][0]["id"] == 1
@pytest.mark.unit
def test_note_search_result_with_score():
"""Test NoteSearchResult with optional score field."""
result = NoteSearchResult(
id=99,
title="Relevant Note",
category="Archive",
score=0.95,
)
assert result.id == 99
assert result.score == 0.95
@pytest.mark.unit
def test_note_search_result_without_score():
"""Test NoteSearchResult without optional score field."""
result = NoteSearchResult(
id=99,
title="Relevant Note",
category="Archive",
)
assert result.id == 99
assert result.score is None
@pytest.mark.unit
def test_sampling_search_response_with_answer():
"""Test SamplingSearchResponse with LLM-generated answer."""
sources = [
SemanticSearchResult(
id=1,
doc_type="note",
title="Python Guide",
category="Development",
excerpt="Use async/await for asynchronous programming",
score=0.92,
chunk_index=0,
total_chunks=3,
),
SemanticSearchResult(
id=2,
doc_type="note",
title="Best Practices",
category="Development",
excerpt="Always use context managers with async operations",
score=0.85,
chunk_index=1,
total_chunks=2,
),
]
response = SamplingSearchResponse(
query="How do I use async in Python?",
generated_answer="Based on Document 1 and Document 2, use async/await for asynchronous programming and always use context managers.",
sources=sources,
total_found=2,
search_method="semantic_sampling",
model_used="claude-3-5-sonnet",
stop_reason="endTurn",
success=True,
)
# Verify the response structure
assert response.query == "How do I use async in Python?"
assert "async/await" in response.generated_answer
assert len(response.sources) == 2
assert response.sources[0].id == 1
assert response.sources[0].score == 0.92
assert response.total_found == 2
assert response.search_method == "semantic_sampling"
assert response.model_used == "claude-3-5-sonnet"
assert response.stop_reason == "endTurn"
assert response.success is True
# Verify it serializes correctly
data = response.model_dump()
assert "query" in data
assert "generated_answer" in data
assert "sources" in data
assert isinstance(data["sources"], list)
assert len(data["sources"]) == 2
assert data["sources"][0]["id"] == 1
assert data["model_used"] == "claude-3-5-sonnet"
@pytest.mark.unit
def test_sampling_search_response_fallback():
"""Test SamplingSearchResponse when sampling fails (fallback mode)."""
sources = [
SemanticSearchResult(
id=1,
doc_type="note",
title="Note 1",
category="Work",
excerpt="Some content",
score=0.75,
chunk_index=0,
total_chunks=1,
)
]
response = SamplingSearchResponse(
query="test query",
generated_answer="[Sampling unavailable: Client does not support sampling]\n\nFound 1 relevant documents. Please review the sources below.",
sources=sources,
total_found=1,
search_method="semantic_sampling_fallback",
model_used=None,
stop_reason=None,
success=True,
)
# Verify fallback behavior
assert "[Sampling unavailable" in response.generated_answer
assert response.search_method == "semantic_sampling_fallback"
assert response.model_used is None
assert response.stop_reason is None
assert len(response.sources) == 1
@pytest.mark.unit
def test_sampling_search_response_no_results():
"""Test SamplingSearchResponse when no documents found."""
response = SamplingSearchResponse(
query="nonexistent topic",
generated_answer="No relevant documents found in your Nextcloud Notes for this query.",
sources=[],
total_found=0,
search_method="semantic_sampling",
success=True,
)
# Verify no results case
assert response.total_found == 0
assert len(response.sources) == 0
assert "No relevant documents" in response.generated_answer
assert response.model_used is None
assert response.stop_reason is None
@pytest.mark.unit
def test_sampling_search_response_serialization():
"""Test SamplingSearchResponse serializes to JSON correctly."""
response = SamplingSearchResponse(
query="test",
generated_answer="Test answer",
sources=[],
total_found=0,
search_method="semantic_sampling",
model_used="claude-3-5-sonnet",
stop_reason="maxTokens",
success=True,
)
data = response.model_dump()
# Check all fields are present
assert data["query"] == "test"
assert data["generated_answer"] == "Test answer"
assert data["sources"] == []
assert data["total_found"] == 0
assert data["search_method"] == "semantic_sampling"
assert data["model_used"] == "claude-3-5-sonnet"
assert data["stop_reason"] == "maxTokens"
assert data["success"] is True
def _map_contact(raw: dict) -> Contact:
"""Thin wrapper around the production mapping function for test readability."""
return _raw_contact_to_model(raw)
@pytest.mark.unit
def test_contact_mapping_preserves_email_birthday_nickname():
"""Test that list_contacts mapping preserves email, birthday, and nickname.
Regression test for PR #574: the original mapping only kept uid, fn, etag
and silently dropped email, birthday, and nickname.
"""
raw_contact = {
"vcard_id": "abc-123",
"getetag": '"etag-val"',
"contact": {
"fullname": "Jane Doe",
"email": "jane@example.com",
"birthday": "1990-05-15",
"nickname": "JD",
},
}
contact = _map_contact(raw_contact)
assert contact.uid == "abc-123"
assert contact.fn == "Jane Doe"
assert contact.etag == '"etag-val"'
assert contact.birthday == "1990-05-15"
assert len(contact.emails) == 1
assert contact.emails[0].value == "jane@example.com"
assert contact.emails[0].type == "email"
assert contact.custom_fields["nickname"] == "JD"
@pytest.mark.unit
def test_contact_mapping_multiple_emails():
"""Test that multiple emails are mapped correctly."""
raw_contact = {
"vcard_id": "def-456",
"contact": {
"fullname": "John Smith",
"email": ["john@work.com", "john@home.com"],
},
}
contact = _map_contact(raw_contact)
assert len(contact.emails) == 2
assert contact.emails[0].value == "john@work.com"
assert contact.emails[1].value == "john@home.com"
@pytest.mark.unit
def test_contact_mapping_missing_optional_fields():
"""Test mapping when email, birthday, and nickname are absent."""
raw_contact = {
"vcard_id": "ghi-789",
"contact": {"fullname": "No Details"},
}
contact = _map_contact(raw_contact)
assert contact.uid == "ghi-789"
assert contact.fn == "No Details"
assert contact.birthday is None
assert contact.emails == []
assert contact.custom_fields == {}
@pytest.mark.unit
def test_list_contacts_response_wraps_contacts():
"""Test ListContactsResponse wraps contacts correctly for MCP output."""
contacts = [
_map_contact(
{
"vcard_id": "a",
"getetag": '"e1"',
"contact": {
"fullname": "Alice",
"email": "alice@test.com",
"birthday": "2000-01-01",
"nickname": "Ali",
},
}
),
]
response = ListContactsResponse(
contacts=contacts, addressbook="personal", total_count=1
)
data = response.model_dump()
assert data["total_count"] == 1
assert len(data["contacts"]) == 1
c = data["contacts"][0]
assert c["birthday"] == "2000-01-01"
assert c["emails"][0]["value"] == "alice@test.com"
assert c["custom_fields"]["nickname"] == "Ali"
# ============= _event_dict_to_summary tests =============
@pytest.mark.unit
def test_event_dict_to_summary_basic():
"""Test basic mapping with all fields populated."""
event = {
"uid": "evt-001",
"title": "Team Standup",
"start_datetime": "2025-07-28T09:00:00",
"end_datetime": "2025-07-28T09:30:00",
"all_day": False,
"location": "Room 42",
"description": "Daily sync",
"categories": ["work", "meeting"],
"status": "CONFIRMED",
"calendar_name": "office",
"calendar_display_name": "Office Calendar",
}
summary = _event_dict_to_summary(event)
assert summary.uid == "evt-001"
assert summary.summary == "Team Standup"
assert summary.start == "2025-07-28T09:00:00"
assert summary.end == "2025-07-28T09:30:00"
assert summary.all_day is False
assert summary.location == "Room 42"
assert summary.description == "Daily sync"
assert summary.categories == ["work", "meeting"]
assert summary.status == "CONFIRMED"
assert summary.calendar_name == "office"
assert summary.calendar_display_name == "Office Calendar"
@pytest.mark.unit
def test_event_dict_to_summary_categories_string():
"""Test that comma-separated category string is split into a list."""
event = {
"uid": "evt-002",
"title": "Review",
"categories": "work, meeting, important",
}
summary = _event_dict_to_summary(event)
assert summary.categories == ["work", "meeting", "important"]
@pytest.mark.unit
def test_event_dict_to_summary_categories_list_passthrough():
"""Test that a list of categories passes through unchanged."""
event = {
"uid": "evt-003",
"title": "Review",
"categories": ["personal", "health"],
}
summary = _event_dict_to_summary(event)
assert summary.categories == ["personal", "health"]
@pytest.mark.unit
def test_event_dict_to_summary_falsy_location_description():
"""Test that empty/falsy location and description are coerced to None."""
event = {
"uid": "evt-004",
"title": "Quick Chat",
"location": "",
"description": "",
}
summary = _event_dict_to_summary(event)
assert summary.location is None
assert summary.description is None
@pytest.mark.unit
def test_event_dict_to_summary_missing_optional_fields():
"""Test mapping with only required fields present."""
event = {"uid": "evt-005", "title": "Minimal Event"}
summary = _event_dict_to_summary(event)
assert summary.uid == "evt-005"
assert summary.summary == "Minimal Event"
assert summary.start == ""
assert summary.end is None
assert summary.all_day is False
assert summary.location is None
assert summary.description is None
assert summary.categories == []
assert summary.status is None
assert summary.calendar_name is None
assert summary.calendar_display_name is None
@pytest.mark.unit
def test_event_dict_to_summary_calendar_name_without_display_name():
"""Test single-calendar path: calendar_name set, display_name absent falls back."""
event = {
"uid": "evt-006",
"title": "Personal Errand",
"calendar_name": "personal",
}
summary = _event_dict_to_summary(event)
assert summary.calendar_name == "personal"
assert summary.calendar_display_name == "personal"