diff --git a/src/fabledassistant/services/embeddings.py b/src/fabledassistant/services/embeddings.py index c3c898a..368b3ba 100644 --- a/src/fabledassistant/services/embeddings.py +++ b/src/fabledassistant/services/embeddings.py @@ -133,7 +133,7 @@ async def semantic_search_notes( stmt = ( select(NoteEmbedding, Note) .join(Note, NoteEmbedding.note_id == Note.id) - .where(NoteEmbedding.user_id == user_id) + .where(NoteEmbedding.user_id == user_id, Note.deleted_at.is_(None)) ) if orphan_only: stmt = stmt.where(Note.project_id.is_(None)) diff --git a/src/fabledassistant/services/events.py b/src/fabledassistant/services/events.py index 13fb989..ce85545 100644 --- a/src/fabledassistant/services/events.py +++ b/src/fabledassistant/services/events.py @@ -134,7 +134,9 @@ async def get_event(user_id: int, event_id: int) -> Event | None: """Return event owned by user_id, or None.""" async with async_session() as session: result = await session.execute( - select(Event).where(Event.id == event_id, Event.user_id == user_id) + select(Event).where( + Event.id == event_id, Event.user_id == user_id, Event.deleted_at.is_(None) + ) ) return result.scalar_one_or_none() @@ -164,6 +166,7 @@ async def list_events( select(Event) .where( Event.user_id == user_id, + Event.deleted_at.is_(None), or_( Event.recurrence.isnot(None), Event.start_dt <= date_to, @@ -232,6 +235,7 @@ async def search_events( async with async_session() as session: where = [ Event.user_id == user_id, + Event.deleted_at.is_(None), or_( Event.title.ilike(q), Event.description.ilike(q), @@ -337,6 +341,7 @@ async def find_events_by_query(user_id: int, query: str) -> list[Event]: upcoming = (await session.execute( select(Event).where( Event.user_id == user_id, + Event.deleted_at.is_(None), Event.title.ilike(q), Event.start_dt >= now, ).order_by(Event.start_dt) @@ -346,6 +351,7 @@ async def find_events_by_query(user_id: int, query: str) -> list[Event]: past = (await session.execute( select(Event).where( Event.user_id == user_id, + Event.deleted_at.is_(None), Event.title.ilike(q), Event.start_dt < now, ).order_by(Event.start_dt.desc()) diff --git a/src/fabledassistant/services/milestones.py b/src/fabledassistant/services/milestones.py index ba297e3..3874a14 100644 --- a/src/fabledassistant/services/milestones.py +++ b/src/fabledassistant/services/milestones.py @@ -37,7 +37,10 @@ async def create_milestone( async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None: async with async_session() as session: result = await session.execute( - select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id) + select(Milestone).where( + Milestone.id == milestone_id, Milestone.user_id == user_id, + Milestone.deleted_at.is_(None), + ) ) return result.scalars().first() @@ -93,6 +96,7 @@ async def list_milestones( query = select(Milestone).where( Milestone.user_id == user_id, Milestone.project_id == project_id, + Milestone.deleted_at.is_(None), ) if status: query = query.where(Milestone.status == status) @@ -136,7 +140,10 @@ async def get_milestone_progress(milestone_id: int) -> dict: async with async_session() as session: rows = await session.execute( select(Note.status, func.count(Note.id)) - .where(Note.milestone_id == milestone_id, Note.status.isnot(None)) + .where( + Note.milestone_id == milestone_id, Note.status.isnot(None), + Note.deleted_at.is_(None), + ) .group_by(Note.status) ) status_counts: dict[str, int] = {} diff --git a/src/fabledassistant/services/projects.py b/src/fabledassistant/services/projects.py index 2abcebc..74aabd9 100644 --- a/src/fabledassistant/services/projects.py +++ b/src/fabledassistant/services/projects.py @@ -37,7 +37,10 @@ async def create_project( async def get_project(user_id: int, project_id: int) -> Project | None: async with async_session() as session: result = await session.execute( - select(Project).where(Project.id == project_id, Project.user_id == user_id) + select(Project).where( + Project.id == project_id, Project.user_id == user_id, + Project.deleted_at.is_(None), + ) ) return result.scalars().first() @@ -48,6 +51,7 @@ async def get_project_by_title(user_id: int, title: str) -> Project | None: select(Project).where( Project.user_id == user_id, func.lower(Project.title) == func.lower(title.strip()), + Project.deleted_at.is_(None), ).limit(1) ) return result.scalars().first() @@ -62,7 +66,9 @@ async def get_or_create_project(user_id: int, title: str) -> Project: async def list_projects(user_id: int, status: str | None = None) -> list[Project]: async with async_session() as session: - query = select(Project).where(Project.user_id == user_id) + query = select(Project).where( + Project.user_id == user_id, Project.deleted_at.is_(None) + ) if status: query = query.where(Project.status == status) query = query.order_by(Project.updated_at.desc()) @@ -111,6 +117,7 @@ async def get_project_summary(user_id: int, project_id: int) -> dict: Note.user_id == user_id, Note.project_id == project_id, Note.status.isnot(None), + Note.deleted_at.is_(None), ) .group_by(Note.status) ) @@ -127,6 +134,7 @@ async def get_project_summary(user_id: int, project_id: int) -> dict: Note.user_id == user_id, Note.project_id == project_id, Note.status.is_(None), + Note.deleted_at.is_(None), ) ) note_count = note_count_result or 0 diff --git a/src/fabledassistant/services/rulebooks.py b/src/fabledassistant/services/rulebooks.py index db01acb..fa63f1e 100644 --- a/src/fabledassistant/services/rulebooks.py +++ b/src/fabledassistant/services/rulebooks.py @@ -39,7 +39,7 @@ async def list_rulebooks(user_id: int) -> list[Rulebook]: async with async_session() as session: result = await session.execute( select(Rulebook) - .where(Rulebook.owner_user_id == user_id) + .where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None)) .order_by(Rulebook.title) ) return list(result.scalars().all()) @@ -52,6 +52,7 @@ async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]: select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, + Rulebook.deleted_at.is_(None), ) ) return result.scalar_one_or_none() @@ -105,6 +106,7 @@ async def find_rulebook_by_title( select(Rulebook).where( Rulebook.owner_user_id == user_id, Rulebook.title == title, + Rulebook.deleted_at.is_(None), ) ) return result.scalar_one_or_none() @@ -123,6 +125,7 @@ async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> Non select(Rulebook).where( Rulebook.id == rulebook_id, Rulebook.owner_user_id == user_id, + Rulebook.deleted_at.is_(None), ) ) if result.scalar_one_or_none() is None: @@ -152,7 +155,10 @@ async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]: await _assert_rulebook_owned(session, rulebook_id, user_id) result = await session.execute( select(RulebookTopic) - .where(RulebookTopic.rulebook_id == rulebook_id) + .where( + RulebookTopic.rulebook_id == rulebook_id, + RulebookTopic.deleted_at.is_(None), + ) .order_by(RulebookTopic.order_index, RulebookTopic.title) ) return list(result.scalars().all()) @@ -167,6 +173,8 @@ async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]: .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, + RulebookTopic.deleted_at.is_(None), + Rulebook.deleted_at.is_(None), ) ) return result.scalar_one_or_none() @@ -226,6 +234,8 @@ async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None: .where( RulebookTopic.id == topic_id, Rulebook.owner_user_id == user_id, + RulebookTopic.deleted_at.is_(None), + Rulebook.deleted_at.is_(None), ) ) if result.scalar_one_or_none() is None: @@ -269,7 +279,12 @@ async def list_rules( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) - .where(Rulebook.owner_user_id == user_id) + .where( + Rulebook.owner_user_id == user_id, + Rule.deleted_at.is_(None), + RulebookTopic.deleted_at.is_(None), + Rulebook.deleted_at.is_(None), + ) ) if topic_id: stmt = stmt.where(Rule.topic_id == topic_id) @@ -299,6 +314,9 @@ async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]: .where( Rule.id == rule_id, Rulebook.owner_user_id == user_id, + Rule.deleted_at.is_(None), + RulebookTopic.deleted_at.is_(None), + Rulebook.deleted_at.is_(None), ) ) return result.scalar_one_or_none() @@ -411,6 +429,7 @@ async def get_applicable_rules( .where( project_rulebook_subscriptions.c.project_id == project_id, Rulebook.owner_user_id == user_id, + Rulebook.deleted_at.is_(None), ) .order_by(Rulebook.title) ) @@ -435,6 +454,9 @@ async def get_applicable_rules( .where( project_rulebook_subscriptions.c.project_id == project_id, Rulebook.owner_user_id == user_id, + Rule.deleted_at.is_(None), + RulebookTopic.deleted_at.is_(None), + Rulebook.deleted_at.is_(None), ) .order_by( Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title, diff --git a/tests/test_trash_filtering.py b/tests/test_trash_filtering.py new file mode 100644 index 0000000..feb9061 --- /dev/null +++ b/tests/test_trash_filtering.py @@ -0,0 +1,100 @@ +"""Verify live-read paths exclude trashed rows. + +Strategy: run each service read with a mocked session that CAPTURES the +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 + + +def _capturing_session(captured: list[str]): + s = AsyncMock() + s.__aenter__ = AsyncMock(return_value=s) + s.__aexit__ = AsyncMock(return_value=False) + s.commit = AsyncMock() + s.scalar = AsyncMock(return_value=0) + + async def _exec(stmt, *a, **k): + captured.append(str(stmt)) + r = MagicMock() + r.scalars.return_value.all.return_value = [] + r.scalars.return_value.first.return_value = None + r.scalar_one_or_none.return_value = None + r.first.return_value = None + r.all.return_value = [] + r.fetchall.return_value = [] + return r + + s.execute = AsyncMock(side_effect=_exec) + return s + + +def _has_filter(captured: list[str]) -> bool: + return any("deleted_at IS NULL" in s for s in captured) + + +@pytest.mark.asyncio +async def test_get_note_excludes_trashed(): + cap: list[str] = [] + with patch("fabledassistant.services.notes.async_session") as cls: + cls.return_value = _capturing_session(cap) + from fabledassistant.services.notes import get_note + await get_note(1, 5) + assert _has_filter(cap) + + +@pytest.mark.asyncio +async def test_list_notes_excludes_trashed(): + cap: list[str] = [] + with patch("fabledassistant.services.notes.async_session") as cls: + cls.return_value = _capturing_session(cap) + from fabledassistant.services.notes import list_notes + await list_notes(1) + assert _has_filter(cap) + + +@pytest.mark.asyncio +async def test_list_projects_excludes_trashed(): + cap: list[str] = [] + with patch("fabledassistant.services.projects.async_session") as cls: + cls.return_value = _capturing_session(cap) + from fabledassistant.services.projects import list_projects + await list_projects(1) + assert _has_filter(cap) + + +@pytest.mark.asyncio +async def test_list_events_excludes_trashed(): + cap: list[str] = [] + with patch("fabledassistant.services.events.async_session") as cls: + cls.return_value = _capturing_session(cap) + from fabledassistant.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] = [] + with patch("fabledassistant.services.knowledge.async_session") as cls: + cls.return_value = _capturing_session(cap) + from fabledassistant.services.knowledge import query_knowledge + await query_knowledge(user_id=1, note_type=None, tags=[], sort="modified", q=None, limit=10, offset=0) + assert _has_filter(cap) + + +@pytest.mark.asyncio +async def test_get_applicable_rules_excludes_trashed(): + cap: list[str] = [] + with patch("fabledassistant.services.rulebooks.async_session") as cls: + cls.return_value = _capturing_session(cap) + from fabledassistant.services.rulebooks import get_applicable_rules + await get_applicable_rules(project_id=3, user_id=1) + assert _has_filter(cap)