feat(joplin): add --joplin flag to fetch weekly timesheet note from Joplin
- Add joplin.py with fetch_week_note() that walks Work > Timesheets > YYYY and returns the body of the matching YYYY-WNN note via joppy ClientApi - Add filter_rows_by_date() to parser.py to extract only rows belonging to a specific day based on '# ... YYYY-MM-DD' headings in the document - Update cli.py: input and --joplin are now a mutually exclusive required group; add --token flag with JOPLIN_TOKEN env var fallback; --date is parsed into a real date object used for both output and day filtering - Add joppy as a runtime dependency (lazy-imported in cli.py) - Add tests for filter_rows_by_date and full mocked coverage of joplin.py - Update AGENTS.md with Joplin usage, notebook structure, and test rules The actual Joplin structure has notes directly inside the year notebook (Work > Timesheets > YYYY), not in per-week sub-notebooks as initially assumed. fetch_week_note() reflects this flat structure.
This commit is contained in:
parent
d6689a6c83
commit
ecdd28e8a3
8 changed files with 513 additions and 17 deletions
116
tests/test_joplin.py
Normal file
116
tests/test_joplin.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Tests for the Joplin integration module.
|
||||
|
||||
All tests mock the joppy ClientApi so no live Joplin instance is required.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from timesheets.joplin import _iso_week_label, fetch_week_note
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _iso_week_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsoWeekLabel:
|
||||
@pytest.mark.parametrize(
|
||||
"d, expected",
|
||||
[
|
||||
(date(2026, 5, 22), "2026 - W21"),
|
||||
(date(2026, 5, 18), "2026 - W21"), # Monday of same week
|
||||
(date(2026, 1, 1), "2026 - W01"),
|
||||
(date(2026, 12, 28), "2026 - W53"),
|
||||
],
|
||||
)
|
||||
def test_label(self, d, expected):
|
||||
assert _iso_week_label(d) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_week_note — mocked API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_notebook(id_, title, parent_id=""):
|
||||
nb = MagicMock()
|
||||
nb.id = id_
|
||||
nb.title = title
|
||||
nb.parent_id = parent_id
|
||||
return nb
|
||||
|
||||
|
||||
def _make_note(id_, title, body):
|
||||
note = MagicMock()
|
||||
note.id = id_
|
||||
note.title = title
|
||||
note.body = body
|
||||
return note
|
||||
|
||||
|
||||
def _build_api(note_body: str, week_label: str = "2026 - W21"):
|
||||
"""Return a mocked ClientApi that serves a minimal notebook tree."""
|
||||
api = MagicMock()
|
||||
api.get_all_notebooks.return_value = [
|
||||
_make_notebook("work_id", "Work", parent_id=""),
|
||||
_make_notebook("ts_id", "Timesheets", parent_id="work_id"),
|
||||
_make_notebook("yr_id", "2026", parent_id="ts_id"),
|
||||
]
|
||||
api.get_all_notes.return_value = [
|
||||
_make_note("note_id", week_label, note_body),
|
||||
]
|
||||
return api
|
||||
|
||||
|
||||
class TestFetchWeekNote:
|
||||
def _patch(self, api):
|
||||
return patch("timesheets.joplin.ClientApi", return_value=api)
|
||||
|
||||
def test_returns_note_body(self):
|
||||
api = _build_api("# content")
|
||||
with self._patch(api):
|
||||
result = fetch_week_note("dummy_token", date(2026, 5, 22))
|
||||
assert result == "# content"
|
||||
|
||||
def test_constructs_client_with_token(self):
|
||||
api = _build_api("body")
|
||||
with patch("timesheets.joplin.ClientApi", return_value=api) as mock_cls:
|
||||
fetch_week_note("my_secret_token", date(2026, 5, 22))
|
||||
mock_cls.assert_called_once_with(token="my_secret_token")
|
||||
|
||||
def test_missing_work_notebook_raises(self):
|
||||
api = MagicMock()
|
||||
api.get_all_notebooks.return_value = []
|
||||
with self._patch(api):
|
||||
with pytest.raises(RuntimeError, match="'Work' not found"):
|
||||
fetch_week_note("token", date(2026, 5, 22))
|
||||
|
||||
def test_missing_timesheets_notebook_raises(self):
|
||||
api = MagicMock()
|
||||
api.get_all_notebooks.return_value = [
|
||||
_make_notebook("work_id", "Work", parent_id=""),
|
||||
]
|
||||
with self._patch(api):
|
||||
with pytest.raises(RuntimeError, match="Timesheets"):
|
||||
fetch_week_note("token", date(2026, 5, 22))
|
||||
|
||||
def test_missing_year_notebook_raises(self):
|
||||
api = MagicMock()
|
||||
api.get_all_notebooks.return_value = [
|
||||
_make_notebook("work_id", "Work", parent_id=""),
|
||||
_make_notebook("ts_id", "Timesheets", parent_id="work_id"),
|
||||
]
|
||||
with self._patch(api):
|
||||
with pytest.raises(RuntimeError, match="2026"):
|
||||
fetch_week_note("token", date(2026, 5, 22))
|
||||
|
||||
def test_missing_note_raises(self):
|
||||
api = _build_api("body", week_label="2026 - W21")
|
||||
# Return a note with the wrong title
|
||||
api.get_all_notes.return_value = [_make_note("x", "wrong title", "body")]
|
||||
with self._patch(api):
|
||||
with pytest.raises(RuntimeError, match="note '2026 - W21' not found"):
|
||||
fetch_week_note("token", date(2026, 5, 22))
|
||||
Loading…
Add table
Add a link
Reference in a new issue