"""Tests for journal closeout extraction helpers.""" import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest def _msg(role: str, content: str, kind: str | None = None): """Build a Message-like stand-in for the filter helpers.""" metadata = {"kind": kind} if kind else None return SimpleNamespace(role=role, content=content, msg_metadata=metadata) def _fake_conv(conv_id: int = 1): return SimpleNamespace(id=conv_id) def _patch_db(messages, conv=None): """Build a context manager that fakes async_session() and the two select() calls (conversation lookup + messages query).""" session = AsyncMock() session.execute = AsyncMock() conv_result = MagicMock() conv_result.scalar_one_or_none = MagicMock(return_value=conv) msg_result = MagicMock() scalars = MagicMock() scalars.all = MagicMock(return_value=list(messages)) msg_result.scalars = MagicMock(return_value=scalars) session.execute.side_effect = [conv_result, msg_result] session_ctx = MagicMock() session_ctx.__aenter__ = AsyncMock(return_value=session) session_ctx.__aexit__ = AsyncMock(return_value=None) return session_ctx def test_filter_excludes_daily_prep_messages(): from fabledassistant.services.journal_closeout import _filter_messages msgs = [ _msg("assistant", "Good morning — here is today's plan…", kind="daily_prep"), _msg("user", "I want to focus on the auth refactor today."), _msg("assistant", "Got it. I'll keep tool calls quiet."), ] kept = _filter_messages(msgs) contents = [m.content for m in kept] assert "Good morning — here is today's plan…" not in contents assert "I want to focus on the auth refactor today." in contents assert "Got it. I'll keep tool calls quiet." in contents def test_build_transcript_labels_roles_and_caps_content(): from fabledassistant.services.journal_closeout import _build_transcript msgs = [ _msg("user", "hello"), _msg("assistant", "hi"), _msg("user", "x" * 600), ] out = _build_transcript(msgs) lines = out.splitlines() assert lines[0] == "USER: hello" assert lines[1] == "ASSISTANT: hi" # Third line content truncated to 500 chars assert lines[2].startswith("USER: ") assert len(lines[2]) == len("USER: ") + 500 def test_build_transcript_keeps_only_last_20_messages(): from fabledassistant.services.journal_closeout import _build_transcript msgs = [_msg("user", f"msg-{i}") for i in range(30)] out = _build_transcript(msgs) lines = out.splitlines() assert len(lines) == 20 # Newest 20 means msg-10 through msg-29 assert lines[0] == "USER: msg-10" assert lines[-1] == "USER: msg-29" def test_system_prompt_lists_structured_fields_to_exclude(): """The prompt must explicitly tell the LLM not to restate structured fields, so the freeform learned_summary stays a narrow lane.""" from fabledassistant.services.journal_closeout import SYSTEM_PROMPT text = SYSTEM_PROMPT.lower() for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"): assert field in text, f"system prompt should mention '{field}'" assert "(nothing to note)" in SYSTEM_PROMPT @pytest.mark.asyncio async def test_run_for_user_happy_path_appends_bullets(): from fabledassistant.services import journal_closeout yesterday = datetime.date(2026, 5, 11) msgs = [ _msg("assistant", "Good morning — here's today.", kind="daily_prep"), _msg("user", "Skip the news section — I never read it."), _msg("assistant", "Noted. Will drop it."), ] with ( patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())), patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")), patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="- User skips news section")), patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, ): await journal_closeout.run_for_user(user_id=42, yesterday=yesterday) mock_append.assert_awaited_once_with(42, "- User skips news section") @pytest.mark.asyncio async def test_run_for_user_skips_when_no_conversation(): from fabledassistant.services import journal_closeout with ( patch.object(journal_closeout, "async_session", return_value=_patch_db([], conv=None)), patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm, ): await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11)) mock_append.assert_not_awaited() mock_llm.assert_not_awaited() @pytest.mark.asyncio async def test_run_for_user_skips_when_only_prep_message_after_filter(): """If only the daily_prep message exists, the in-Python filter pass leaves zero messages and we short-circuit before the LLM.""" from fabledassistant.services import journal_closeout msgs_post_sql = [ _msg("assistant", "Daily prep block.", kind="daily_prep"), ] with ( patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs_post_sql, conv=_fake_conv())), patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm, ): await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11)) mock_append.assert_not_awaited() mock_llm.assert_not_awaited() @pytest.mark.asyncio async def test_run_for_user_respects_nothing_to_note_sentinel(): from fabledassistant.services import journal_closeout msgs = [_msg("user", "hi"), _msg("assistant", "hi back")] with ( patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())), patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")), patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="(nothing to note)")), patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, ): await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11)) mock_append.assert_not_awaited() @pytest.mark.asyncio async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch): from fabledassistant.services import journal_scheduler as sched fake_scheduler = MagicMock() fake_scheduler.get_job = MagicMock(return_value=None) monkeypatch.setattr(sched, "_scheduler", fake_scheduler) monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ "prep_enabled": False, # isolate the closeout job "closeout_enabled": True, "day_rollover_hour": 4, })) monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) await sched.update_user_schedule(user_id=7) added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list] assert "journal_closeout_7" in added @pytest.mark.asyncio async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch): from fabledassistant.services import journal_scheduler as sched fake_scheduler = MagicMock() fake_scheduler.get_job = MagicMock(return_value=None) monkeypatch.setattr(sched, "_scheduler", fake_scheduler) monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ "prep_enabled": False, "closeout_enabled": False, "day_rollover_hour": 4, })) monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) await sched.update_user_schedule(user_id=7) added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list] assert "journal_closeout_7" not in added @pytest.mark.asyncio async def test_closeout_catchup_skips_when_already_have_entry(monkeypatch): from fabledassistant.services import journal_scheduler as sched monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ "closeout_enabled": True, "day_rollover_hour": 0, # slot has always passed })) yesterday = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).date() fake_profile = SimpleNamespace(observations_raw=[{"date": yesterday.isoformat(), "bullets": "..."}]) from fabledassistant.services import user_profile as up monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile)) run_mock = AsyncMock() from fabledassistant.services import journal_closeout as jc monkeypatch.setattr(jc, "run_for_user", run_mock) await sched._closeout_catchup(user_id=7) run_mock.assert_not_awaited() @pytest.mark.asyncio async def test_closeout_catchup_runs_when_no_entry_yet(monkeypatch): from fabledassistant.services import journal_scheduler as sched monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ "closeout_enabled": True, "day_rollover_hour": 0, })) fake_profile = SimpleNamespace(observations_raw=[]) from fabledassistant.services import user_profile as up monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile)) run_mock = AsyncMock() from fabledassistant.services import journal_closeout as jc monkeypatch.setattr(jc, "run_for_user", run_mock) await sched._closeout_catchup(user_id=7) run_mock.assert_awaited_once()