Files
discord_bot/tests/test_spec_per.py
T

172 lines
7.7 KiB
Python

"""Unit coverage for SPEC-009 persistence (PER-01..05) + CFG-04 (D9)."""
import pickle
import sqlite3
import stat
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
from fjerkroa_bot.persistence import SCHEMA_VERSION, PersistentStore
from .test_bdd_envelope import FakeModelResponder, envelope
from .test_main import TestBotBase
class StoreBase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = Path(self.tmp.name) / "bot.db"
def tearDown(self):
self.tmp.cleanup()
class TestHistoryRoundtrip(StoreBase):
def test_history_survives_restart(self):
"""PER-01: history rows come back per channel, in order, from a fresh store."""
store = PersistentStore(self.db_path)
entries = [{"role": "user", "content": "one"}, {"role": "assistant", "content": "two"}]
store.save_history("chat", entries)
store.save_history("other", [{"role": "user", "content": "elsewhere"}])
reloaded = PersistentStore(self.db_path)
self.assertEqual(reloaded.load_history("chat"), entries)
self.assertEqual(reloaded.load_history("other"), [{"role": "user", "content": "elsewhere"}])
def test_save_replaces_previous_state(self):
"""PER-01: save_history reflects trims — replaced, not appended."""
store = PersistentStore(self.db_path)
store.save_history("chat", [{"role": "user", "content": "a"}, {"role": "user", "content": "b"}])
store.save_history("chat", [{"role": "user", "content": "b"}])
self.assertEqual(store.load_history("chat"), [{"role": "user", "content": "b"}])
class TestMemoryRoundtrip(StoreBase):
def test_memory_survives_restart(self):
"""PER-02: per-channel memory string persists across store instances."""
store = PersistentStore(self.db_path)
store.save_memory("chat", "remember this")
self.assertEqual(PersistentStore(self.db_path).load_memory("chat"), "remember this")
self.assertIsNone(PersistentStore(self.db_path).load_memory("unknown"))
class TestPickleMigration(StoreBase):
def test_pickles_migrate_once(self):
"""PER-03: legacy pickles import once, files renamed *.migrated, no re-import."""
history_file = Path(self.tmp.name) / "chat.dat"
memory_file = Path(self.tmp.name) / "chat.memory"
with open(history_file, "wb") as fd:
pickle.dump([{"role": "user", "content": "old times"}], fd)
with open(memory_file, "wb") as fd:
pickle.dump("old memory", fd)
config = {"system": "s", "history-limit": 5, "history-directory": self.tmp.name}
responder = AIResponder(config, "chat")
self.assertEqual(responder.history, [{"role": "user", "content": "old times"}])
self.assertEqual(responder.memory, "old memory")
self.assertFalse(history_file.exists())
self.assertFalse(memory_file.exists())
self.assertTrue(history_file.with_suffix(".dat.migrated").exists())
# second start reads from the DB, does not re-import
responder2 = AIResponder(config, "chat")
self.assertEqual(responder2.history, [{"role": "user", "content": "old times"}])
class TestDatabaseHygiene(StoreBase):
def test_wal_version_and_permissions(self):
"""PER-04: WAL mode, user_version = current schema version, file mode 0600."""
PersistentStore(self.db_path)
conn = sqlite3.connect(self.db_path)
try:
self.assertEqual(conn.execute("PRAGMA journal_mode").fetchone()[0], "wal")
self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0], SCHEMA_VERSION)
finally:
conn.close()
mode = stat.S_IMODE(self.db_path.stat().st_mode)
self.assertEqual(mode, 0o600)
class TestWritesOffEventLoop(unittest.IsolatedAsyncioTestCase):
async def test_persistence_runs_in_worker_thread(self):
"""PER-05: history writes happen off the event loop (D6)."""
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
config = {"system": "s", "history-limit": 5, "history-directory": tmp.name}
responder = FakeModelResponder(config, "chat")
writing_threads = set()
original_save = responder.store.save_history
def capture_save(channel, history):
writing_threads.add(threading.current_thread())
return original_save(channel, history)
responder.store.save_history = capture_save
responder.scripted.append(envelope(answer="hei", answer_needed=True))
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertTrue(writing_threads)
self.assertNotIn(threading.main_thread(), writing_threads)
# and the data actually landed
self.assertTrue(PersistentStore(Path(tmp.name) / "bot.db").load_history("chat"))
class TestConfigReloadRace(TestBotBase):
async def test_reload_swaps_on_event_loop(self):
"""CFG-04: watchdog thread schedules the config swap via call_soon_threadsafe (D9)."""
new_config = dict(self.config_data)
new_config["history-limit"] = 99
self.bot.load_config = lambda path: new_config
self.bot.loop = MagicMock()
self.bot.on_config_file_changed()
self.bot.loop.call_soon_threadsafe.assert_called_once()
apply_fn = self.bot.loop.call_soon_threadsafe.call_args.args[0]
apply_fn()
self.assertEqual(self.bot.config["history-limit"], 99)
self.assertEqual(self.bot.airesponder.config["history-limit"], 99)
async def test_reload_applies_directly_without_loop(self):
"""CFG-04: before the loop runs, the swap applies synchronously."""
new_config = dict(self.config_data)
new_config["history-limit"] = 42
self.bot.load_config = lambda path: new_config
loop = MagicMock()
loop.call_soon_threadsafe.side_effect = RuntimeError("no running loop")
self.bot.loop = loop
self.bot.on_config_file_changed()
self.assertEqual(self.bot.config["history-limit"], 42)
class TestConfigReloadRenameSafe(unittest.TestCase):
def test_atomic_rename_and_modify_trigger_reload(self):
"""CFG-05: a modified OR a renamed-into-place config fires the reload; unrelated files do not."""
from fjerkroa_bot.discord_bot import ConfigFileHandler
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "kroa.toml"
config.write_text("x = 1\n")
hits = []
handler = ConfigFileHandler(str(config), lambda: hits.append(1))
def evt(is_dir=False, src=None, dest=None):
event = MagicMock()
event.is_directory = is_dir
event.src_path = src if src is not None else ""
event.dest_path = dest if dest is not None else ""
return event
handler.on_modified(evt(src=str(config))) # in-place modify
handler.on_moved(evt(src=str(Path(tmp) / "kroa.toml.tmp"), dest=str(config))) # atomic rename over
handler.on_created(evt(src=str(config))) # write-new
self.assertEqual(len(hits), 3)
handler.on_modified(evt(src=str(Path(tmp) / "other.txt"))) # unrelated file
handler.on_modified(evt(is_dir=True, src=str(config))) # directory event
self.assertEqual(len(hits), 3) # neither fired
# open/close of the config (our own load_config re-reads) must NOT be handled — else a reload loop.
self.assertNotIn("on_opened", vars(ConfigFileHandler))
self.assertNotIn("on_closed", vars(ConfigFileHandler))