feat(news): add Nextcloud News app integration
Add full integration for the Nextcloud News (RSS/Atom reader) app: - Add NewsClient with complete CRUD operations for folders, feeds, and items - Add 8 read-only MCP tools for listing/getting folders, feeds, items - Add Pydantic models for News entities with camelCase alias support - Add vector sync support for starred + unread items - Add HTML to Markdown converter using markdownify for better embeddings - Add Docker post-install hook to enable News app - Add 25 unit tests for NewsClient API methods Vector sync indexes starred and unread items, providing a balanced approach that captures important (starred) and current (unread) content without indexing the entire article history. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -480,3 +480,222 @@ def create_mock_table_row_ocs_response(
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# News Mock Response Helpers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def create_mock_news_folders_response(
|
||||
folders: list[dict] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for News folders list.
|
||||
|
||||
Args:
|
||||
folders: List of folder dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with folders data
|
||||
"""
|
||||
if folders is None:
|
||||
folders = []
|
||||
|
||||
return create_mock_response(status_code=200, json_data={"folders": folders})
|
||||
|
||||
|
||||
def create_mock_news_folder_response(
|
||||
folder_id: int = 1,
|
||||
name: str = "Test Folder",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a News folder.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID
|
||||
name: Folder name
|
||||
**kwargs: Additional folder fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with folder data
|
||||
"""
|
||||
folder_data = {
|
||||
"id": folder_id,
|
||||
"name": name,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data={"folders": [folder_data]})
|
||||
|
||||
|
||||
def create_mock_news_feeds_response(
|
||||
feeds: list[dict] | None = None,
|
||||
starred_count: int = 0,
|
||||
newest_item_id: int | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for News feeds list.
|
||||
|
||||
Args:
|
||||
feeds: List of feed dictionaries. If None, returns empty list.
|
||||
starred_count: Number of starred items
|
||||
newest_item_id: ID of newest item
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with feeds data
|
||||
"""
|
||||
if feeds is None:
|
||||
feeds = []
|
||||
|
||||
data = {
|
||||
"feeds": feeds,
|
||||
"starredCount": starred_count,
|
||||
}
|
||||
if newest_item_id is not None:
|
||||
data["newestItemId"] = newest_item_id
|
||||
|
||||
return create_mock_response(status_code=200, json_data=data)
|
||||
|
||||
|
||||
def create_mock_news_feed_response(
|
||||
feed_id: int = 1,
|
||||
url: str = "https://example.com/feed",
|
||||
title: str = "Test Feed",
|
||||
favicon_link: str | None = None,
|
||||
folder_id: int | None = None,
|
||||
unread_count: int = 0,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a News feed.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
url: Feed URL
|
||||
title: Feed title
|
||||
favicon_link: Favicon URL
|
||||
folder_id: Parent folder ID
|
||||
unread_count: Number of unread items
|
||||
**kwargs: Additional feed fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with feed data
|
||||
"""
|
||||
feed_data = {
|
||||
"id": feed_id,
|
||||
"url": url,
|
||||
"title": title,
|
||||
"faviconLink": favicon_link,
|
||||
"folderId": folder_id,
|
||||
"unreadCount": unread_count,
|
||||
"link": kwargs.get("link", "https://example.com"),
|
||||
"added": kwargs.get("added", 1700000000),
|
||||
"updateErrorCount": kwargs.get("updateErrorCount", 0),
|
||||
"lastUpdateError": kwargs.get("lastUpdateError"),
|
||||
**{
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k not in ["link", "added", "updateErrorCount", "lastUpdateError"]
|
||||
},
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data={"feeds": [feed_data]})
|
||||
|
||||
|
||||
def create_mock_news_items_response(
|
||||
items: list[dict] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for News items list.
|
||||
|
||||
Args:
|
||||
items: List of item dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with items data
|
||||
"""
|
||||
if items is None:
|
||||
items = []
|
||||
|
||||
return create_mock_response(status_code=200, json_data={"items": items})
|
||||
|
||||
|
||||
def create_mock_news_item(
|
||||
item_id: int = 1,
|
||||
feed_id: int = 1,
|
||||
title: str = "Test Article",
|
||||
body: str = "<p>Test content</p>",
|
||||
url: str = "https://example.com/article",
|
||||
author: str | None = "Test Author",
|
||||
pub_date: int = 1700000000,
|
||||
unread: bool = True,
|
||||
starred: bool = False,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""Create a mock News item dictionary.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
feed_id: Parent feed ID
|
||||
title: Article title
|
||||
body: Article body (HTML)
|
||||
url: Article URL
|
||||
author: Article author
|
||||
pub_date: Publication timestamp (Unix)
|
||||
unread: Whether item is unread
|
||||
starred: Whether item is starred
|
||||
**kwargs: Additional item fields
|
||||
|
||||
Returns:
|
||||
Item dictionary
|
||||
"""
|
||||
return {
|
||||
"id": item_id,
|
||||
"feedId": feed_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"url": url,
|
||||
"author": author,
|
||||
"pubDate": pub_date,
|
||||
"unread": unread,
|
||||
"starred": starred,
|
||||
"guid": kwargs.get("guid", f"guid-{item_id}"),
|
||||
"guidHash": kwargs.get("guidHash", f"hash-{item_id}"),
|
||||
"lastModified": kwargs.get("lastModified", pub_date * 1000000),
|
||||
"enclosureLink": kwargs.get("enclosureLink"),
|
||||
"enclosureMime": kwargs.get("enclosureMime"),
|
||||
"fingerprint": kwargs.get("fingerprint", f"fp-{item_id}"),
|
||||
"contentHash": kwargs.get("contentHash", f"ch-{item_id}"),
|
||||
**{
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k
|
||||
not in [
|
||||
"guid",
|
||||
"guidHash",
|
||||
"lastModified",
|
||||
"enclosureLink",
|
||||
"enclosureMime",
|
||||
"fingerprint",
|
||||
"contentHash",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_mock_news_status_response(
|
||||
version: str = "25.0.0",
|
||||
warnings: dict | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for News status.
|
||||
|
||||
Args:
|
||||
version: News app version
|
||||
warnings: Warning messages
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with status data
|
||||
"""
|
||||
data = {
|
||||
"version": version,
|
||||
"warnings": warnings or {},
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=data)
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Unit tests for NewsClient API methods."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client.news import NewsClient, NewsItemType
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_news_feed_response,
|
||||
create_mock_news_feeds_response,
|
||||
create_mock_news_folder_response,
|
||||
create_mock_news_folders_response,
|
||||
create_mock_news_item,
|
||||
create_mock_news_items_response,
|
||||
create_mock_news_status_response,
|
||||
create_mock_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Folder Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_get_folders(mocker):
|
||||
"""Test that get_folders correctly parses the API response."""
|
||||
mock_response = create_mock_news_folders_response(
|
||||
folders=[
|
||||
{"id": 1, "name": "Tech"},
|
||||
{"id": 2, "name": "News"},
|
||||
]
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
folders = await client.get_folders()
|
||||
|
||||
assert len(folders) == 2
|
||||
assert folders[0]["id"] == 1
|
||||
assert folders[0]["name"] == "Tech"
|
||||
assert folders[1]["name"] == "News"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/folders")
|
||||
|
||||
|
||||
async def test_news_api_create_folder(mocker):
|
||||
"""Test that create_folder correctly creates a folder."""
|
||||
mock_response = create_mock_news_folder_response(folder_id=3, name="New Folder")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
folder = await client.create_folder(name="New Folder")
|
||||
|
||||
assert folder["id"] == 3
|
||||
assert folder["name"] == "New Folder"
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/folders", json={"name": "New Folder"}
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_rename_folder(mocker):
|
||||
"""Test that rename_folder makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.rename_folder(folder_id=1, name="Renamed")
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"PUT", "/apps/news/api/v1-3/folders/1", json={"name": "Renamed"}
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_delete_folder(mocker):
|
||||
"""Test that delete_folder makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.delete_folder(folder_id=1)
|
||||
|
||||
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/folders/1")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Feed Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_get_feeds(mocker):
|
||||
"""Test that get_feeds correctly parses the API response."""
|
||||
mock_response = create_mock_news_feeds_response(
|
||||
feeds=[
|
||||
{"id": 1, "url": "https://example.com/feed1", "title": "Feed 1"},
|
||||
{"id": 2, "url": "https://example.com/feed2", "title": "Feed 2"},
|
||||
],
|
||||
starred_count=5,
|
||||
newest_item_id=100,
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_feeds()
|
||||
|
||||
assert len(result["feeds"]) == 2
|
||||
assert result["starredCount"] == 5
|
||||
assert result["newestItemId"] == 100
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/feeds")
|
||||
|
||||
|
||||
async def test_news_api_create_feed(mocker):
|
||||
"""Test that create_feed correctly creates a feed."""
|
||||
mock_response = create_mock_news_feed_response(
|
||||
feed_id=10, url="https://example.com/new-feed", title="New Feed"
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
feed = await client.create_feed(url="https://example.com/new-feed")
|
||||
|
||||
assert feed["id"] == 10
|
||||
assert feed["url"] == "https://example.com/new-feed"
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/news/api/v1-3/feeds",
|
||||
json={"url": "https://example.com/new-feed"},
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_create_feed_with_folder(mocker):
|
||||
"""Test that create_feed correctly creates a feed in a folder."""
|
||||
mock_response = create_mock_news_feed_response(
|
||||
feed_id=10, url="https://example.com/feed", folder_id=5
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
feed = await client.create_feed(url="https://example.com/feed", folder_id=5)
|
||||
|
||||
assert feed["folderId"] == 5
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/news/api/v1-3/feeds",
|
||||
json={"url": "https://example.com/feed", "folderId": 5},
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_delete_feed(mocker):
|
||||
"""Test that delete_feed makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.delete_feed(feed_id=10)
|
||||
|
||||
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/feeds/10")
|
||||
|
||||
|
||||
async def test_news_api_move_feed(mocker):
|
||||
"""Test that move_feed makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.move_feed(feed_id=10, folder_id=5)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/feeds/10/move", json={"folderId": 5}
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_rename_feed(mocker):
|
||||
"""Test that rename_feed makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.rename_feed(feed_id=10, title="Renamed Feed")
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/news/api/v1-3/feeds/10/rename",
|
||||
json={"feedTitle": "Renamed Feed"},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_get_items(mocker):
|
||||
"""Test that get_items correctly parses the API response."""
|
||||
items = [
|
||||
create_mock_news_item(item_id=1, title="Article 1"),
|
||||
create_mock_news_item(item_id=2, title="Article 2"),
|
||||
]
|
||||
mock_response = create_mock_news_items_response(items=items)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_items()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["title"] == "Article 1"
|
||||
assert result[1]["title"] == "Article 2"
|
||||
|
||||
# Verify default parameters
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items")
|
||||
params = call_args[1]["params"]
|
||||
assert params["batchSize"] == 50
|
||||
assert params["type"] == NewsItemType.ALL
|
||||
|
||||
|
||||
async def test_news_api_get_items_starred(mocker):
|
||||
"""Test that get_items with STARRED type filters correctly."""
|
||||
items = [create_mock_news_item(item_id=1, starred=True)]
|
||||
mock_response = create_mock_news_items_response(items=items)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_items(type_=NewsItemType.STARRED)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["starred"] is True
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
params = call_args[1]["params"]
|
||||
assert params["type"] == NewsItemType.STARRED
|
||||
|
||||
|
||||
async def test_news_api_get_items_unread_only(mocker):
|
||||
"""Test that get_items with get_read=False filters correctly."""
|
||||
items = [create_mock_news_item(item_id=1, unread=True)]
|
||||
mock_response = create_mock_news_items_response(items=items)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_items(get_read=False)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
params = call_args[1]["params"]
|
||||
assert params["getRead"] == "false"
|
||||
|
||||
|
||||
async def test_news_api_get_updated_items(mocker):
|
||||
"""Test that get_updated_items correctly calls the updated endpoint."""
|
||||
items = [create_mock_news_item(item_id=1)]
|
||||
mock_response = create_mock_news_items_response(items=items)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
result = await client.get_updated_items(last_modified=1700000000)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items/updated")
|
||||
params = call_args[1]["params"]
|
||||
assert params["lastModified"] == 1700000000
|
||||
|
||||
|
||||
async def test_news_api_mark_item_read(mocker):
|
||||
"""Test that mark_item_read makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.mark_item_read(item_id=123)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/123/read"
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_mark_item_unread(mocker):
|
||||
"""Test that mark_item_unread makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.mark_item_unread(item_id=123)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/123/unread"
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_star_item(mocker):
|
||||
"""Test that star_item makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.star_item(item_id=123)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/123/star"
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_unstar_item(mocker):
|
||||
"""Test that unstar_item makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.unstar_item(item_id=123)
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/123/unstar"
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_mark_items_read_multiple(mocker):
|
||||
"""Test that mark_items_read makes the correct API call for multiple items."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.mark_items_read(item_ids=[1, 2, 3])
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/read/multiple", json={"itemIds": [1, 2, 3]}
|
||||
)
|
||||
|
||||
|
||||
async def test_news_api_star_items_multiple(mocker):
|
||||
"""Test that star_items makes the correct API call for multiple items."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
await client.star_items(item_ids=[1, 2, 3])
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/news/api/v1-3/items/star/multiple", json={"itemIds": [1, 2, 3]}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Status Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_get_status(mocker):
|
||||
"""Test that get_status correctly parses the API response."""
|
||||
mock_response = create_mock_news_status_response(
|
||||
version="25.0.0",
|
||||
warnings={"improperlyConfiguredCron": False},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
status = await client.get_status()
|
||||
|
||||
assert status["version"] == "25.0.0"
|
||||
assert "warnings" in status
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/status")
|
||||
|
||||
|
||||
async def test_news_api_get_version(mocker):
|
||||
"""Test that get_version correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200, json_data={"version": "25.0.0"}
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NewsClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
version = await client.get_version()
|
||||
|
||||
assert version == "25.0.0"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/version")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Handling Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_news_api_create_folder_conflict(mocker):
|
||||
"""Test that create_folder raises HTTPStatusError on 409 conflict."""
|
||||
error_response = create_mock_error_response(409, "Folder name already exists")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"409 Conflict",
|
||||
request=httpx.Request("POST", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.create_folder(name="Existing Folder")
|
||||
|
||||
assert excinfo.value.response.status_code == 409
|
||||
|
||||
|
||||
async def test_news_api_delete_feed_not_found(mocker):
|
||||
"""Test that delete_feed raises HTTPStatusError on 404."""
|
||||
error_response = create_mock_error_response(404, "Feed not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("DELETE", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_feed(feed_id=999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
async def test_news_api_create_feed_invalid_url(mocker):
|
||||
"""Test that create_feed raises HTTPStatusError on 422 for invalid URL."""
|
||||
error_response = create_mock_error_response(422, "Invalid feed URL")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"422 Unprocessable Entity",
|
||||
request=httpx.Request("POST", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NewsClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.create_feed(url="not-a-valid-url")
|
||||
|
||||
assert excinfo.value.response.status_code == 422
|
||||
Reference in New Issue
Block a user