Files
ai-chatexport/tests/test_joplin.py
JesseMarkowitz 304cf4fde4 feat: v0.2.0 — Joplin import, ChatGPT Projects, --project filter
Core features:
- Add `joplin` command: syncs exported Markdown to Joplin via local REST API
- Notebooks auto-created per provider+project (e.g. "ChatGPT - My Project")
- Idempotent: notes updated (not duplicated) on re-run; note ID tracked in manifest
- Add `--project` filter to `export` and `list` commands (substring or 'none')
- Add ChatGPT Projects support via CHATGPT_PROJECT_IDS env var

Config:
- Add JOPLIN_API_TOKEN, JOPLIN_API_URL, JOPLIN_REQUEST_TIMEOUT
- Version now read from importlib.metadata (single source of truth: pyproject.toml)
- Bump version to 0.2.0

Quality:
- Explicit Timeout handling in JoplinClient with actionable error messages
- token validation (validate_token) separate from connectivity (ping)
- Remove debug_auth.py, debug_claude.py, and untracked .har file
- Add *.har to .gitignore (may contain auth cookies/session tokens)
- Update README, CHANGELOG, FUTURE.md to reflect v0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 06:04:03 -05:00

342 lines
13 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_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")