From c92f4944ccc021ca87d49e83ef8915fd10fd3c20 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 22:49:21 -0400 Subject: [PATCH] feat: auto-set started_at/completed_at on task status transitions --- src/fabledassistant/services/notes.py | 19 ++ tests/test_recurrence.py | 291 ++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/test_recurrence.py diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 818c393..111de1f 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -240,6 +240,25 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No elif key == "tags" and isinstance(value, list): value = _normalize_tags(value) setattr(note, key, value) + # Auto-set lifecycle timestamps on status transitions + if "status" in fields: + _now = datetime.now(timezone.utc) + if note.status == TaskStatus.in_progress.value: + if note.started_at is None: + note.started_at = _now + elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value): + note.completed_at = _now + if note.recurrence_rule: + from fabledassistant.services.recurrence import calculate_next_due + base = note.due_date or _now.date() + next_due = calculate_next_due(note.recurrence_rule, base) + note.recurrence_next_spawn_at = datetime( + next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc + ) + elif note.status == TaskStatus.todo.value: + note.started_at = None + note.completed_at = None + note.recurrence_next_spawn_at = None note.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(note) diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py new file mode 100644 index 0000000..0916085 --- /dev/null +++ b/tests/test_recurrence.py @@ -0,0 +1,291 @@ +"""Tests for task lifecycle timestamps and recurrence logic.""" +from datetime import date, datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + + +# ── Timestamp side-effect tests ────────────────────────────────────────────── + +async def test_update_note_sets_started_at_on_in_progress(): + """started_at is set when status transitions to in_progress.""" + mock_note = MagicMock() + mock_note.status = "in_progress" + mock_note.started_at = None + mock_note.recurrence_rule = None + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = mock_note + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.commit = AsyncMock() + mock_session.refresh = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("fabledassistant.services.notes.async_session", return_value=mock_session): + from fabledassistant.services.notes import update_note + await update_note(1, 1, status="in_progress") + + assert mock_note.started_at is not None + + +async def test_update_note_sets_completed_at_on_done(): + """completed_at is set when status transitions to done.""" + mock_note = MagicMock() + mock_note.status = "done" + mock_note.started_at = datetime(2026, 3, 1, tzinfo=timezone.utc) + mock_note.completed_at = None + mock_note.recurrence_rule = None + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = mock_note + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.commit = AsyncMock() + mock_session.refresh = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("fabledassistant.services.notes.async_session", return_value=mock_session): + from fabledassistant.services.notes import update_note + await update_note(1, 1, status="done") + + assert mock_note.completed_at is not None + + +async def test_update_note_clears_timestamps_on_todo(): + """started_at and completed_at are cleared when status resets to todo.""" + mock_note = MagicMock() + mock_note.status = "todo" + mock_note.started_at = datetime(2026, 3, 1, tzinfo=timezone.utc) + mock_note.completed_at = datetime(2026, 3, 15, tzinfo=timezone.utc) + mock_note.recurrence_rule = None + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = mock_note + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.commit = AsyncMock() + mock_session.refresh = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("fabledassistant.services.notes.async_session", return_value=mock_session): + from fabledassistant.services.notes import update_note + await update_note(1, 1, status="todo") + + assert mock_note.started_at is None + assert mock_note.completed_at is None + + +async def test_update_note_preserves_started_at_if_already_set(): + """started_at is not overwritten on a second transition to in_progress.""" + original_start = datetime(2026, 3, 1, tzinfo=timezone.utc) + mock_note = MagicMock() + mock_note.status = "in_progress" + mock_note.started_at = original_start + mock_note.recurrence_rule = None + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.first.return_value = mock_note + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.commit = AsyncMock() + mock_session.refresh = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("fabledassistant.services.notes.async_session", return_value=mock_session): + from fabledassistant.services.notes import update_note + await update_note(1, 1, status="in_progress") + + assert mock_note.started_at == original_start + + +# ── Recurrence rule validation ──────────────────────────────────────────────── + +import pytest + + +def test_validate_interval_rule_valid(): + from fabledassistant.services.recurrence import validate_recurrence_rule + validate_recurrence_rule({"type": "interval", "every": 3, "unit": "month"}) # no error + + +def test_validate_interval_rule_bad_unit(): + from fabledassistant.services.recurrence import validate_recurrence_rule + with pytest.raises(ValueError, match="unit"): + validate_recurrence_rule({"type": "interval", "every": 3, "unit": "fortnight"}) + + +def test_validate_interval_rule_bad_every(): + from fabledassistant.services.recurrence import validate_recurrence_rule + with pytest.raises(ValueError, match="every"): + validate_recurrence_rule({"type": "interval", "every": 0, "unit": "week"}) + + +def test_validate_calendar_monthly_valid(): + from fabledassistant.services.recurrence import validate_recurrence_rule + validate_recurrence_rule({"type": "calendar", "unit": "month", "day_of_month": 1}) + + +def test_validate_calendar_annual_valid(): + from fabledassistant.services.recurrence import validate_recurrence_rule + validate_recurrence_rule( + {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 6} + ) + + +def test_validate_calendar_bad_day(): + from fabledassistant.services.recurrence import validate_recurrence_rule + with pytest.raises(ValueError, match="day_of_month"): + validate_recurrence_rule({"type": "calendar", "unit": "month", "day_of_month": 32}) + + +def test_validate_calendar_annual_missing_month(): + from fabledassistant.services.recurrence import validate_recurrence_rule + with pytest.raises(ValueError, match="month"): + validate_recurrence_rule( + {"type": "calendar", "unit": "year", "day_of_month": 15} + ) + + +def test_validate_unknown_type(): + from fabledassistant.services.recurrence import validate_recurrence_rule + with pytest.raises(ValueError, match="type"): + validate_recurrence_rule({"type": "cron", "every": 1, "unit": "day"}) + + +# ── calculate_next_due ──────────────────────────────────────────────────────── + +def test_calculate_next_due_interval_days(): + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "interval", "every": 7, "unit": "day"} + assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 4, 3) + + +def test_calculate_next_due_interval_weeks(): + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "interval", "every": 2, "unit": "week"} + assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 4, 10) + + +def test_calculate_next_due_interval_months(): + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "interval", "every": 3, "unit": "month"} + assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 6, 1) + + +def test_calculate_next_due_interval_months_end_of_month(): + """Month addition clamps to last day when target month is shorter.""" + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "interval", "every": 1, "unit": "month"} + assert calculate_next_due(rule, date(2026, 1, 31)) == date(2026, 2, 28) + + +def test_calculate_next_due_interval_years(): + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "interval", "every": 1, "unit": "year"} + assert calculate_next_due(rule, date(2026, 3, 27)) == date(2027, 3, 27) + + +def test_calculate_next_due_calendar_monthly_future_day(): + """If day_of_month is later this month, return that date.""" + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "calendar", "unit": "month", "day_of_month": 28} + assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 3, 28) + + +def test_calculate_next_due_calendar_monthly_same_or_past_day(): + """If day_of_month is today or earlier, advance to next month.""" + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "calendar", "unit": "month", "day_of_month": 1} + assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 4, 1) + + +def test_calculate_next_due_calendar_annual_future(): + """Annual date later this year is returned.""" + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 6} + assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 6, 15) + + +def test_calculate_next_due_calendar_annual_past(): + """Annual date already passed this year → next year.""" + from fabledassistant.services.recurrence import calculate_next_due + rule = {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 1} + assert calculate_next_due(rule, date(2026, 3, 27)) == date(2027, 1, 15) + + +# ── spawn_recurring_tasks ───────────────────────────────────────────────────── + +async def test_spawn_recurring_tasks_creates_child(): + from unittest.mock import AsyncMock, MagicMock, patch + + mock_task = MagicMock() + mock_task.id = 1 + mock_task.user_id = 42 + mock_task.title = "Change air filter" + mock_task.body = "" + mock_task.priority = "medium" + mock_task.tags = [] + mock_task.project_id = None + mock_task.milestone_id = None + mock_task.due_date = date(2026, 3, 1) + mock_task.recurrence_rule = {"type": "interval", "every": 3, "unit": "month"} + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [mock_task] + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.get = AsyncMock(return_value=mock_task) + mock_session.commit = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + mock_child = MagicMock() + mock_child.id = 2 + mock_child.title = "Change air filter" + mock_child.body = "" + + with ( + patch("fabledassistant.services.recurrence.async_session", return_value=mock_session), + patch( + "fabledassistant.services.recurrence.create_note", + new_callable=AsyncMock, + return_value=mock_child, + ) as mock_create, + patch("fabledassistant.services.recurrence.upsert_note_embedding"), + ): + from fabledassistant.services.recurrence import spawn_recurring_tasks + count = await spawn_recurring_tasks() + + assert count == 1 + mock_create.assert_called_once() + kwargs = mock_create.call_args[1] + assert kwargs["title"] == "Change air filter" + assert kwargs["due_date"] == date(2026, 6, 1) + assert kwargs["recurrence_rule"] == {"type": "interval", "every": 3, "unit": "month"} + assert kwargs["status"] == "todo" + + +# ── Multi-status filtering ──────────────────────────────────────────────────── + +async def test_list_notes_multi_status_builds_in_clause(): + """list_notes with a list of statuses uses IN rather than equality.""" + from unittest.mock import AsyncMock, MagicMock, patch + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + mock_count_result = MagicMock() + mock_count_result.scalar.return_value = 0 + mock_session.execute = AsyncMock(side_effect=[mock_result, mock_count_result]) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch("fabledassistant.services.notes.async_session", return_value=mock_session): + from fabledassistant.services.notes import list_notes + notes, total = await list_notes(1, status=["todo", "in_progress"], is_task=True) + + assert total == 0 + assert mock_session.execute.call_count == 2