import asyncio import sqlite3 from pathlib import Path import pytest from mjr_am_backend.adapters.db import sqlite_facade as s def test_missing_error_helpers(): assert s._is_missing_column_error(Exception("no such column: x")) is True assert s._is_missing_table_error(Exception("no such table: x")) is True def test_schema_helpers_and_populate(tmp_path: Path): db = tmp_path / "t.db" conn = sqlite3.connect(str(db)) conn.execute("CREATE TABLE assets (id INTEGER, filename TEXT)") conn.commit() conn.close() s._populate_known_columns_from_schema(db) assert "id" in s._KNOWN_COLUMNS and "a.id" in s._KNOWN_COLUMNS assert s._extract_schema_column_name((0, "id")) == "id" assert s._extract_schema_column_name(None) == "" def test_validate_and_build_in_query(): ok, msg = s._validate_in_base_query("SELECT * FROM assets WHERE {IN_CLAUSE}") assert ok and not msg ok2, _ = s._validate_in_base_query("DELETE FROM x WHERE {IN_CLAUSE}") assert ok2 is False b_ok, q, ph = s._build_in_query("SELECT * FROM assets WHERE {IN_CLAUSE}", "id", 3) assert b_ok and "id IN (?,?,?)" in q and ph == 3 def test_column_repair_helpers(monkeypatch): monkeypatch.setattr(s, "_KNOWN_COLUMNS_LOWER", {"a.id": "a.id", "id": "id"}) assert s._try_repair_column_name(" A.ID ") == "a.id" assert s._try_repair_column_name("id;") == "id" ok, col = s._validate_and_repair_column_name(" a.id ") assert ok and col == "a.id" ok2, col2 = s._validate_and_repair_column_name("bad;drop") assert ok2 is False and col2 is None def test_asset_lock_key(): assert s._asset_lock_key(None) == "asset:__null__" assert s._asset_lock_key(7) == "asset:7" def test_find_unresolved_sql_template(): assert s._find_unresolved_sql_template("SELECT * FROM assets WHERE {IN_CLAUSE}") == "{IN_CLAUSE}" assert s._find_unresolved_sql_template("SELECT COALESCE(m.metadata_raw, '{}') FROM asset_metadata m") is None assert s._find_unresolved_sql_template("SELECT 1") is None @pytest.mark.asyncio async def test_execute_async_rejects_unresolved_template_before_db_init(): obj = object.__new__(s.Sqlite) result = await obj._execute_async("SELECT * FROM assets WHERE {IN_CLAUSE}", tuple(), fetch=True) assert result.ok is False assert result.code == s.ErrorCode.INVALID_INPUT assert "unresolved template token" in str(result.error or "").lower() def test_async_loop_thread_run_submit_stop(): t = s._AsyncLoopThread(run_timeout_s=2.0) loop = t.start() assert loop is not None async def _coro(): return 3 assert t.run(_coro()) == 3 fut = t.submit(_coro()) assert fut.result(timeout=2) == 3 t.stop() def test_async_loop_thread_deadlock_guard(): t = s._AsyncLoopThread(run_timeout_s=2.0) _ = t.start() t._thread_ident = __import__("threading").get_ident() coro = asyncio.sleep(0) with pytest.raises(RuntimeError): t.run(coro) coro.close() t.stop() def test_sqlite_wrapper_static_and_delegate_methods(monkeypatch): # Build instance without running heavy __init__. obj = object.__new__(s.Sqlite) obj._diag = {} obj._diag_lock = asyncio.Lock() if False else __import__("threading").Lock() monkeypatch.setattr(s, "pool_load_user_db_config", lambda self: {"ok": 1}) monkeypatch.setattr(s, "pool_resolve_user_db_config_path", lambda self: Path("x")) monkeypatch.setattr(s, "pool_read_user_db_config_file", lambda p: {"x": 1}) monkeypatch.setattr(s, "pool_normalize_user_db_config", lambda d: {"y": 2}) monkeypatch.setattr(s, "pool_maybe_set_config_number", lambda out, data, key, min_value, cast: out.update({key: cast(data.get(key, min_value))})) monkeypatch.setattr(s, "recovery_is_locked_error", lambda exc: True) monkeypatch.setattr(s, "recovery_is_malformed_error", lambda exc: True) monkeypatch.setattr(s, "recovery_mark_locked_event", lambda self, exc: self._diag.update({"locked": True})) monkeypatch.setattr(s, "recovery_mark_malformed_event", lambda self, exc: self._diag.update({"malformed": True})) monkeypatch.setattr(s, "recovery_set_recovery_state", lambda self, state, error=None: self._diag.update({"state": state, "error": error})) monkeypatch.setattr(s, "pool_diagnostics", lambda self: {"d": 1}) assert obj._load_user_db_config()["ok"] == 1 assert str(obj._resolve_user_db_config_path()) == "x" assert s.Sqlite._read_user_db_config_file(Path("x"))["x"] == 1 assert s.Sqlite._normalize_user_db_config({})["y"] == 2 out = {} s.Sqlite._maybe_set_config_number(out, {"k": "5"}, "k", min_value=1, cast=int) assert out["k"] == 5 assert obj._is_locked_error(Exception("x")) is True assert obj._is_malformed_error(Exception("x")) is True obj._mark_locked_event(Exception("x")) obj._mark_malformed_event(Exception("x")) obj._set_recovery_state("ok") assert obj.get_diagnostics() == {"d": 1} def test_sqlite_delegate_prune_and_asset_lock(monkeypatch): obj = object.__new__(s.Sqlite) monkeypatch.setattr(s, "pool_prune_asset_locks_locked", lambda self, now: setattr(self, "_p1", now)) monkeypatch.setattr(s, "pool_prune_asset_locks_by_ttl", lambda self, cutoff: setattr(self, "_p2", cutoff)) monkeypatch.setattr(s, "pool_prune_asset_locks_by_cap", lambda self: setattr(self, "_p3", True)) monkeypatch.setattr(s, "pool_asset_lock_last", lambda entry: 1.0) monkeypatch.setattr(s, "pool_asset_lock_is_locked", lambda entry: False) monkeypatch.setattr(s, "pool_get_or_create_asset_lock", lambda self, asset_id, keyfn: asyncio.Lock()) obj._prune_asset_locks_locked(1.0) obj._prune_asset_locks_by_ttl(2.0) obj._prune_asset_locks_by_cap() assert obj._p1 == 1.0 and obj._p2 == 2.0 and obj._p3 is True assert obj._asset_lock_last({}) == 1.0 assert obj._asset_lock_is_locked({}) is False assert isinstance(obj._get_or_create_asset_lock(1), type(asyncio.Lock())) def test_sqlite_tx_helper_delegates(monkeypatch): obj = object.__new__(s.Sqlite) monkeypatch.setattr(s, "tx_ctx_token", lambda _var: "tok") monkeypatch.setattr(s, "tx_rows_to_dicts", lambda rows: [{"x": 1}] if rows else []) monkeypatch.setattr(s, "tx_cursor_write_result", lambda cur: "ok") monkeypatch.setattr(s, "tx_begin_stmt_for_mode", lambda mode: "BEGIN IMMEDIATE") monkeypatch.setattr(s, "tx_register_tx_token", lambda self, token, conn, lock: setattr(self, "_tok", token)) monkeypatch.setattr(s, "tx_get_tx_state", lambda self, token: (None, False)) monkeypatch.setattr(s, "tx_is_write_sql", lambda q: q.strip().lower().startswith("insert")) assert obj._tx_token() == "tok" assert obj._rows_to_dicts([("a",)]) == [{"x": 1}] assert obj._cursor_write_result(None) == "ok" assert obj._begin_stmt_for_mode("immediate") == "BEGIN IMMEDIATE" obj._register_tx_token("t1", None, None) assert obj._tok == "t1" assert obj._get_tx_state("t1") == (None, False) assert obj._is_write_sql("INSERT INTO x values(1)") is True def test_sqlite_validate_in_query_and_column_repair_delegates(monkeypatch): monkeypatch.setattr(s, "tx_validate_in_base_query", lambda q: (True, "")) monkeypatch.setattr(s, "tx_build_in_query", lambda q, c, n: (True, "Q", ("?",) * n)) monkeypatch.setattr(s, "tx_validate_and_repair_column_name", lambda col, lower, lock: (True, "id")) monkeypatch.setattr(s, "tx_is_missing_column_error", lambda exc: True) monkeypatch.setattr(s, "tx_is_missing_table_error", lambda exc: True) assert s._validate_in_base_query("x")[0] in {True, False} assert s._build_in_query("SELECT {IN_CLAUSE}", "id", 1)[0] is True assert s._validate_and_repair_column_name("id")[0] is True assert s._is_missing_column_error(Exception("x")) in {True, False} assert s._is_missing_table_error(Exception("x")) in {True, False}