Files
FabledScribe/tests/test_journal_closeout.py
T
2026-05-12 14:35:57 -04:00

205 lines
8.0 KiB
Python

"""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