CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 15s
Docstrings, section headers and assertion messages drop the record number, milestone or step that introduced the behaviour, and incident accounts are restated as the present-tense failure shape. A dead pointer to a design doc that is not in the repo is removed. Renamed to the property they check: test_the_old_shape_would_be_caught -> test_a_rank_then_scope_query_is_caught test_a_ledger_repeat_no_longer_produces_that_case_at_all -> test_a_ledger_repeat_is_counted_not_withheld test_the_header_no_longer_promises_once_per_session -> test_the_header_explains_the_seen_marker test_the_portal_case_no_longer_stamps_one_canon_onto_a_whole_file -> test_one_canon_is_not_stamped_onto_a_whole_file test_the_repos_own_prose_no_longer_defines_with -> test_the_repos_own_prose_does_not_define_with test_the_original_regression_create_note_with_content -> test_the_real_server_refuses_content_on_create_note Fixture and example ids stay: "note 5" in a supersession chain, "rule 156" in an illustrative question, `#2275` as the hex-versus-citation case, `#3244` as the example of a bare number, fixed dates in date-filter tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""Tests for task lifecycle timestamps and recurrence logic.
|
|
|
|
The note stand-ins come from `fake_note`, not a bare MagicMock. A fresh
|
|
MagicMock is truthy on every attribute, so once `update_note` reads a column
|
|
the stand-in did not set, `verify_with` reads as "this record carries a check"
|
|
and the guard refuses the write. That is the reason fake_note exists — a
|
|
stand-in has to be able to say NO.
|
|
"""
|
|
from datetime import date, datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from tests.helpers import fake_note, make_mock_session
|
|
|
|
|
|
# ── 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 = fake_note()
|
|
mock_note.status = "in_progress"
|
|
mock_note.started_at = None
|
|
mock_note.recurrence_rule = None
|
|
|
|
mock_session = make_mock_session()
|
|
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()
|
|
|
|
with patch("scribe.services.notes.async_session", return_value=mock_session):
|
|
from scribe.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 = fake_note()
|
|
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 = make_mock_session()
|
|
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()
|
|
|
|
with patch("scribe.services.notes.async_session", return_value=mock_session):
|
|
from scribe.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 = fake_note()
|
|
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 = make_mock_session()
|
|
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()
|
|
|
|
with patch("scribe.services.notes.async_session", return_value=mock_session):
|
|
from scribe.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 = fake_note()
|
|
mock_note.status = "in_progress"
|
|
mock_note.started_at = original_start
|
|
mock_note.recurrence_rule = None
|
|
|
|
mock_session = make_mock_session()
|
|
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()
|
|
|
|
with patch("scribe.services.notes.async_session", return_value=mock_session):
|
|
from scribe.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 scribe.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 scribe.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 scribe.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 scribe.services.recurrence import validate_recurrence_rule
|
|
validate_recurrence_rule({"type": "calendar", "unit": "month", "day_of_month": 1})
|
|
|
|
|
|
def test_validate_calendar_annual_valid():
|
|
from scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 scribe.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 = make_mock_session()
|
|
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_child = MagicMock()
|
|
mock_child.id = 2
|
|
mock_child.title = "Change air filter"
|
|
mock_child.body = ""
|
|
|
|
with (
|
|
patch("scribe.services.recurrence.async_session", return_value=mock_session),
|
|
patch(
|
|
"scribe.services.notes.create_note",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_child,
|
|
) as mock_create,
|
|
):
|
|
from scribe.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 executes without error."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
mock_session = make_mock_session()
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = []
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
mock_session.scalar = AsyncMock(return_value=0)
|
|
|
|
with patch("scribe.services.notes.async_session", return_value=mock_session):
|
|
from scribe.services.notes import list_notes
|
|
notes, total = await list_notes(1, status=["todo", "in_progress"], is_task=True)
|
|
|
|
assert total == 0
|
|
assert notes == []
|
|
mock_session.scalar.assert_called_once()
|
|
mock_session.execute.assert_called_once()
|