""" Regression tests for the post-login settings atomicity invariant. Three load-bearing properties are locked in here: 1. On failure mid-write, the DB rolls back cleanly — no partial rows, no orphaned `app.version`. The next login retries from a clean state instead of entering the sticky loop (commit 621d8d0d — fix(auth): atomic settings reload + app.version update on login). 2. On success, both the defaults import AND `app.version` persist together — the invariant that makes `db_version_matches_package()` useful. 3. `engine.dispose()` on a busy engine does NOT break a thread holding a checked-out connection. This is the SA 2.0 contract (`QueuePool.dispose` drains only idle queue entries, `Engine.dispose` calls `pool.recreate()`, checked-out connections keep working) and we lock it in so a future SA upgrade cannot silently change it. This test makes PR #3487's "dispose orphans checked-out" mechanism claim falsifiable — if it ever starts being true for our SQLCipher+WAL path, this test fails. """ import sys import tempfile import threading from pathlib import Path import pytest from sqlalchemy.orm import sessionmaker sys.path.insert(0, str(Path(__file__).parent.parent.parent.resolve())) from local_deep_research.database.encrypted_db import DatabaseManager from local_deep_research.database.models import Setting from local_deep_research.settings.manager import SettingsManager @pytest.fixture def temp_data_dir(): """Per-test temporary directory for encrypted databases.""" with tempfile.TemporaryDirectory() as tmpdir: yield Path(tmpdir) @pytest.fixture def db_manager(temp_data_dir, monkeypatch): """DatabaseManager pointed at a temp directory. Production QueuePool is used (not StaticPool) so the dispose-vs- checkout test exercises the real code path. """ monkeypatch.setattr( "local_deep_research.database.encrypted_db.get_data_directory", lambda: temp_data_dir, ) monkeypatch.delenv("TESTING", raising=False) manager = DatabaseManager() yield manager for username in list(manager.connections.keys()): try: manager.close_user_database(username) except Exception: pass @pytest.fixture def user_engine(db_manager): """Fresh per-test SQLCipher engine for a unique user. After `create_user_database`, the engine is already populated by `initialize_database` with the full defaults set + `app.version`. Tests that need a pre-import state delete the relevant rows in their arrange step. """ username = "atomicity_user" password = "AtomicityPass123!" engine = db_manager.create_user_database(username, password) yield username, password, engine def _has_setting(engine, key: str) -> bool: Session = sessionmaker(bind=engine) with Session() as s: return s.query(Setting).filter(Setting.key == key).first() is not None def _delete_keys(engine, keys): Session = sessionmaker(bind=engine) with Session() as s: s.query(Setting).filter(Setting.key.in_(list(keys))).delete( synchronize_session=False ) s.commit() def _run_post_login_atomic_block(engine, raise_after_stage: bool = False): """Mirror the atomic block in `_perform_post_login_tasks` step 1. We call the SettingsManager methods directly against a real engine instead of routing through `get_user_db_session` so the test does not depend on Flask session state. The transaction scope is identical: one session, one terminal commit. Args: engine: SQLCipher engine for the user DB. raise_after_stage: If True, raise after load_from_defaults_file has staged rows but before the terminal commit. This models a mid-write crash / thread death / dispose-triggered error whose rollback must leave the DB in a clean pre-write state. """ Session = sessionmaker(bind=engine) with Session() as db_session: settings_manager = SettingsManager(db_session) settings_manager.load_from_defaults_file(commit=False, overwrite=False) if raise_after_stage: raise RuntimeError("simulated mid-write failure") settings_manager.update_db_version(commit=False) db_session.commit() class TestPostLoginSettingsAtomicity: """The invariant fixed by commit 621d8d0d must stay fixed.""" def test_atomic_block_restores_deleted_keys_and_app_version( self, user_engine ): """ Happy path: if the pre-login state is missing `app.version` and some defaults (simulating a version-mismatch + manual-cleanup scenario), the atomic block restores everything in one commit. """ _, _, engine = user_engine probe_keys = {"app.version", "app.debug"} _delete_keys(engine, probe_keys) for k in probe_keys: assert not _has_setting(engine, k), ( f"test precondition: {k} deleted" ) _run_post_login_atomic_block(engine) for k in probe_keys: assert _has_setting(engine, k), ( f"{k} must be restored after a successful atomic block" ) def test_mid_write_failure_rolls_back_to_pre_write_state(self, user_engine): """ Sticky-loop regression guard. Without atomicity the pre-621d8d0d path committed the defaults import first and then tried to commit `app.version` separately; any failure between the two commits left `app.version` unwritten while staged rows had already landed — every subsequent login re-ran the bulk insert. With the atomic block, a failure between staging and commit rolls back ALL staged changes — the probe keys deleted below remain deleted, and `app.version` is still absent. Next login retries from an unchanged pre-write state. """ _, _, engine = user_engine probe_keys = {"app.version", "app.debug"} _delete_keys(engine, probe_keys) for k in probe_keys: assert not _has_setting(engine, k) with pytest.raises(RuntimeError, match="simulated mid-write failure"): _run_post_login_atomic_block(engine, raise_after_stage=True) for k in probe_keys: assert not _has_setting(engine, k), ( f"{k} was deleted before the atomic block; a mid-write " f"failure must roll back all staged changes and leave " f"it deleted. If it exists now, the two-commit split " f"has regressed and the sticky loop is back." ) _run_post_login_atomic_block(engine) for k in probe_keys: assert _has_setting(engine, k) def test_post_login_routes_uses_commit_false_for_both_calls(self): """ Structural guard: lock in that the post-login task body calls `load_from_defaults_file` and `update_db_version` with `commit=False` and emits a single terminal `db_session.commit()`. Any refactor that regresses to the two-commit form will fail this test before production sees a sticky loop. Inspects `_perform_post_login_tasks_body` (not the decorated wrapper) because #3489 split the function into a thin try/except wrapper + body for daemon-thread exception logging. The atomic block lives in the body. """ import inspect from local_deep_research.web.auth import routes src = inspect.getsource(routes._perform_post_login_tasks_body) assert "load_from_defaults_file(" in src assert "update_db_version(commit=False)" in src, ( "update_db_version must be called with commit=False so the " "atomic block controls the terminal commit" ) lfd_idx = src.index("load_from_defaults_file(") lfd_tail = src[lfd_idx : lfd_idx + 200] assert "commit=False" in lfd_tail, ( "load_from_defaults_file must be called with commit=False " "so the atomic block controls the terminal commit" ) assert "db_session.commit()" in src, ( "the atomic block must end with a single db_session.commit()" ) class TestCheckedOutConnectionSurvivesDispose: """Lock in the SA 2.0 contract that PR #3487 misread.""" @pytest.mark.parametrize("iteration", range(20)) def test_checked_out_session_completes_after_dispose( self, user_engine, iteration ): """ Mid-transaction dispose from another thread MUST NOT break the writer's session. SA 2.0 `QueuePool.dispose` drains only idle entries and `Engine.dispose` calls `pool.recreate()` — a thread holding a checked-out connection keeps using it until return. 20 iterations surface races. """ _, _, engine = user_engine key = f"atomicity.test.survival.{iteration}" writer_ready = threading.Event() main_disposed = threading.Event() writer_result = {} def writer(): Session = sessionmaker(bind=engine) try: with Session() as session: session.add( Setting( key=key, value=f"value_{iteration}", name=key, type="APP", visible=False, editable=False, ) ) session.flush() writer_ready.set() if not main_disposed.wait(timeout=5.0): writer_result["error"] = ( "main thread did not signal dispose within 5s" ) return session.commit() writer_result["ok"] = True except Exception as exc: writer_result["error"] = repr(exc) t = threading.Thread(target=writer, daemon=True) t.start() assert writer_ready.wait(timeout=5.0), ( f"writer did not start; result={writer_result}" ) engine.dispose() main_disposed.set() t.join(timeout=10.0) assert not t.is_alive(), "writer thread did not finish" assert writer_result.get("ok") is True, ( "engine.dispose() must NOT break a thread holding a " "checked-out connection — if this fires, either SA 2.0 " "semantics changed or SQLCipher introduced a hook that " "violates them. Error: " f"{writer_result.get('error')}" ) Session = sessionmaker(bind=engine) with Session() as s: row = s.query(Setting).filter(Setting.key == key).first() assert row is not None, ( "writer's committed row must be readable from a fresh " "session after dispose" ) assert row.value == f"value_{iteration}" def test_dispose_without_checked_out_connections_is_noop_for_clients( self, user_engine ): """Sanity: dispose on an idle engine leaves subsequent sessions working normally.""" _, _, engine = user_engine Session = sessionmaker(bind=engine) with Session() as s: s.add( Setting( key="atomicity.test.pre_dispose", value="before", name="atomicity.test.pre_dispose", type="APP", visible=False, editable=False, ) ) s.commit() engine.dispose() with Session() as s: row = ( s.query(Setting) .filter(Setting.key == "atomicity.test.pre_dispose") .first() ) assert row is not None assert row.value == "before" s.add( Setting( key="atomicity.test.post_dispose", value="after", name="atomicity.test.post_dispose", type="APP", visible=False, editable=False, ) ) s.commit() with Session() as s: row = ( s.query(Setting) .filter(Setting.key == "atomicity.test.post_dispose") .first() ) assert row is not None assert row.value == "after"