odoo-timesheets/tests/test_joplin.py
Jef Roosens ecdd28e8a3
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.
2026-06-02 09:31:02 +02:00

116 lines
3.8 KiB
Python

"""
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))