Joplin notebooks now use a two-level hierarchy: AI-ChatGPT / <project> and AI-Claude / <project> instead of a single flat title. Note titles are prefixed with the conversation created_at date (YYYY-MM-DD). Export folders collapse provider/project/year into a single provider/project.year directory. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
435 lines
18 KiB
Python
435 lines
18 KiB
Python
"""Unit tests for src/joplin.py (JoplinClient)."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from src.joplin import JoplinClient, JoplinError, _http_error_message, _timeout_message, notebook_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_client() -> JoplinClient:
|
|
return JoplinClient(base_url="http://localhost:41184", token="test-token")
|
|
|
|
|
|
def _mock_response(json_data=None, text="", status_code=200):
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.text = text
|
|
resp.json.return_value = json_data or {}
|
|
resp.raise_for_status = MagicMock()
|
|
if status_code >= 400:
|
|
resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
|
response=resp
|
|
)
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# notebook_path helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNotebookPath:
|
|
def test_no_project(self):
|
|
assert notebook_path("chatgpt", None) == ("AI-ChatGPT", "No Project")
|
|
|
|
def test_no_project_string(self):
|
|
assert notebook_path("chatgpt", "no-project") == ("AI-ChatGPT", "No Project")
|
|
|
|
def test_project_with_hyphens(self):
|
|
assert notebook_path("chatgpt", "my-project") == ("AI-ChatGPT", "My Project")
|
|
|
|
def test_claude_provider(self):
|
|
assert notebook_path("claude", "budget-tracker") == ("AI-Claude", "Budget Tracker")
|
|
|
|
def test_multi_word_project(self):
|
|
assert notebook_path("claude", "ai-research-notes") == ("AI-Claude", "Ai Research Notes")
|
|
|
|
def test_returns_tuple(self):
|
|
result = notebook_path("chatgpt", "some-project")
|
|
assert isinstance(result, tuple) and len(result) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPing:
|
|
def test_ping_success(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(text="JoplinClipperServer")
|
|
assert client.ping() is True
|
|
|
|
def test_ping_not_joplin(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(text="SomeOtherServer")
|
|
assert client.ping() is False
|
|
|
|
def test_ping_connection_refused(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.side_effect = requests.exceptions.ConnectionError()
|
|
assert client.ping() is False
|
|
|
|
def test_ping_timeout_returns_false(self):
|
|
"""Ping timeout is not an error — Joplin just isn't responding."""
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.side_effect = requests.exceptions.Timeout()
|
|
assert client.ping() is False
|
|
|
|
def test_ping_invalid_url_raises_joplin_error(self):
|
|
"""Non-connection, non-timeout errors (e.g. invalid URL) surface as JoplinError."""
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.side_effect = requests.exceptions.InvalidURL("bad url")
|
|
with pytest.raises(JoplinError):
|
|
client.ping()
|
|
|
|
|
|
class TestValidateToken:
|
|
def test_validate_token_success(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(json_data={"items": [], "has_more": False})
|
|
client.validate_token() # should not raise
|
|
|
|
def test_validate_token_401_raises_joplin_error(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(status_code=401)
|
|
with pytest.raises(JoplinError, match="401"):
|
|
client.validate_token()
|
|
|
|
|
|
class TestTimeoutMessage:
|
|
def test_includes_timeout_duration(self):
|
|
import src.joplin as joplin_module
|
|
msg = _timeout_message("POST", "/notes")
|
|
assert "POST" in msg
|
|
assert "/notes" in msg
|
|
assert str(joplin_module._REQUEST_TIMEOUT) in msg
|
|
|
|
def test_includes_actionable_hints(self):
|
|
msg = _timeout_message("PUT", "/notes/abc")
|
|
assert "JOPLIN_REQUEST_TIMEOUT" in msg
|
|
# Should mention at least one cause
|
|
assert "large" in msg.lower() or "busy" in msg.lower() or "frozen" in msg.lower()
|
|
|
|
|
|
class TestTimeoutHandling:
|
|
def test_get_timeout_raises_joplin_error_with_clear_message(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.side_effect = requests.exceptions.Timeout()
|
|
with pytest.raises(JoplinError) as exc_info:
|
|
client._get("/folders")
|
|
assert "timed out" in str(exc_info.value).lower()
|
|
assert "JOPLIN_REQUEST_TIMEOUT" in str(exc_info.value)
|
|
|
|
def test_post_timeout_raises_joplin_error_with_clear_message(self):
|
|
client = _make_client()
|
|
with patch("requests.post") as mock_post:
|
|
mock_post.side_effect = requests.exceptions.Timeout()
|
|
with pytest.raises(JoplinError) as exc_info:
|
|
client._post("/notes", {"title": "Test"})
|
|
assert "timed out" in str(exc_info.value).lower()
|
|
|
|
def test_put_timeout_raises_joplin_error_with_clear_message(self):
|
|
client = _make_client()
|
|
with patch("requests.put") as mock_put:
|
|
mock_put.side_effect = requests.exceptions.Timeout()
|
|
with pytest.raises(JoplinError) as exc_info:
|
|
client._put("/notes/abc", {"title": "Test"})
|
|
assert "timed out" in str(exc_info.value).lower()
|
|
|
|
def test_create_note_timeout_propagates(self):
|
|
"""Timeout on create_note surfaces as JoplinError, not raw requests exception."""
|
|
client = _make_client()
|
|
with patch("requests.post") as mock_post:
|
|
mock_post.side_effect = requests.exceptions.Timeout()
|
|
with pytest.raises(JoplinError, match="timed out"):
|
|
client.create_note("Big Note", "x" * 100_000, "nb-123")
|
|
|
|
def test_update_note_timeout_propagates(self):
|
|
client = _make_client()
|
|
with patch("requests.put") as mock_put:
|
|
mock_put.side_effect = requests.exceptions.Timeout()
|
|
with pytest.raises(JoplinError, match="timed out"):
|
|
client.update_note("note-id", "Big Note", "x" * 100_000)
|
|
|
|
|
|
class TestHttpErrorMessage:
|
|
def test_401_gives_token_hint(self):
|
|
resp = MagicMock()
|
|
resp.status_code = 401
|
|
resp.text = "Unauthorized"
|
|
e = requests.exceptions.HTTPError(response=resp)
|
|
msg = _http_error_message("GET", "/folders", e)
|
|
assert "401" in msg
|
|
assert "token" in msg.lower()
|
|
|
|
def test_404_gives_deleted_note_hint(self):
|
|
resp = MagicMock()
|
|
resp.status_code = 404
|
|
resp.text = "Not Found"
|
|
e = requests.exceptions.HTTPError(response=resp)
|
|
msg = _http_error_message("PUT", "/notes/abc", e)
|
|
assert "404" in msg
|
|
assert "deleted" in msg.lower()
|
|
|
|
def test_other_error_includes_status_and_body(self):
|
|
resp = MagicMock()
|
|
resp.status_code = 500
|
|
resp.text = "Internal Server Error"
|
|
e = requests.exceptions.HTTPError(response=resp)
|
|
msg = _http_error_message("POST", "/notes", e)
|
|
assert "500" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_notebooks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListNotebooks:
|
|
def test_list_notebooks_single_page(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(
|
|
json_data={"items": [{"id": "nb1", "title": "ChatGPT - No Project"}], "has_more": False}
|
|
)
|
|
result = client.list_notebooks()
|
|
assert len(result) == 1
|
|
assert result[0]["id"] == "nb1"
|
|
|
|
def test_list_notebooks_paginated(self):
|
|
client = _make_client()
|
|
page1 = _mock_response(
|
|
json_data={"items": [{"id": "nb1", "title": "A"}], "has_more": True}
|
|
)
|
|
page2 = _mock_response(
|
|
json_data={"items": [{"id": "nb2", "title": "B"}], "has_more": False}
|
|
)
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.side_effect = [page1, page2]
|
|
result = client.list_notebooks()
|
|
assert len(result) == 2
|
|
assert {nb["id"] for nb in result} == {"nb1", "nb2"}
|
|
|
|
def test_list_notebooks_connection_error(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.side_effect = requests.exceptions.ConnectionError()
|
|
with pytest.raises(JoplinError, match="Joplin"):
|
|
client.list_notebooks()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_or_create_notebook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetOrCreateNotebook:
|
|
def test_returns_existing_root_notebook_id(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(
|
|
json_data={
|
|
"items": [{"id": "nb-existing", "title": "AI-ChatGPT", "parent_id": ""}],
|
|
"has_more": False,
|
|
}
|
|
)
|
|
nb_id = client.get_or_create_notebook("AI-ChatGPT")
|
|
assert nb_id == "nb-existing"
|
|
|
|
def test_returns_existing_child_notebook_id(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(
|
|
json_data={
|
|
"items": [{"id": "nb-child", "title": "No Project", "parent_id": "nb-parent"}],
|
|
"has_more": False,
|
|
}
|
|
)
|
|
nb_id = client.get_or_create_notebook("No Project", parent_id="nb-parent")
|
|
assert nb_id == "nb-child"
|
|
|
|
def test_creates_new_notebook_when_not_found(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
|
|
mock_get.return_value = _mock_response(
|
|
json_data={"items": [], "has_more": False}
|
|
)
|
|
mock_post.return_value = _mock_response(
|
|
json_data={"id": "nb-new", "title": "AI-ChatGPT"}
|
|
)
|
|
nb_id = client.get_or_create_notebook("AI-ChatGPT")
|
|
assert nb_id == "nb-new"
|
|
mock_post.assert_called_once()
|
|
|
|
def test_creates_child_notebook_with_parent_id(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
|
|
mock_get.return_value = _mock_response(
|
|
json_data={"items": [], "has_more": False}
|
|
)
|
|
mock_post.return_value = _mock_response(
|
|
json_data={"id": "nb-child", "title": "My Project"}
|
|
)
|
|
nb_id = client.get_or_create_notebook("My Project", parent_id="nb-parent")
|
|
assert nb_id == "nb-child"
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs["json"]["parent_id"] == "nb-parent"
|
|
|
|
def test_does_not_include_parent_id_for_root(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
|
|
mock_get.return_value = _mock_response(json_data={"items": [], "has_more": False})
|
|
mock_post.return_value = _mock_response(json_data={"id": "nb-root", "title": "AI-Claude"})
|
|
client.get_or_create_notebook("AI-Claude")
|
|
_, kwargs = mock_post.call_args
|
|
assert "parent_id" not in kwargs["json"]
|
|
|
|
def test_caches_notebook_after_first_load(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(
|
|
json_data={
|
|
"items": [{"id": "nb1", "title": "AI-Claude", "parent_id": ""}],
|
|
"has_more": False,
|
|
}
|
|
)
|
|
# Call twice — GET /folders should only happen once
|
|
client.get_or_create_notebook("AI-Claude")
|
|
client.get_or_create_notebook("AI-Claude")
|
|
assert mock_get.call_count == 1
|
|
|
|
def test_different_parent_ids_are_distinct_cache_entries(self):
|
|
"""Same title under different parents are different notebooks."""
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get:
|
|
mock_get.return_value = _mock_response(
|
|
json_data={
|
|
"items": [
|
|
{"id": "nb-a", "title": "No Project", "parent_id": "parent-chatgpt"},
|
|
{"id": "nb-b", "title": "No Project", "parent_id": "parent-claude"},
|
|
],
|
|
"has_more": False,
|
|
}
|
|
)
|
|
id_a = client.get_or_create_notebook("No Project", parent_id="parent-chatgpt")
|
|
id_b = client.get_or_create_notebook("No Project", parent_id="parent-claude")
|
|
assert id_a == "nb-a"
|
|
assert id_b == "nb-b"
|
|
|
|
|
|
class TestGetOrCreateNotebookPath:
|
|
def test_creates_two_level_path(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
|
|
mock_get.return_value = _mock_response(json_data={"items": [], "has_more": False})
|
|
mock_post.side_effect = [
|
|
_mock_response(json_data={"id": "nb-parent", "title": "AI-ChatGPT"}),
|
|
_mock_response(json_data={"id": "nb-child", "title": "No Project"}),
|
|
]
|
|
leaf_id = client.get_or_create_notebook_path(["AI-ChatGPT", "No Project"])
|
|
assert leaf_id == "nb-child"
|
|
assert mock_post.call_count == 2
|
|
# Second POST should use the parent's ID
|
|
_, kwargs = mock_post.call_args_list[1]
|
|
assert kwargs["json"]["parent_id"] == "nb-parent"
|
|
|
|
def test_reuses_existing_parent_for_new_child(self):
|
|
client = _make_client()
|
|
with patch("requests.get") as mock_get, patch("requests.post") as mock_post:
|
|
mock_get.return_value = _mock_response(
|
|
json_data={
|
|
"items": [{"id": "nb-parent", "title": "AI-Claude", "parent_id": ""}],
|
|
"has_more": False,
|
|
}
|
|
)
|
|
mock_post.return_value = _mock_response(
|
|
json_data={"id": "nb-child", "title": "Budget Tracker"}
|
|
)
|
|
leaf_id = client.get_or_create_notebook_path(["AI-Claude", "Budget Tracker"])
|
|
assert leaf_id == "nb-child"
|
|
# Only one POST — the parent already existed
|
|
assert mock_post.call_count == 1
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs["json"]["parent_id"] == "nb-parent"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_note
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateNote:
|
|
def test_create_note_returns_id(self):
|
|
client = _make_client()
|
|
with patch("requests.post") as mock_post:
|
|
mock_post.return_value = _mock_response(
|
|
json_data={"id": "note-123", "title": "My Note"}
|
|
)
|
|
note_id = client.create_note("My Note", "Note body", "nb-456")
|
|
assert note_id == "note-123"
|
|
_, kwargs = mock_post.call_args
|
|
assert kwargs["json"]["title"] == "My Note"
|
|
assert kwargs["json"]["body"] == "Note body"
|
|
assert kwargs["json"]["parent_id"] == "nb-456"
|
|
|
|
def test_create_note_connection_error(self):
|
|
client = _make_client()
|
|
with patch("requests.post") as mock_post:
|
|
mock_post.side_effect = requests.exceptions.ConnectionError()
|
|
with pytest.raises(JoplinError, match="Joplin"):
|
|
client.create_note("Title", "Body", "nb-id")
|
|
|
|
def test_create_note_http_error(self):
|
|
client = _make_client()
|
|
with patch("requests.post") as mock_post:
|
|
mock_post.return_value = _mock_response(status_code=401)
|
|
with pytest.raises(JoplinError):
|
|
client.create_note("Title", "Body", "nb-id")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_note
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateNote:
|
|
def test_update_note_calls_put(self):
|
|
client = _make_client()
|
|
with patch("requests.put") as mock_put:
|
|
mock_put.return_value = _mock_response(json_data={"id": "note-123"})
|
|
client.update_note("note-123", "New Title", "New Body")
|
|
mock_put.assert_called_once()
|
|
_, kwargs = mock_put.call_args
|
|
assert kwargs["json"]["title"] == "New Title"
|
|
assert kwargs["json"]["body"] == "New Body"
|
|
|
|
def test_update_note_connection_error(self):
|
|
client = _make_client()
|
|
with patch("requests.put") as mock_put:
|
|
mock_put.side_effect = requests.exceptions.ConnectionError()
|
|
with pytest.raises(JoplinError, match="Joplin"):
|
|
client.update_note("note-id", "Title", "Body")
|
|
|
|
def test_update_note_http_error(self):
|
|
client = _make_client()
|
|
with patch("requests.put") as mock_put:
|
|
mock_put.return_value = _mock_response(status_code=404)
|
|
with pytest.raises(JoplinError):
|
|
client.update_note("note-id", "Title", "Body")
|