mirror of
https://github.com/LearningCircuit/local-deep-research.git
synced 2026-06-16 03:51:07 +03:00
* fix(db): add busy_timeout and temp_store PRAGMAs to auth database Auth database had no PRAGMA configuration, meaning concurrent writes would immediately raise OperationalError (SQLite default busy_timeout is 0ms). Add busy_timeout=10000 and temp_store=MEMORY via event listener, matching the pattern used in encrypted_db.py. Deliberately does NOT enable WAL mode — auth_db is a tiny metadata store not included in the backup system, and WAL sidecar files would be missed by naive file copies of the data directory. * fix(test): patch event.listen in auth_db tests that mock create_engine Tests that mock create_engine return a Mock object which doesn't support SQLAlchemy event registration. Patch event module to prevent InvalidRequestError when the engine creation path registers the new PRAGMA event listener.
575 lines
21 KiB
Python
575 lines
21 KiB
Python
"""Tests for auth_db module."""
|
|
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
from unittest.mock import patch, Mock
|
|
|
|
|
|
class TestGetAuthDbPath:
|
|
"""Tests for get_auth_db_path function."""
|
|
|
|
def test_returns_path_object(self):
|
|
"""get_auth_db_path returns a Path object."""
|
|
from local_deep_research.database.auth_db import get_auth_db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_data_directory"
|
|
) as mock_get_data:
|
|
mock_get_data.return_value = Path("/fake/data/dir")
|
|
|
|
result = get_auth_db_path()
|
|
|
|
assert isinstance(result, Path)
|
|
|
|
def test_returns_correct_filename(self):
|
|
"""get_auth_db_path returns path with ldr_auth.db filename."""
|
|
from local_deep_research.database.auth_db import get_auth_db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_data_directory"
|
|
) as mock_get_data:
|
|
mock_get_data.return_value = Path("/fake/data/dir")
|
|
|
|
result = get_auth_db_path()
|
|
|
|
assert result.name == "ldr_auth.db"
|
|
|
|
def test_uses_data_directory(self):
|
|
"""get_auth_db_path uses get_data_directory for parent path."""
|
|
from local_deep_research.database.auth_db import get_auth_db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_data_directory"
|
|
) as mock_get_data:
|
|
mock_get_data.return_value = Path("/test/data/path")
|
|
|
|
result = get_auth_db_path()
|
|
|
|
mock_get_data.assert_called_once()
|
|
assert result.parent == Path("/test/data/path")
|
|
|
|
|
|
class TestInitAuthDatabase:
|
|
"""Tests for init_auth_database function."""
|
|
|
|
def test_creates_database_directory(self):
|
|
"""init_auth_database creates parent directory if needed."""
|
|
from local_deep_research.database.auth_db import init_auth_database
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "subdir" / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.create_engine"
|
|
) as mock_engine:
|
|
mock_conn = Mock()
|
|
mock_engine.return_value.begin.return_value.__enter__ = (
|
|
Mock(return_value=mock_conn)
|
|
)
|
|
mock_engine.return_value.begin.return_value.__exit__ = Mock(
|
|
return_value=False
|
|
)
|
|
|
|
init_auth_database()
|
|
|
|
# Directory should be created
|
|
assert db_path.parent.exists()
|
|
|
|
def test_idempotent_if_database_exists(self):
|
|
"""init_auth_database is idempotent when database already exists."""
|
|
from local_deep_research.database.auth_db import init_auth_database
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
# Create the file
|
|
db_path.touch()
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.create_engine"
|
|
) as mock_engine:
|
|
mock_conn = Mock()
|
|
mock_engine.return_value.begin.return_value.__enter__ = (
|
|
Mock(return_value=mock_conn)
|
|
)
|
|
mock_engine.return_value.begin.return_value.__exit__ = Mock(
|
|
return_value=False
|
|
)
|
|
|
|
# Should not raise even if DB already exists
|
|
init_auth_database()
|
|
|
|
# create_engine is called (uses IF NOT EXISTS)
|
|
mock_engine.assert_called_once()
|
|
|
|
def test_creates_tables(self):
|
|
"""init_auth_database creates User table using CreateTable DDL."""
|
|
from local_deep_research.database.auth_db import init_auth_database
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.create_engine"
|
|
) as mock_engine:
|
|
mock_conn = Mock()
|
|
mock_engine.return_value.begin.return_value.__enter__ = (
|
|
Mock(return_value=mock_conn)
|
|
)
|
|
mock_engine.return_value.begin.return_value.__exit__ = Mock(
|
|
return_value=False
|
|
)
|
|
|
|
init_auth_database()
|
|
|
|
# Should execute DDL via conn.execute
|
|
assert mock_conn.execute.called
|
|
|
|
|
|
class TestGetAuthDbSession:
|
|
"""Tests for get_auth_db_session function."""
|
|
|
|
def test_returns_session(self):
|
|
"""get_auth_db_session returns a SQLAlchemy session."""
|
|
from local_deep_research.database.auth_db import get_auth_db_session
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
# Create the file so init is skipped
|
|
db_path.touch()
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.create_engine"
|
|
) as mock_engine:
|
|
mock_engine_instance = Mock()
|
|
mock_engine.return_value = mock_engine_instance
|
|
|
|
with patch("local_deep_research.database.auth_db.event"):
|
|
with patch(
|
|
"local_deep_research.database.auth_db.sessionmaker"
|
|
) as mock_sessionmaker:
|
|
mock_session_class = Mock()
|
|
mock_session = Mock()
|
|
mock_session_class.return_value = mock_session
|
|
mock_sessionmaker.return_value = mock_session_class
|
|
|
|
result = get_auth_db_session()
|
|
|
|
assert result is mock_session
|
|
|
|
def test_creates_database_if_missing(self):
|
|
"""get_auth_db_session initializes database if it doesn't exist."""
|
|
from local_deep_research.database.auth_db import get_auth_db_session
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
# Don't create the file - it doesn't exist
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.init_auth_database"
|
|
) as mock_init:
|
|
with patch(
|
|
"local_deep_research.database.auth_db.create_engine"
|
|
) as mock_engine:
|
|
mock_engine_instance = Mock()
|
|
mock_engine.return_value = mock_engine_instance
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.event"
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.auth_db.sessionmaker"
|
|
) as mock_sessionmaker:
|
|
mock_session_class = Mock()
|
|
mock_session = Mock()
|
|
mock_session_class.return_value = mock_session
|
|
mock_sessionmaker.return_value = (
|
|
mock_session_class
|
|
)
|
|
|
|
get_auth_db_session()
|
|
|
|
# init_auth_database should be called
|
|
mock_init.assert_called_once()
|
|
|
|
def test_creates_engine_with_correct_url(self):
|
|
"""get_auth_db_session creates engine with correct SQLite URL."""
|
|
from local_deep_research.database.auth_db import get_auth_db_session
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
db_path.touch()
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.create_engine"
|
|
) as mock_engine:
|
|
mock_engine_instance = Mock()
|
|
mock_engine.return_value = mock_engine_instance
|
|
|
|
with patch("local_deep_research.database.auth_db.event"):
|
|
with patch(
|
|
"local_deep_research.database.auth_db.sessionmaker"
|
|
) as mock_sessionmaker:
|
|
mock_session_class = Mock()
|
|
mock_session = Mock()
|
|
mock_session_class.return_value = mock_session
|
|
mock_sessionmaker.return_value = mock_session_class
|
|
|
|
get_auth_db_session()
|
|
|
|
# Verify create_engine was called with sqlite URL
|
|
call_args = mock_engine.call_args[0][0]
|
|
assert call_args.startswith("sqlite:///")
|
|
assert "ldr_auth.db" in call_args
|
|
|
|
|
|
class TestAuthDbEngineCache:
|
|
"""Tests for cached engine behavior."""
|
|
|
|
def test_get_auth_engine_returns_engine(self):
|
|
"""Test that _get_auth_engine() returns an engine."""
|
|
from local_deep_research.database.auth_db import (
|
|
_get_auth_engine,
|
|
dispose_auth_engine,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
# Ensure we start with a clean state
|
|
dispose_auth_engine()
|
|
|
|
engine = _get_auth_engine()
|
|
|
|
assert engine is not None
|
|
|
|
# Clean up
|
|
dispose_auth_engine()
|
|
|
|
def test_engine_cached_on_subsequent_calls(self):
|
|
"""Test that _get_auth_engine() returns cached engine."""
|
|
from local_deep_research.database.auth_db import (
|
|
_get_auth_engine,
|
|
dispose_auth_engine,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
# Ensure we start with a clean state
|
|
dispose_auth_engine()
|
|
|
|
engine1 = _get_auth_engine()
|
|
engine2 = _get_auth_engine()
|
|
|
|
# Should return the same cached engine
|
|
assert engine1 is engine2
|
|
|
|
# Clean up
|
|
dispose_auth_engine()
|
|
|
|
def test_engine_recreated_on_path_change(self):
|
|
"""Test that engine is recreated when data directory changes."""
|
|
from local_deep_research.database.auth_db import (
|
|
_get_auth_engine,
|
|
dispose_auth_engine,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir1:
|
|
with tempfile.TemporaryDirectory() as temp_dir2:
|
|
db_path1 = Path(temp_dir1) / "ldr_auth.db"
|
|
db_path2 = Path(temp_dir2) / "ldr_auth.db"
|
|
|
|
# Ensure we start with a clean state
|
|
dispose_auth_engine()
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
# First call with path1
|
|
mock_path.return_value = db_path1
|
|
engine1 = _get_auth_engine()
|
|
|
|
# Change to path2
|
|
mock_path.return_value = db_path2
|
|
engine2 = _get_auth_engine()
|
|
|
|
# Should be different engines
|
|
assert engine1 is not engine2
|
|
|
|
# Clean up
|
|
dispose_auth_engine()
|
|
|
|
def test_dispose_clears_cache(self):
|
|
"""Test that dispose_auth_engine() clears the cached engine."""
|
|
from local_deep_research.database import auth_db
|
|
from local_deep_research.database.auth_db import (
|
|
_get_auth_engine,
|
|
dispose_auth_engine,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
# Ensure we start with a clean state
|
|
dispose_auth_engine()
|
|
|
|
# Create an engine
|
|
_get_auth_engine()
|
|
|
|
# Dispose it
|
|
dispose_auth_engine()
|
|
|
|
# Module-level _auth_engine should be None
|
|
assert auth_db._auth_engine is None
|
|
|
|
def test_dispose_clears_path(self):
|
|
"""Test that dispose_auth_engine() clears the cached path."""
|
|
from local_deep_research.database import auth_db
|
|
from local_deep_research.database.auth_db import (
|
|
_get_auth_engine,
|
|
dispose_auth_engine,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
# Ensure we start with a clean state
|
|
dispose_auth_engine()
|
|
|
|
# Create an engine
|
|
_get_auth_engine()
|
|
|
|
# Dispose it
|
|
dispose_auth_engine()
|
|
|
|
# Module-level _auth_engine_path should be None
|
|
assert auth_db._auth_engine_path is None
|
|
|
|
def test_dispose_handles_no_engine(self):
|
|
"""Test that dispose_auth_engine() handles no engine gracefully."""
|
|
from local_deep_research.database.auth_db import dispose_auth_engine
|
|
|
|
# Dispose when there's no engine should not raise
|
|
dispose_auth_engine()
|
|
dispose_auth_engine() # Calling twice should be fine
|
|
|
|
|
|
class TestInitAuthDatabaseAtomicDDL:
|
|
"""Tests for atomic DDL in init_auth_database (PR #2146)."""
|
|
|
|
def test_uses_create_table_with_if_not_exists(self):
|
|
"""init_auth_database executes CreateTable with if_not_exists=True."""
|
|
from local_deep_research.database.auth_db import init_auth_database
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.create_engine"
|
|
) as mock_engine:
|
|
mock_conn = Mock()
|
|
mock_engine.return_value.begin.return_value.__enter__ = (
|
|
Mock(return_value=mock_conn)
|
|
)
|
|
mock_engine.return_value.begin.return_value.__exit__ = Mock(
|
|
return_value=False
|
|
)
|
|
|
|
init_auth_database()
|
|
|
|
# Verify conn.execute was called (CreateTable + CreateIndex)
|
|
assert mock_conn.execute.call_count >= 1
|
|
|
|
# Check that CreateTable DDL was passed
|
|
from sqlalchemy.schema import CreateTable
|
|
|
|
first_call = mock_conn.execute.call_args_list[0]
|
|
ddl_arg = first_call[0][0]
|
|
assert isinstance(ddl_arg, CreateTable)
|
|
|
|
def test_does_not_use_base_metadata_create_all(self):
|
|
"""init_auth_database no longer uses Base.metadata.create_all."""
|
|
from local_deep_research.database.auth_db import init_auth_database
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.create_engine"
|
|
) as mock_engine:
|
|
mock_conn = Mock()
|
|
mock_engine.return_value.begin.return_value.__enter__ = (
|
|
Mock(return_value=mock_conn)
|
|
)
|
|
mock_engine.return_value.begin.return_value.__exit__ = Mock(
|
|
return_value=False
|
|
)
|
|
|
|
init_auth_database()
|
|
|
|
# Verify engine.begin() was used (not metadata.create_all)
|
|
mock_engine.return_value.begin.assert_called_once()
|
|
|
|
def test_concurrent_init_does_not_fail(self):
|
|
"""Concurrent calls to init_auth_database complete without errors (PR #2146).
|
|
|
|
The fix replaced Python-level TOCTOU check (file exists → skip)
|
|
with SQL-level IF NOT EXISTS, which is atomic in SQLite.
|
|
"""
|
|
from local_deep_research.database.auth_db import init_auth_database
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
errors = []
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
|
|
def init_worker():
|
|
try:
|
|
init_auth_database()
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
# Run 10 concurrent init calls
|
|
threads = [
|
|
threading.Thread(target=init_worker) for _ in range(10)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert len(errors) == 0, (
|
|
f"Concurrent init_auth_database failed: {errors}"
|
|
)
|
|
# Database file should exist
|
|
assert db_path.exists()
|
|
|
|
def test_no_init_on_import(self):
|
|
"""init_auth_database is no longer called on module import (PR #2146)."""
|
|
import importlib
|
|
import local_deep_research.database.auth_db as auth_module
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.init_auth_database"
|
|
) as mock_init:
|
|
importlib.reload(auth_module)
|
|
mock_init.assert_not_called()
|
|
|
|
|
|
class TestAuthDbPerformancePragmas:
|
|
"""Tests for performance PRAGMA application on auth database connections."""
|
|
|
|
def test_busy_timeout_set(self):
|
|
"""Verify busy_timeout is applied on auth database connections."""
|
|
from local_deep_research.database.auth_db import (
|
|
_get_auth_engine,
|
|
dispose_auth_engine,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
dispose_auth_engine()
|
|
|
|
engine = _get_auth_engine()
|
|
with engine.connect() as conn:
|
|
result = conn.exec_driver_sql(
|
|
"PRAGMA busy_timeout"
|
|
).scalar()
|
|
assert result == 10000
|
|
|
|
dispose_auth_engine()
|
|
|
|
def test_temp_store_set(self):
|
|
"""Verify temp_store=MEMORY is applied on auth database connections."""
|
|
from local_deep_research.database.auth_db import (
|
|
_get_auth_engine,
|
|
dispose_auth_engine,
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
db_path = Path(temp_dir) / "ldr_auth.db"
|
|
|
|
with patch(
|
|
"local_deep_research.database.auth_db.get_auth_db_path"
|
|
) as mock_path:
|
|
mock_path.return_value = db_path
|
|
dispose_auth_engine()
|
|
|
|
engine = _get_auth_engine()
|
|
with engine.connect() as conn:
|
|
result = conn.exec_driver_sql("PRAGMA temp_store").scalar()
|
|
# temp_store=MEMORY is value 2
|
|
assert result == 2
|
|
|
|
dispose_auth_engine()
|