Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions src/note_mcp/api/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -864,16 +864,19 @@ async def publish_article(
# Fetch article title (required for both draft_save and PUT)
article_response = await client.get(f"/v3/notes/{article_id}")
article_data = article_response.get("data", {})
# For drafts, title is in note_draft.name; for published, it's in name
article_title = article_data.get("name", "")
if not article_title:
note_draft = article_data.get("note_draft")
if isinstance(note_draft, dict):
article_title = note_draft.get("name", "")
# For drafts, prefer note_draft.body which has full HTML including headings
# data.body may be a stripped/sanitized version
# Prefer note_draft.name over data.name — mirroring the body logic
# below. For published articles with pending draft edits (saved via
# update_article), the new title lives in note_draft.name while
# data.name still holds the stale published title. Reading
# data.name first would silently revert title changes on publish.
# Use `or ""` to handle None values (key exists but value is None)
note_draft = article_data.get("note_draft")
if isinstance(note_draft, dict) and note_draft.get("name"):
article_title = note_draft.get("name") or ""
else:
article_title = article_data.get("name", "")
# For drafts, prefer note_draft.body which has full HTML including headings
# data.body may be a stripped/sanitized version
if isinstance(note_draft, dict) and note_draft.get("body"):
article_body = note_draft.get("body") or ""
else:
Expand Down
65 changes: 65 additions & 0 deletions tests/integration/test_article_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,71 @@ async def test_publish_existing_draft(self) -> None:
assert article.status == ArticleStatus.PUBLISHED
assert article.url == "https://note.com/testuser/n/n1234567890ab"

@pytest.mark.asyncio
async def test_publish_uses_draft_title_for_pending_edits(self) -> None:
"""Publishing a published article with pending draft edits must use note_draft.name.

Regression test: update_article() saves a new title into note_draft.name
(via draft_save), while data.name still holds the stale published title.
publish_article() must prefer note_draft.name — otherwise the title
change is silently reverted on publish (while the body is updated,
because the body logic already prefers note_draft.body).
"""
session = create_mock_session()

# Published article with a pending draft edit: data.name is stale,
# note_draft holds the updated title and body.
mock_get_response: dict[str, Any] = {
"data": {
"id": 123456,
"key": "n1234567890ab",
"name": "Old Published Title",
"body": "<p>old body</p>",
"status": "published",
"note_draft": {
"name": "New Draft Title",
"body": "<p>new body</p>",
},
}
}

mock_put_response: dict[str, Any] = {"data": {"result": True}}

mock_published_article = Article(
id="123456",
key="n1234567890ab",
title="New Draft Title",
body="new body",
status=ArticleStatus.PUBLISHED,
url="https://note.com/testuser/n/n1234567890ab",
)

with (
patch("note_mcp.api.articles.NoteAPIClient") as mock_client_class,
patch("note_mcp.api.articles._resolve_numeric_note_id") as mock_resolve,
patch(
"note_mcp.api.articles.get_article_via_api",
new_callable=AsyncMock,
return_value=mock_published_article,
),
):
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=mock_get_response)
mock_client.put = AsyncMock(return_value=mock_put_response)
mock_resolve.return_value = "123456"

await publish_article(session, article_id="n1234567890ab")

call_args = mock_client.put.call_args
assert call_args is not None
put_payload = call_args[1]["json"]
# Title and body must both come from note_draft (the pending edit)
assert put_payload["name"] == "New Draft Title"
assert put_payload["free_body"] == "<p>new body</p>"

@pytest.mark.asyncio
async def test_publish_new_article(self) -> None:
"""Test publishing a new article directly."""
Expand Down