From c4e48f90e84103d77d17df3568fbcc97a757eec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=87=E6=B2=BC=E3=81=BB=E3=81=A8=E3=82=8A?= <206570885+katanumahotori@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:58:16 +0900 Subject: [PATCH] fix: publish_article reverts title changes saved via update_article publish_article() read the stale published title from data.name before falling back to note_draft.name. For published articles with pending draft edits (saved by update_article via draft_save), the new title lives in note_draft.name, so the title change was silently lost on publish while the body was correctly taken from note_draft.body. Prefer note_draft.name over data.name, mirroring the existing body logic. Add a regression test reproducing the stale-title scenario. --- src/note_mcp/api/articles.py | 19 +++--- tests/integration/test_article_operations.py | 65 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/note_mcp/api/articles.py b/src/note_mcp/api/articles.py index 0b62c2c..2c9fcd2 100644 --- a/src/note_mcp/api/articles.py +++ b/src/note_mcp/api/articles.py @@ -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: diff --git a/tests/integration/test_article_operations.py b/tests/integration/test_article_operations.py index 24ef7f2..0acc568 100644 --- a/tests/integration/test_article_operations.py +++ b/tests/integration/test_article_operations.py @@ -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": "
old body
", + "status": "published", + "note_draft": { + "name": "New Draft Title", + "body": "new body
", + }, + } + } + + 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"] == "new body
" + @pytest.mark.asyncio async def test_publish_new_article(self) -> None: """Test publishing a new article directly."""