"""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_title # --------------------------------------------------------------------------- # 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_title helper # --------------------------------------------------------------------------- class TestNotebookTitle: def test_no_project(self): assert notebook_title("chatgpt", None) == "ChatGPT - No Project" def test_no_project_string(self): assert notebook_title("chatgpt", "no-project") == "ChatGPT - No Project" def test_project_with_hyphens(self): assert notebook_title("chatgpt", "my-project") == "ChatGPT - My Project" def test_claude_provider(self): assert notebook_title("claude", "budget-tracker") == "Claude - Budget Tracker" def test_multi_word_project(self): assert notebook_title("claude", "ai-research-notes") == "Claude - Ai Research Notes" # --------------------------------------------------------------------------- # 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_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": "ChatGPT - No Project"}], "has_more": False, } ) nb_id = client.get_or_create_notebook("ChatGPT - No Project") assert nb_id == "nb-existing" 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": "ChatGPT - New Project"} ) nb_id = client.get_or_create_notebook("ChatGPT - New Project") assert nb_id == "nb-new" mock_post.assert_called_once() 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": "Claude - No Project"}], "has_more": False, } ) # Call twice — GET /folders should only happen once client.get_or_create_notebook("Claude - No Project") client.get_or_create_notebook("Claude - No Project") assert mock_get.call_count == 1 # --------------------------------------------------------------------------- # 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")