refactor(scribe): retire calendar/events + person/place/list entities (backend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Has been skipped

Narrow Scribe to a Claude-Code work system-of-record (milestone #194,
decision note #1759). Wholesale removal per rule #22 — backend + schema half.

Calendar/events + CalDAV: delete models/event, services/{events,caldav,
caldav_sync}, routes/events, mcp/tools/events; strip event branches from
backup (bump v3->v4), dashboard (upcoming_events), trash, recent, and the
mcp server read-only allowlist + instructions.

Typed entities (person/place/list): delete mcp/tools/entities; drop the
notes.metadata (entity_meta) column from model/service/routes and the
knowledge browse service. note_type STAYS — it also marks 'process' notes.

Scheduler: event_scheduler -> recurrence_scheduler, keeping only the
recurring-task spawn job (drops event reminders + CalDAV sync).

Schema: migration 0069 drops the events table + notes.metadata column +
orphan caldav settings rows (faithful downgrade recreates them).

KEEP: recurrence.py (task recurrence), notifications task reminders, graph
view, and every work surface. Frontend + plugin/docs true-up follow next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
This commit is contained in:
2026-07-19 13:29:14 -04:00
parent f6629d4bcf
commit b49efdcb11
33 changed files with 194 additions and 3453 deletions
-26
View File
@@ -1,26 +0,0 @@
def test_event_model_has_new_columns():
from scribe.models.event import Event
cols = {c.key for c in Event.__table__.columns}
assert "caldav_uid" in cols
assert "color" in cols
def test_event_to_dict_includes_new_fields():
from scribe.models.event import Event
from datetime import datetime, timezone
e = Event(
user_id=1, uid="test-uid", title="Test",
start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc),
caldav_uid="sync-uid", color="#6366f1",
)
d = e.to_dict()
assert d["caldav_uid"] == "sync-uid"
assert d["color"] == "#6366f1"
def test_caldav_create_event_accepts_uid_param():
"""caldav.create_event signature must accept an optional uid param."""
import inspect
from scribe.services.caldav import create_event
sig = inspect.signature(create_event)
assert "uid" in sig.parameters
-67
View File
@@ -1,67 +0,0 @@
"""Route-level tests for the events blueprint.
Full HTTP integration tests require a live DB (not available in unit test
environment). These tests cover structural correctness and the route
module's public interface; ownership enforcement is covered by the service
tests in test_events_service.py.
"""
def test_events_blueprint_registered():
"""events_bp must be importable and have the correct name."""
from scribe.routes.events import events_bp
assert events_bp.name == "events"
assert events_bp.url_prefix == "/api/events"
def test_events_blueprint_has_five_routes():
"""Blueprint must declare routes for GET/POST '' and GET/PATCH/DELETE '/<id>'."""
from scribe.routes.events import events_bp
methods_by_rule: dict[str, set[str]] = {}
for rule in events_bp.deferred_functions:
pass # deferred; inspect via url_map after binding
# Import routes module to confirm all 5 view functions exist
from scribe.routes import events as events_module
assert callable(events_module.list_events)
assert callable(events_module.create_event)
assert callable(events_module.get_event)
assert callable(events_module.update_event)
assert callable(events_module.delete_event)
def test_events_blueprint_registered_in_app():
"""events_bp must be registered in the app factory."""
from scribe.app import create_app
app = create_app()
# Check the blueprint is present in the app's blueprints dict
assert "events" in app.blueprints
def test_events_service_ownership_enforced_on_get():
"""get_event returns None for a different user — route will 404."""
# Ownership is enforced by the service filtering by user_id.
# The service returns None when the event belongs to a different user,
# and the route converts that to a 404 response.
import inspect
from scribe.services import events as events_svc
sig = inspect.signature(events_svc.get_event)
assert "user_id" in sig.parameters
assert "event_id" in sig.parameters
def test_events_service_ownership_enforced_on_update():
"""update_event takes user_id — route passes current user's id."""
import inspect
from scribe.services import events as events_svc
sig = inspect.signature(events_svc.update_event)
assert "user_id" in sig.parameters
assert "event_id" in sig.parameters
def test_events_service_ownership_enforced_on_delete():
"""delete_event takes user_id — route verifies ownership before deleting."""
import inspect
from scribe.services import events as events_svc
sig = inspect.signature(events_svc.delete_event)
assert "user_id" in sig.parameters
assert "event_id" in sig.parameters
-326
View File
@@ -1,326 +0,0 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone
def _make_mock_session():
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
return mock_session
def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
caldav_uid="", color="", duration_minutes=60):
e = MagicMock()
e.id = id
e.user_id = user_id
e.uid = uid
e.title = title
e.caldav_uid = caldav_uid
e.color = color
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
e.duration_minutes = duration_minutes
# end_dt is derived; mirror the property's behavior on the mock so
# service code that reads `event.end_dt` gets a sensible value.
if duration_minutes is None:
e.end_dt = None
else:
from datetime import timedelta
e.end_dt = e.start_dt + timedelta(minutes=duration_minutes)
e.all_day = False
e.description = ""
e.location = ""
e.recurrence = None
e.project_id = None
e.to_dict.return_value = {
"id": id, "uid": uid, "title": title,
"caldav_uid": caldav_uid, "color": color,
"start_dt": e.start_dt.isoformat(),
"end_dt": e.end_dt.isoformat() if e.end_dt else None,
"duration_minutes": duration_minutes,
}
return e
@pytest.mark.asyncio
async def test_create_event_stores_to_db():
mock_session = _make_mock_session()
with patch("scribe.services.events.async_session") as mock_cls, \
patch("scribe.services.events.asyncio.create_task") as mock_task:
mock_cls.return_value = mock_session
from scribe.services.events import create_event
result = await create_event(
user_id=1,
title="Dentist",
start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc),
)
assert mock_session.add.called
assert mock_session.commit.called
# CalDAV push background task should be scheduled
assert mock_task.called
@pytest.mark.asyncio
async def test_find_events_by_query_returns_ilike_results():
mock_event = _make_mock_event(title="Team Meeting")
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from scribe.services.events import find_events_by_query
results = await find_events_by_query(user_id=1, query="meeting")
assert len(results) == 1
assert results[0].title == "Team Meeting"
@pytest.mark.asyncio
async def test_list_events_returns_events_in_range():
mock_event = _make_mock_event()
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from scribe.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 3, 1, tzinfo=timezone.utc),
date_to=datetime(2026, 3, 31, tzinfo=timezone.utc),
)
assert len(results) == 1
@pytest.mark.asyncio
async def test_delete_event_fires_caldav_push_when_uid_set():
mock_event = _make_mock_event(caldav_uid="sync-uid")
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.events.async_session") as mock_cls, \
patch("scribe.services.events.asyncio.create_task") as mock_task:
mock_cls.return_value = mock_session
from scribe.services.events import delete_event
await delete_event(user_id=1, event_id=1)
# Push task fired because caldav_uid is set
assert mock_task.called
@pytest.mark.asyncio
async def test_update_event_fires_caldav_push():
mock_event = _make_mock_event(caldav_uid="sync-uid")
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.events.async_session") as mock_cls, \
patch("scribe.services.events.asyncio.create_task") as mock_task:
mock_cls.return_value = mock_session
from scribe.services.events import update_event
await update_event(user_id=1, event_id=1, title="Updated Title")
assert mock_task.called
# ── Duration-model write-side guarantees (Fable #160) ─────────────────────────
def test_normalize_duration_from_end_dt():
"""end_dt sugar converts to a positive minute count anchored on start."""
from scribe.services.events import _normalize_duration
start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc)
assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90
def test_normalize_duration_zero_is_valid_point_event():
"""end_dt == start_dt → duration 0. The point-with-zero-duration case
is rare but legal (e.g. an instant marker); the duration model treats
it the same as duration None for display purposes."""
from scribe.services.events import _normalize_duration
same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0
def test_normalize_duration_rejects_end_before_start():
"""The exact 2026-04-29 prod failure: end 32 days before start.
The duration model makes this inexpressible at the schema level
via a CHECK constraint, but write-path callers still get a
helpful ValueError if they construct an inconsistent (start, end)
pair via the end_dt sugar."""
from scribe.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="at or after start_dt"):
_normalize_duration(
start_dt=start, end_dt=end_before, duration_minutes=None,
)
def test_normalize_duration_rejects_negative_duration():
"""Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK
constraint at the service boundary so callers get a clean error
rather than a constraint violation from psycopg."""
from scribe.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="must be >= 0"):
_normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15)
def test_normalize_duration_rejects_inconsistent_end_and_duration():
"""If a caller passes both end_dt AND duration_minutes that disagree,
the inconsistency is surfaced rather than silently picking one."""
from scribe.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min
with pytest.raises(ValueError, match="implies 60 minutes"):
_normalize_duration(
start_dt=start, end_dt=end, duration_minutes=30,
)
def test_normalize_duration_none_for_open_ended():
"""Both inputs None → None duration (open-ended event)."""
from scribe.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
assert _normalize_duration(
start_dt=start, end_dt=None, duration_minutes=None,
) is None
@pytest.mark.asyncio
async def test_create_event_rejects_end_before_start():
"""Service-level rejection — same scenario as the prod bug, surfaced
cleanly for tool / route callers via ValueError."""
from scribe.services.events import create_event
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="at or after start_dt"):
await create_event(
user_id=1, title="Bad",
start_dt=start, end_dt=end_before,
)
@pytest.mark.asyncio
async def test_update_event_preserves_duration_when_only_start_changes():
"""Sliding semantics: when the user moves an event by changing only
start_dt, the existing duration_minutes is preserved as-is. The new
effective end_dt slides forward with the start. This is a behavioral
upgrade vs. the old end_dt model, where moving start past the
stored end made the event 'go backward in time'."""
mock_event = _make_mock_event(duration_minutes=60) # start 10:00, end 11:00
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.events.async_session") as mock_cls, \
patch("scribe.services.events.asyncio.create_task"):
mock_cls.return_value = mock_session
from scribe.services.events import update_event
# Move start to 12:00; effective end becomes 13:00 automatically.
result = await update_event(
user_id=1, event_id=1,
start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
)
assert result is not None
# duration_minutes was NOT touched; mock_event still has 60.
assert mock_event.duration_minutes == 60
@pytest.mark.asyncio
async def test_update_event_clearing_end_dt_clears_duration():
"""Passing end_dt=None on update is the documented way to clear the
end (turn a timed event into a point event). The service must
translate that into duration_minutes=None, not leave the prior
value in place."""
mock_event = _make_mock_event(duration_minutes=60)
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.events.async_session") as mock_cls, \
patch("scribe.services.events.asyncio.create_task"):
mock_cls.return_value = mock_session
from scribe.services.events import update_event
await update_event(user_id=1, event_id=1, end_dt=None)
assert mock_event.duration_minutes is None
@pytest.mark.asyncio
async def test_list_events_includes_point_event_in_window():
"""A point event (duration_minutes=None) surfaces when its start
is in the window. Replaces the prior 'corrupt end_dt' regression
test — the duration model can't represent that state, but the
same code path is exercised here for point events."""
mock_event = _make_mock_event(duration_minutes=None)
# Point event in the upcoming window
mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = None
mock_event.to_dict.return_value = {
"id": 1, "title": "Point",
"start_dt": mock_event.start_dt.isoformat(),
"end_dt": None,
"duration_minutes": None,
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from scribe.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
assert len(results) == 1
assert results[0]["id"] == 1
@pytest.mark.asyncio
async def test_list_events_excludes_timed_event_that_already_ended():
"""A timed event whose start + duration is before the window must
NOT surface. Verifies the Python-side refinement actually works
against the coarse SQL prefilter."""
mock_event = _make_mock_event(duration_minutes=60)
# Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past.
mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc)
mock_event.to_dict.return_value = {
"id": 1, "start_dt": mock_event.start_dt.isoformat(),
"end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60,
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("scribe.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from scribe.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
assert results == []
# test_tools_calendar_always_available removed in Phase 8 along with the
# services/tools/ LLM-tool layer. Event CRUD is now exposed via MCP tools
# (see tests/test_mcp_tool_events.py for coverage).
-191
View File
@@ -1,191 +0,0 @@
"""Tests for typed-entity tools (person/place/list).
Focuses on the metadata-shape translations and the entity_meta merge logic,
since that's where bugs would hide."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.entities import (
list_persons, create_person, update_person,
create_place, update_place,
create_list, update_list,
)
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def _fake_note(*, note_type="note", entity_meta=None, **overrides) -> MagicMock:
n = MagicMock()
n.note_type = note_type
n.entity_meta = entity_meta
base = {"id": 1, "title": "t", "note_type": note_type,
"metadata": entity_meta or {}}
base.update(overrides)
n.to_dict.return_value = base
return n
# ─── list ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_persons_calls_knowledge_with_person_type():
mock = AsyncMock(return_value=([{"id": 1, "title": "alice"}], 1))
with patch("scribe.mcp.tools.entities.knowledge_svc.query_knowledge", mock):
out = await list_persons(tag="work")
kwargs = mock.call_args.kwargs
assert kwargs["note_type"] == "person"
assert kwargs["tags"] == ["work"]
assert out["total"] == 1
assert "persons" in out # type-specific plural key
# ─── create: metadata building ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_person_only_includes_provided_fields_in_meta():
"""Empty-string fields must NOT pollute entity_meta."""
fake = _fake_note(note_type="person")
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock):
await create_person(name="Alice", email="a@x.com")
kwargs = mock.call_args.kwargs
assert kwargs["title"] == "Alice"
assert kwargs["note_type"] == "person"
assert kwargs["entity_meta"] == {"email": "a@x.com"}
@pytest.mark.asyncio
async def test_create_person_all_empty_meta_is_none():
"""Service is called with entity_meta=None when no typed fields were given."""
fake = _fake_note(note_type="person")
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock):
await create_person(name="Bob")
assert mock.call_args.kwargs["entity_meta"] is None
@pytest.mark.asyncio
async def test_create_place_categories_into_meta():
fake = _fake_note(note_type="place")
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock):
await create_place(name="Cafe X", address="123 Main", category="coffee")
meta = mock.call_args.kwargs["entity_meta"]
assert meta == {"address": "123 Main", "category": "coffee"}
@pytest.mark.asyncio
async def test_create_list_translates_strings_to_unchecked_items():
fake = _fake_note(note_type="list")
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.entities.notes_svc.create_note", mock):
await create_list(name="shopping", items=["milk", "bread"])
meta = mock.call_args.kwargs["entity_meta"]
assert meta["list_items"] == [
{"text": "milk", "checked": False},
{"text": "bread", "checked": False},
]
# ─── update: meta merge ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_update_person_merges_meta_preserving_other_fields():
"""Updating one field must NOT clobber the others stored in entity_meta."""
existing = _fake_note(
id=5, note_type="person",
entity_meta={"email": "old@x.com", "phone": "555-1234"},
)
get_mock = AsyncMock(return_value=existing)
updated = _fake_note(note_type="person")
update_mock = AsyncMock(return_value=updated)
with patch(
"scribe.mcp.tools.entities.notes_svc.get_note", get_mock,
), patch(
"scribe.mcp.tools.entities.notes_svc.update_note", update_mock,
):
await update_person(person_id=5, email="new@x.com")
new_meta = update_mock.call_args.kwargs["entity_meta"]
assert new_meta == {"email": "new@x.com", "phone": "555-1234"}
@pytest.mark.asyncio
async def test_update_person_no_typed_fields_keeps_meta_unchanged():
existing = _fake_note(
id=5, note_type="person",
entity_meta={"email": "a@x.com"},
)
updated = _fake_note(note_type="person")
with patch(
"scribe.mcp.tools.entities.notes_svc.get_note",
AsyncMock(return_value=existing),
), patch(
"scribe.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated),
) as update_mock:
await update_person(person_id=5, name="New Name")
# entity_meta unchanged, but still passed (service gets the full new dict)
assert update_mock.call_args.kwargs["entity_meta"] == {"email": "a@x.com"}
assert update_mock.call_args.kwargs["title"] == "New Name"
@pytest.mark.asyncio
async def test_update_person_rejects_wrong_type():
"""Trying to update a 'place' as a 'person' must fail."""
wrong_type = _fake_note(id=5, note_type="place")
with patch(
"scribe.mcp.tools.entities.notes_svc.get_note",
AsyncMock(return_value=wrong_type),
):
with pytest.raises(ValueError, match="person 5 not found"):
await update_person(person_id=5, email="x@x.com")
@pytest.mark.asyncio
async def test_update_list_items_empty_list_clears_items():
"""items=[] clears all items; items=None leaves unchanged."""
existing = _fake_note(
id=5, note_type="list",
entity_meta={"list_items": [{"text": "old", "checked": True}]},
)
updated = _fake_note(note_type="list")
with patch(
"scribe.mcp.tools.entities.notes_svc.get_note",
AsyncMock(return_value=existing),
), patch(
"scribe.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated),
) as update_mock:
await update_list(list_id=5, items=[])
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == []
@pytest.mark.asyncio
async def test_update_list_items_none_leaves_items_unchanged():
existing = _fake_note(
id=5, note_type="list",
entity_meta={"list_items": [{"text": "keep", "checked": False}]},
)
updated = _fake_note(note_type="list")
with patch(
"scribe.mcp.tools.entities.notes_svc.get_note",
AsyncMock(return_value=existing),
), patch(
"scribe.mcp.tools.entities.notes_svc.update_note",
AsyncMock(return_value=updated),
) as update_mock:
await update_list(list_id=5, name="renamed")
# Existing items intact in the merged meta
assert update_mock.call_args.kwargs["entity_meta"]["list_items"] == [
{"text": "keep", "checked": False},
]
-158
View File
@@ -1,158 +0,0 @@
"""Tests for fable_*_event tools."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.events import (
list_events, create_event, get_event,
update_event, delete_event,
)
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def _fake_event(**overrides) -> MagicMock:
e = MagicMock()
base = {
"id": 1, "title": "ev", "start_dt": "2026-06-01T10:00:00",
"duration_minutes": 30, "all_day": False,
"location": "", "description": "",
}
base.update(overrides)
e.to_dict.return_value = base
return e
@pytest.mark.asyncio
async def test_list_events_passes_timezone_aware_range():
"""Range must be tz-aware (UTC) and date_to inclusive at end-of-day —
Event.start_dt is tz-aware in the DB; naive comparisons raise TypeError."""
from datetime import timezone
mock = AsyncMock(return_value=[
{"id": 1, "title": "morning standup"},
])
with patch("scribe.mcp.tools.events.events_svc.list_events", mock):
out = await list_events(date_from="2026-06-01", date_to="2026-06-08")
args, _ = mock.call_args
assert args[0] == 7 # user_id
assert args[1] == datetime(2026, 6, 1, tzinfo=timezone.utc)
# date_to is end-of-day inclusive → start of 2026-06-09 (24h past start of 2026-06-08)
assert args[2] == datetime(2026, 6, 9, tzinfo=timezone.utc)
assert out["total"] == 1
@pytest.mark.asyncio
async def test_create_event_combines_date_and_time():
e = _fake_event()
mock = AsyncMock(return_value=e)
with patch("scribe.mcp.tools.events.events_svc.create_event", mock):
await create_event(
title="standup", start_date="2026-06-01", start_time="09:30",
duration_minutes=15,
)
kwargs = mock.call_args.kwargs
assert kwargs["start_dt"] == datetime(2026, 6, 1, 9, 30)
assert kwargs["duration_minutes"] == 15
@pytest.mark.asyncio
async def test_create_event_zero_duration_means_point_event():
"""duration_minutes=0 must map to None at the service layer (NULL = point)."""
e = _fake_event()
mock = AsyncMock(return_value=e)
with patch("scribe.mcp.tools.events.events_svc.create_event", mock):
await create_event(title="x", start_date="2026-06-01")
assert mock.call_args.kwargs["duration_minutes"] is None
@pytest.mark.asyncio
async def test_get_event_raises_when_not_found():
with patch(
"scribe.mcp.tools.events.events_svc.get_event",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="event 999 not found"):
await get_event(event_id=999)
@pytest.mark.asyncio
async def test_update_event_only_sends_non_default_fields():
e = _fake_event()
mock = AsyncMock(return_value=e)
with patch("scribe.mcp.tools.events.events_svc.update_event", mock):
await update_event(event_id=1, title="new title")
args, kwargs = mock.call_args
assert args == (7, 1)
assert kwargs == {"title": "new title"}
@pytest.mark.asyncio
async def test_update_event_duration_minus_one_means_unchanged():
e = _fake_event()
mock = AsyncMock(return_value=e)
with patch("scribe.mcp.tools.events.events_svc.update_event", mock):
await update_event(event_id=1, duration_minutes=-1)
assert "duration_minutes" not in mock.call_args.kwargs
@pytest.mark.asyncio
async def test_update_event_duration_zero_clears_to_point():
"""duration_minutes=0 means "set to point event" (NULL)."""
e = _fake_event()
mock = AsyncMock(return_value=e)
with patch("scribe.mcp.tools.events.events_svc.update_event", mock):
await update_event(event_id=1, duration_minutes=0)
assert mock.call_args.kwargs["duration_minutes"] is None
@pytest.mark.asyncio
async def test_update_event_requires_both_date_and_time_to_move():
e = _fake_event()
mock = AsyncMock(return_value=e)
with patch("scribe.mcp.tools.events.events_svc.update_event", mock):
await update_event(event_id=1, start_date="2026-06-02")
# Only start_date, no start_time → start_dt NOT in fields
assert "start_dt" not in mock.call_args.kwargs
mock.reset_mock()
await update_event(
event_id=1, start_date="2026-06-02", start_time="11:00",
)
assert mock.call_args.kwargs["start_dt"] == datetime(2026, 6, 2, 11)
@pytest.mark.asyncio
async def test_update_event_raises_when_not_found():
with patch(
"scribe.mcp.tools.events.events_svc.update_event",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="event 999 not found"):
await update_event(event_id=999, title="x")
@pytest.mark.asyncio
async def test_delete_event_soft_deletes_and_returns_batch():
with patch(
"scribe.mcp.tools.events.trash_svc.delete",
AsyncMock(return_value="batch-1"),
):
result = await delete_event(event_id=7)
assert result["deleted_batch_id"] == "batch-1"
@pytest.mark.asyncio
async def test_delete_event_raises_when_not_found():
with patch(
"scribe.mcp.tools.events.trash_svc.delete",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="event 999 not found"):
await delete_event(event_id=999)
+6 -6
View File
@@ -1,9 +1,9 @@
"""Unit tests for the v3 backup export contract.
"""Unit tests for the v4 backup export contract.
CI runs pytest with no database, so these cover the parts that don't need one:
the version/coverage constants, the pure join-table row helpers, and the export
dict shape (via a mocked session). Full FK-remapping round-trip is exercised
manually against a real DB (export a backup, confirm rulebooks/events appear).
manually against a real DB (export a backup, confirm rulebooks appear).
"""
from types import SimpleNamespace
from unittest.mock import patch
@@ -13,8 +13,8 @@ import pytest
from scribe.services import backup
def test_backup_version_is_v3():
assert backup.BACKUP_VERSION == 3
def test_backup_version_is_v4():
assert backup.BACKUP_VERSION == 4
def test_not_included_lists_the_known_gaps():
@@ -61,12 +61,12 @@ async def test_export_full_backup_contains_v3_sections():
with patch("scribe.services.backup.async_session", lambda: _CM()):
out = await backup.export_full_backup()
assert out["version"] == 3
assert out["version"] == 4
assert out["scope"] == "full"
assert "api_keys" in out["_not_included"]
# The sections v2 silently dropped must now be present (empty here).
for key in ("rulebooks", "rulebook_topics", "rules",
"rulebook_subscriptions", "rule_suppressions",
"topic_suppressions", "events"):
"topic_suppressions"):
assert key in out, f"missing v3 section: {key}"
assert out[key] == []
-3
View File
@@ -42,14 +42,12 @@ async def test_build_dashboard_composes_sections():
import scribe.services.dashboard as dash
with patch.object(dash, "_active_projects", AsyncMock(return_value=["P"])), \
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
patch.object(dash, "_upcoming_events", AsyncMock(return_value=["evt"])), \
patch.object(dash, "_open_issues", AsyncMock(return_value=["iss"])), \
patch.object(dash, "_week_stats", AsyncMock(return_value={"open_total": 4})):
out = await dash.build_dashboard(user_id=1)
assert out == {
"active_projects": ["P"],
"recently_completed": ["done"],
"upcoming_events": ["evt"],
"open_issues": ["iss"],
"week_stats": {"open_total": 4},
}
@@ -60,7 +58,6 @@ async def test_build_dashboard_isolates_failing_section():
import scribe.services.dashboard as dash
with patch.object(dash, "_active_projects", AsyncMock(side_effect=RuntimeError("db down"))), \
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
patch.object(dash, "_upcoming_events", AsyncMock(return_value=[])), \
patch.object(dash, "_open_issues", AsyncMock(return_value=[])), \
patch.object(dash, "_week_stats", AsyncMock(return_value={})):
out = await dash.build_dashboard(user_id=1)
+2 -2
View File
@@ -39,7 +39,7 @@ async def test_counts_include_process_in_facet_and_total():
assert counts["process"] == 2
# facet keys all present (setdefault)
for key in ("note", "person", "place", "list", "task", "plan", "process"):
for key in ("note", "task", "plan", "process"):
assert key in counts
# total = note(3) + person(0) + place(0) + list(0) + task(1) + process(2)
# total = note(3) + task(1) + process(2)
assert counts["total"] == 6
-15
View File
@@ -5,7 +5,6 @@ compiled SQL of every statement passed to execute, then assert the
'deleted_at IS NULL' filter is present. No real DB.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone
import pytest
@@ -66,20 +65,6 @@ async def test_list_projects_excludes_trashed():
assert _has_filter(cap)
@pytest.mark.asyncio
async def test_list_events_excludes_trashed():
cap: list[str] = []
with patch("scribe.services.events.async_session") as cls:
cls.return_value = _capturing_session(cap)
from scribe.services.events import list_events
await list_events(
1,
datetime(2026, 5, 1, tzinfo=timezone.utc),
datetime(2026, 5, 31, tzinfo=timezone.utc),
)
assert _has_filter(cap)
@pytest.mark.asyncio
async def test_query_knowledge_excludes_trashed():
cap: list[str] = []