"""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() event = MagicMock() event.src_path = self.bot.config_file self.bot.on_config_file_modified(event) 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 event = MagicMock() event.src_path = self.bot.config_file self.bot.on_config_file_modified(event) self.assertEqual(self.bot.config["history-limit"], 42)