chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Database-related tests for Local Deep Research."""
+1
View File
@@ -0,0 +1 @@
"""Tests for database backup module."""
@@ -0,0 +1,577 @@
"""
Docker-based end-to-end backup crash recovery test.
This test runs in CI (release gate) inside the LDR Docker container
with SQLCipher available. It exercises the full lifecycle in 10 steps:
1. Create encrypted DB with real schema + data
2. Backup via BackupService
3. Verify backup is encrypted (no plaintext SQLite header)
4. Destroy the original DB (simulate crash)
5. Copy backup to replace original
6. Open with same password, verify data intact
7. Simulate password change (rekey)
8. Verify old password fails on rekeyed DB
9. Purge old backups + create fresh one with new key
10. Verify fresh backup works with new password, old backup gone
All files are created in tmp_path — nothing is uploaded or committed.
Requires SQLCipher (skips gracefully if unavailable).
"""
import os
import shutil
from pathlib import Path
from unittest.mock import patch
import pytest
from local_deep_research.database.backup.backup_service import (
BackupService,
)
def _get_sqlcipher():
"""Import SQLCipher or skip the test."""
try:
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
return get_sqlcipher_module()
except (ImportError, RuntimeError):
pytest.skip("SQLCipher not available")
def _create_test_database(db_path: Path, password: str):
"""Create a real encrypted SQLCipher database with LDR-like schema.
Uses create_sqlcipher_connection() for correct pragma ordering:
apply_cipher_defaults_before_key → set_sqlcipher_key → apply_sqlcipher_pragmas
"""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
conn = create_sqlcipher_connection(
str(db_path), password, creation_mode=True
)
cursor = conn.cursor()
# Create tables mimicking real LDR schema
cursor.execute(
"""CREATE TABLE research_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
query TEXT,
status TEXT DEFAULT 'completed',
created_at TEXT DEFAULT (datetime('now'))
)"""
)
cursor.execute(
"""CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT,
type TEXT DEFAULT 'APP'
)"""
)
cursor.execute(
"""CREATE TABLE documents (
id TEXT PRIMARY KEY,
research_id INTEGER,
file_name TEXT,
FOREIGN KEY (research_id) REFERENCES research_history(id)
)"""
)
# Insert test data
for i in range(20):
cursor.execute(
"INSERT INTO research_history (title, query) VALUES (?, ?)",
(f"Research on Topic {i}", f"What is topic {i}?"),
)
cursor.execute(
"INSERT INTO app_settings (key, value) VALUES (?, ?)",
("llm.model", "gpt-4o-mini"),
)
cursor.execute(
"INSERT INTO app_settings (key, value) VALUES (?, ?)",
("backup.enabled", "true"),
)
for i in range(5):
cursor.execute(
"INSERT INTO documents (id, research_id, file_name) VALUES (?, ?, ?)",
(f"doc-{i}", i + 1, f"paper_{i}.pdf"),
)
conn.commit()
cursor.close()
conn.close()
def _open_and_verify(
db_path: Path, password: str, expected_research_count: int = 20
):
"""Open a database with create_sqlcipher_connection and verify contents."""
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
conn = create_sqlcipher_connection(str(db_path), password)
cursor = conn.cursor()
# Integrity check
cursor.execute("PRAGMA integrity_check")
assert cursor.fetchone()[0] == "ok"
# Verify research data
cursor.execute("SELECT COUNT(*) FROM research_history")
count = cursor.fetchone()[0]
assert count == expected_research_count, (
f"Expected {expected_research_count} research rows, got {count}"
)
# Verify settings
cursor.execute("SELECT value FROM app_settings WHERE key = 'llm.model'")
model = cursor.fetchone()[0]
assert model == "gpt-4o-mini"
# Verify documents
cursor.execute("SELECT COUNT(*) FROM documents")
doc_count = cursor.fetchone()[0]
assert doc_count == 5
cursor.close()
conn.close()
return True
@pytest.mark.timeout(120)
class TestFullCrashRecoveryCycle:
"""Full end-to-end crash recovery cycle with password change.
Exercises the complete backup lifecycle in a single sequential flow,
as it would happen in production. Requires SQLCipher — skips in
environments without it.
"""
def test_full_backup_restore_password_change_cycle(self, tmp_path):
"""Complete lifecycle: backup → crash → restore → password change → verify."""
_get_sqlcipher() # Skip early if no SQLCipher
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
set_sqlcipher_rekey,
)
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_crashtest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
original_pw = "original_password_789"
new_pw = "changed_password_456"
# ── Step 1: Create database with real data ──
_create_test_database(db_path, original_pw)
_open_and_verify(db_path, original_pw)
# ── Step 2: Create backup ──
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="crashtest",
password=original_pw,
max_backups=3,
max_age_days=7,
)
backup_result = svc.create_backup()
assert backup_result.success, f"Backup failed: {backup_result.error}"
assert backup_result.backup_path.exists()
assert backup_result.backup_path.stat().st_size > 0
# ── Step 3: Verify backup is encrypted (not plaintext SQLite) ──
header = backup_result.backup_path.read_bytes()[:16]
assert header != b"SQLite format 3\x00", (
"Backup has plaintext SQLite header — encryption failed!"
)
# ── Step 4: Simulate crash — destroy original database ──
db_path.unlink()
for suffix in ["-wal", "-shm"]:
wal_path = db_path.parent / (db_path.name + suffix)
if wal_path.exists():
wal_path.unlink()
assert not db_path.exists()
# ── Step 5: Restore from backup — copy to original path ──
shutil.copy2(str(backup_result.backup_path), str(db_path))
os.chmod(db_path, 0o600)
# ── Step 6: Verify restored database works ──
_open_and_verify(db_path, original_pw)
# ── Step 7: Simulate password change (rekey) ──
conn = create_sqlcipher_connection(str(db_path), original_pw)
set_sqlcipher_rekey(conn, new_pw, db_path=db_path)
conn.close()
# Verify new password works
_open_and_verify(db_path, new_pw)
# ── Step 8: Verify old password fails on rekeyed DB ──
with pytest.raises(Exception):
create_sqlcipher_connection(str(db_path), original_pw)
# ── Step 9: Purge old backups and create fresh one ──
old_backup_path = backup_result.backup_path
assert old_backup_path.exists()
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
new_svc = BackupService(
username="crashtest",
password=new_pw,
max_backups=3,
max_age_days=7,
)
refresh_result = new_svc.purge_and_refresh()
assert refresh_result.success, f"Refresh failed: {refresh_result.error}"
assert not old_backup_path.exists(), "Old-key backup was not purged"
assert refresh_result.backup_path.exists()
# ── Step 10: Verify fresh backup ──
# Works with new password
_open_and_verify(refresh_result.backup_path, new_pw)
# Is encrypted (not plaintext)
fresh_header = refresh_result.backup_path.read_bytes()[:16]
assert fresh_header != b"SQLite format 3\x00", (
"Fresh backup has plaintext SQLite header!"
)
# Does NOT work with old password
with pytest.raises(Exception):
create_sqlcipher_connection(
str(refresh_result.backup_path), original_pw
)
# Only 1 backup exists (the fresh one)
backups = list(backup_dir.glob("ldr_backup_*.db"))
assert len(backups) == 1, f"Expected 1 backup, found {len(backups)}"
@pytest.mark.timeout(120)
class TestBackupDataIntegrity:
"""Tests that verify backup data integrity properties.
These tests require real SQLCipher and validate that sqlcipher_export()
preserves schema, foreign keys, and produces a fully functional database.
"""
def test_backup_preserves_all_schema_objects(self, tmp_path):
"""Backup must contain all tables, indexes, and triggers from source."""
_get_sqlcipher()
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
password = "schema-test-pw"
db_dir = tmp_path / "db"
db_dir.mkdir()
db_path = db_dir / "ldr_user_schematest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
# Create DB with tables, explicit index, and trigger
conn = create_sqlcipher_connection(
str(db_path), password, creation_mode=True
)
cursor = conn.cursor()
cursor.execute(
"""CREATE TABLE research_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
query TEXT
)"""
)
cursor.execute(
"""CREATE TABLE documents (
id TEXT PRIMARY KEY,
research_id INTEGER,
file_name TEXT,
FOREIGN KEY (research_id) REFERENCES research_history(id)
)"""
)
cursor.execute(
"CREATE INDEX idx_docs_research ON documents(research_id)"
)
cursor.execute("INSERT INTO research_history (title) VALUES ('Test')")
conn.commit()
cursor.close()
conn.close()
# Get source schema
conn = create_sqlcipher_connection(str(db_path), password)
cursor = conn.cursor()
cursor.execute(
"SELECT type, name, sql FROM sqlite_master "
"WHERE sql IS NOT NULL ORDER BY type, name"
)
source_schema = cursor.fetchall()
cursor.close()
conn.close()
# Create backup
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="schematest", password=password, max_backups=3
)
result = svc.create_backup()
assert result.success, f"Backup failed: {result.error}"
# Compare schemas
conn = create_sqlcipher_connection(str(result.backup_path), password)
cursor = conn.cursor()
cursor.execute(
"SELECT type, name, sql FROM sqlite_master "
"WHERE sql IS NOT NULL ORDER BY type, name"
)
backup_schema = cursor.fetchall()
cursor.close()
conn.close()
assert source_schema == backup_schema, (
f"Schema mismatch!\nSource: {source_schema}\nBackup: {backup_schema}"
)
def test_backup_passes_foreign_key_check(self, tmp_path):
"""Backup must have zero foreign key violations."""
_get_sqlcipher()
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
password = "fk-test-pw"
db_dir = tmp_path / "db"
db_dir.mkdir()
db_path = db_dir / "ldr_user_fktest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
_create_test_database(db_path, password)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="fktest", password=password, max_backups=3
)
result = svc.create_backup()
assert result.success, f"Backup failed: {result.error}"
conn = create_sqlcipher_connection(str(result.backup_path), password)
cursor = conn.cursor()
cursor.execute("PRAGMA foreign_key_check")
violations = cursor.fetchall()
cursor.close()
conn.close()
assert violations == [], f"FK violations in backup: {violations}"
def test_restored_backup_accepts_new_writes(self, tmp_path):
"""A restored backup must be a fully writable, functional database."""
_get_sqlcipher()
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
password = "write-test-pw"
db_dir = tmp_path / "db"
db_dir.mkdir()
db_path = db_dir / "ldr_user_writetest.db"
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
_create_test_database(db_path, password)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="writetest", password=password, max_backups=3
)
result = svc.create_backup()
assert result.success, f"Backup failed: {result.error}"
# Restore: copy backup over original
restored_path = tmp_path / "restored.db"
shutil.copy2(str(result.backup_path), str(restored_path))
# Copy salt file too (needed for key derivation)
salt_src = Path(str(db_path) + ".salt")
if salt_src.exists():
shutil.copy2(str(salt_src), str(restored_path) + ".salt")
# Open restored DB and write new data
conn = create_sqlcipher_connection(str(restored_path), password)
cursor = conn.cursor()
cursor.execute(
"INSERT INTO research_history (title, query) VALUES (?, ?)",
("New after restore", "Does restore work?"),
)
cursor.execute(
"UPDATE research_history SET title = ? WHERE id = 1",
("Modified after restore",),
)
conn.commit()
# Verify writes persisted
cursor.execute("SELECT COUNT(*) FROM research_history")
assert cursor.fetchone()[0] == 21 # 20 original + 1 new
cursor.execute("SELECT title FROM research_history WHERE id = 1")
assert cursor.fetchone()[0] == "Modified after restore"
cursor.close()
conn.close()
# Reopen to verify durability
conn = create_sqlcipher_connection(str(restored_path), password)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM research_history")
assert cursor.fetchone()[0] == 21
cursor.close()
conn.close()
class TestBackupPathWithApostrophe:
"""Regression for #4808: a backup/data-dir path containing an apostrophe
(e.g. /home/O'Brien) must back up successfully. ATTACH DATABASE can't be
parameterized, so the path literal escapes the single quote (doubling it)
instead of the denylist rejecting the path."""
def test_backup_succeeds_with_apostrophe_in_path(self, tmp_path):
_get_sqlcipher() # skip early if SQLCipher is unavailable
db_dir = tmp_path / "encrypted_databases"
db_dir.mkdir()
db_path = db_dir / "ldr_user_apos.db"
# The offending component: a directory whose name has a single quote.
backup_dir = tmp_path / "O'Brien" / "backups"
backup_dir.mkdir(parents=True)
password = "apostrophe_password_123"
_create_test_database(db_path, password)
with (
patch(
"local_deep_research.database.backup.backup_service"
".get_encrypted_database_path",
return_value=db_dir,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_database_filename",
return_value=db_path.name,
),
patch(
"local_deep_research.database.backup.backup_service"
".get_user_backup_directory",
return_value=backup_dir,
),
):
svc = BackupService(
username="apostuser",
password=password,
max_backups=3,
max_age_days=7,
)
result = svc.create_backup()
assert result.success, f"Backup failed: {result.error}"
assert "'" in str(result.backup_path) # the apostrophe path was used
assert result.backup_path.exists()
assert result.backup_path.stat().st_size > 0
# Encrypted, not a plaintext SQLite file
assert result.backup_path.read_bytes()[:16] != b"SQLite format 3\x00"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""Tests for database models."""
@@ -0,0 +1,438 @@
"""
Tests for download tracking models (DownloadTracker, DownloadDuplicates, DownloadAttempt).
Tests model structure, column constraints, default values, and __repr__ methods
WITHOUT requiring a real database -- uses direct attribute assignment on model instances.
"""
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from local_deep_research.database.models.download_tracker import (
DownloadAttempt,
DownloadDuplicates,
DownloadTracker,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _col(model_cls, name):
"""Return a Column object from a model's __table__."""
return model_cls.__table__.columns[name]
def _make(model_cls, **kwargs):
"""Instantiate a model via its normal constructor (no DB session needed).
SQLAlchemy's declarative ``__init__`` accepts column names as keyword
arguments and properly initialises the instance state so that
instrumented attribute access (used by ``__repr__``) works correctly.
"""
return model_cls(**kwargs)
# ===========================================================================
# DownloadTracker
# ===========================================================================
class TestDownloadTrackerTablename:
"""Test 1: __tablename__ is 'download_tracker'."""
def test_tablename(self):
assert DownloadTracker.__tablename__ == "download_tracker"
class TestDownloadTrackerColumns:
"""Test 2: Column types and nullable constraints."""
def test_url_column_is_text_and_not_nullable(self):
col = _col(DownloadTracker, "url")
assert isinstance(col.type, Text)
assert col.nullable is False
def test_url_hash_is_string64_not_nullable_with_table_level_unique(self):
col = _col(DownloadTracker, "url_hash")
assert isinstance(col.type, String)
assert col.type.length == 64
assert col.nullable is False
# Uniqueness lives on a table-level UniqueConstraint named
# ``uq_download_tracker_url_hash`` (see model __table_args__) so the
# index lands inline in CREATE TABLE — required so SQLCipher accepts
# ``url_hash`` as a valid FK target for the child tables. Column-level
# ``unique=True``/``index=True`` would emit a separate CREATE UNIQUE
# INDEX which SQLCipher does not recognise as the FK target.
unique_cols = {
tuple(c.name for c in constraint.columns)
for constraint in DownloadTracker.__table__.constraints
if isinstance(constraint, UniqueConstraint)
}
assert ("url_hash",) in unique_cols
def test_first_resource_id_is_integer_not_nullable(self):
col = _col(DownloadTracker, "first_resource_id")
assert isinstance(col.type, Integer)
assert col.nullable is False
def test_first_resource_id_has_foreign_key(self):
col = _col(DownloadTracker, "first_resource_id")
fk_targets = [fk.target_fullname for fk in col.foreign_keys]
assert "research_resources.id" in fk_targets
def test_file_hash_is_nullable(self):
col = _col(DownloadTracker, "file_hash")
assert isinstance(col.type, String)
assert col.type.length == 64
assert col.nullable is True
assert col.index is True
def test_file_path_is_nullable_text(self):
col = _col(DownloadTracker, "file_path")
assert isinstance(col.type, Text)
assert col.nullable is True
def test_file_name_is_nullable_string255_indexed(self):
col = _col(DownloadTracker, "file_name")
assert isinstance(col.type, String)
assert col.type.length == 255
assert col.nullable is True
assert col.index is True
def test_file_size_is_nullable_integer(self):
col = _col(DownloadTracker, "file_size")
assert isinstance(col.type, Integer)
assert col.nullable is True
def test_is_downloaded_is_boolean_not_nullable_indexed(self):
col = _col(DownloadTracker, "is_downloaded")
assert isinstance(col.type, Boolean)
assert col.nullable is False
assert col.index is True
def test_is_accessible_is_boolean(self):
col = _col(DownloadTracker, "is_accessible")
assert isinstance(col.type, Boolean)
def test_downloaded_at_is_nullable(self):
col = _col(DownloadTracker, "downloaded_at")
assert col.nullable is True
def test_first_seen_is_not_nullable(self):
col = _col(DownloadTracker, "first_seen")
assert col.nullable is False
def test_last_checked_is_not_nullable(self):
col = _col(DownloadTracker, "last_checked")
assert col.nullable is False
def test_library_document_id_is_nullable_with_fk(self):
col = _col(DownloadTracker, "library_document_id")
# documents.id is String(36) UUID; column type matches PK type so
# SQLite doesn't silently coerce via type-affinity (which produced
# the original mismatched-FK bug, see #3697).
assert isinstance(col.type, String)
assert col.type.length == 36
assert col.nullable is True
fk_targets = [fk.target_fullname for fk in col.foreign_keys]
assert "documents.id" in fk_targets
def test_id_is_primary_key_autoincrement(self):
col = _col(DownloadTracker, "id")
assert col.primary_key is True
assert col.autoincrement is True
class TestDownloadTrackerReprDownloaded:
"""Test 3: __repr__ shows 'downloaded' when is_downloaded=True."""
def test_repr_downloaded(self):
obj = _make(
DownloadTracker,
url_hash="abcdef1234567890",
is_downloaded=True,
)
result = repr(obj)
assert "downloaded" in result
assert "not downloaded" not in result
class TestDownloadTrackerReprNotDownloaded:
"""Test 4: __repr__ shows 'not downloaded' when is_downloaded=False."""
def test_repr_not_downloaded(self):
obj = _make(
DownloadTracker,
url_hash="abcdef1234567890",
is_downloaded=False,
)
result = repr(obj)
assert "not downloaded" in result
class TestDownloadTrackerReprTruncation:
"""Test 5: __repr__ truncates url_hash to first 8 characters."""
def test_repr_truncates_url_hash(self):
full_hash = "a1b2c3d4e5f6g7h8i9j0"
obj = _make(
DownloadTracker,
url_hash=full_hash,
is_downloaded=False,
)
result = repr(obj)
assert "a1b2c3d4..." in result
# The full hash should NOT appear in the repr
assert full_hash not in result
class TestDownloadTrackerDefaultIsDownloaded:
"""Test 6: Default is_downloaded is False."""
def test_default_is_downloaded(self):
col = _col(DownloadTracker, "is_downloaded")
assert col.default is not None
assert col.default.arg is False
class TestDownloadTrackerDefaultIsAccessible:
"""Test 7: Default is_accessible is True."""
def test_default_is_accessible(self):
col = _col(DownloadTracker, "is_accessible")
assert col.default is not None
assert col.default.arg is True
# ===========================================================================
# DownloadDuplicates
# ===========================================================================
class TestDownloadDuplicatesTablename:
"""Test 8: __tablename__ is 'download_duplicates'."""
def test_tablename(self):
assert DownloadDuplicates.__tablename__ == "download_duplicates"
class TestDownloadDuplicatesUniqueConstraint:
"""Test 9: Has UniqueConstraint on (url_hash, resource_id)."""
def test_unique_constraint_exists(self):
table = DownloadDuplicates.__table__
unique_constraints = [
c
for c in table.constraints
if c.__class__.__name__ == "UniqueConstraint"
]
# Find the constraint named 'uix_url_resource'
matching = [
c for c in unique_constraints if c.name == "uix_url_resource"
]
assert len(matching) == 1
col_names = {col.name for col in matching[0].columns}
assert col_names == {"url_hash", "resource_id"}
class TestDownloadDuplicatesRepr:
"""Test 10: __repr__ shows url_hash and resource_id."""
def test_repr_content(self):
obj = _make(
DownloadDuplicates,
url_hash="deadbeef12345678",
resource_id=42,
)
result = repr(obj)
assert "deadbeef..." in result
assert "resource_id=42" in result
assert "DownloadDuplicates" in result
class TestDownloadDuplicatesCompositeIndex:
"""Test 11: Has composite index on (research_id, url_hash)."""
def test_composite_index_exists(self):
table = DownloadDuplicates.__table__
index_names = {idx.name for idx in table.indexes}
assert "idx_research_duplicates" in index_names
# Verify the columns in the index
target_index = next(
idx
for idx in table.indexes
if idx.name == "idx_research_duplicates"
)
col_names = [col.name for col in target_index.columns]
assert "research_id" in col_names
assert "url_hash" in col_names
class TestDownloadDuplicatesColumns:
"""Additional column validation for DownloadDuplicates."""
def test_url_hash_not_nullable_with_fk(self):
col = _col(DownloadDuplicates, "url_hash")
assert col.nullable is False
fk_targets = [fk.target_fullname for fk in col.foreign_keys]
assert "download_tracker.url_hash" in fk_targets
def test_resource_id_not_nullable_with_fk(self):
col = _col(DownloadDuplicates, "resource_id")
assert col.nullable is False
fk_targets = [fk.target_fullname for fk in col.foreign_keys]
assert "research_resources.id" in fk_targets
def test_research_id_not_nullable_indexed(self):
col = _col(DownloadDuplicates, "research_id")
assert isinstance(col.type, String)
assert col.type.length == 36
assert col.nullable is False
assert col.index is True
def test_added_at_not_nullable(self):
col = _col(DownloadDuplicates, "added_at")
assert col.nullable is False
# ===========================================================================
# DownloadAttempt
# ===========================================================================
class TestDownloadAttemptTablename:
"""Test 12: __tablename__ is 'download_attempts'."""
def test_tablename(self):
assert DownloadAttempt.__tablename__ == "download_attempts"
class TestDownloadAttemptReprSuccess:
"""Test 13: __repr__ shows 'success' when succeeded=True."""
def test_repr_success(self):
obj = _make(
DownloadAttempt,
attempt_number=1,
succeeded=True,
status_code=200,
error_type=None,
)
result = repr(obj)
assert "success" in result
assert "failed" not in result
assert "attempt=1" in result
class TestDownloadAttemptReprFailedWithStatusCode:
"""Test 14: __repr__ shows 'failed (404)' when succeeded=False, status_code=404."""
def test_repr_failed_status_code(self):
obj = _make(
DownloadAttempt,
attempt_number=2,
succeeded=False,
status_code=404,
error_type=None,
)
result = repr(obj)
assert "failed (404)" in result
assert "attempt=2" in result
class TestDownloadAttemptReprFailedWithErrorType:
"""Test 15: __repr__ shows 'failed (timeout)' when no status_code, error_type='timeout'."""
def test_repr_failed_error_type(self):
obj = _make(
DownloadAttempt,
attempt_number=3,
succeeded=False,
status_code=None,
error_type="timeout",
)
result = repr(obj)
assert "failed (timeout)" in result
assert "attempt=3" in result
class TestDownloadAttemptReprFailedNoInfo:
"""Test 16: __repr__ shows 'failed (None)' when no status_code or error_type."""
def test_repr_failed_none(self):
obj = _make(
DownloadAttempt,
attempt_number=1,
succeeded=False,
status_code=None,
error_type=None,
)
result = repr(obj)
assert "failed (None)" in result
class TestDownloadAttemptDefaultSucceeded:
"""Test 17: Default succeeded is False."""
def test_default_succeeded(self):
col = _col(DownloadAttempt, "succeeded")
assert col.default is not None
assert col.default.arg is False
class TestDownloadAttemptColumns:
"""Additional column validation for DownloadAttempt."""
def test_url_hash_not_nullable_with_fk(self):
col = _col(DownloadAttempt, "url_hash")
assert col.nullable is False
assert col.index is True
fk_targets = [fk.target_fullname for fk in col.foreign_keys]
assert "download_tracker.url_hash" in fk_targets
def test_attempt_number_not_nullable(self):
col = _col(DownloadAttempt, "attempt_number")
assert isinstance(col.type, Integer)
assert col.nullable is False
def test_status_code_nullable(self):
col = _col(DownloadAttempt, "status_code")
assert isinstance(col.type, Integer)
assert col.nullable is True
def test_error_type_nullable_string100(self):
col = _col(DownloadAttempt, "error_type")
assert isinstance(col.type, String)
assert col.type.length == 100
assert col.nullable is True
def test_error_message_nullable_text(self):
col = _col(DownloadAttempt, "error_message")
assert isinstance(col.type, Text)
assert col.nullable is True
def test_attempted_at_not_nullable(self):
col = _col(DownloadAttempt, "attempted_at")
assert col.nullable is False
def test_duration_ms_nullable(self):
col = _col(DownloadAttempt, "duration_ms")
assert isinstance(col.type, Integer)
assert col.nullable is True
def test_succeeded_not_nullable(self):
col = _col(DownloadAttempt, "succeeded")
assert isinstance(col.type, Boolean)
assert col.nullable is False
def test_bytes_downloaded_nullable(self):
col = _col(DownloadAttempt, "bytes_downloaded")
assert isinstance(col.type, Integer)
assert col.nullable is True
def test_id_primary_key(self):
col = _col(DownloadAttempt, "id")
assert col.primary_key is True
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,770 @@
"""
Extended tests for library models - Comprehensive coverage of unified document architecture.
Tests cover:
- Document model operations
- Collection model operations
- DocumentCollection (many-to-many) operations
- DocumentChunk model operations
- DownloadQueue model operations
- RAGIndex model operations
- LibraryStatistics model operations
- CollectionFolder and CollectionFolderFile models
"""
import uuid
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import Base
from local_deep_research.database.models.library import (
Document,
DocumentBlob,
Collection,
DocumentCollection,
DocumentChunk,
DownloadQueue,
LibraryStatistics,
RAGIndex,
CollectionFolder,
CollectionFolderFile,
SourceType,
UploadBatch,
DocumentStatus,
RAGIndexStatus,
EmbeddingProvider,
)
@pytest.fixture
def engine():
"""Create in-memory SQLite engine."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(engine):
"""Create database session."""
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
@pytest.fixture
def source_type(session):
"""Create a source type for testing."""
st = SourceType(
id=str(uuid.uuid4()),
name="research_download",
display_name="Research Download",
description="Downloaded from research sources",
)
session.add(st)
session.commit()
return st
@pytest.fixture
def collection(session):
"""Create a collection for testing."""
coll = Collection(
id=str(uuid.uuid4()),
name="Test Collection",
description="A test collection",
collection_type="user_collection",
is_default=False,
)
session.add(coll)
session.commit()
return coll
class TestDocumentModel:
"""Tests for Document model."""
def test_create_document(self, session, source_type):
"""Should create a document."""
doc = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash="abc123def456" * 5 + "ab", # 64 chars
file_size=1024,
file_type="pdf",
status=DocumentStatus.COMPLETED,
)
session.add(doc)
session.commit()
assert doc.id is not None
assert doc.file_size == 1024
def test_document_with_all_fields(self, session, source_type):
"""Should create document with all optional fields."""
doc = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash="xyz789" * 10 + "abcd",
file_size=2048,
file_type="pdf",
status=DocumentStatus.COMPLETED,
title="Test Paper",
description="A test paper description",
authors=["Author One", "Author Two"],
doi="10.1234/test.doi",
arxiv_id="2301.00001",
text_content="This is the paper content...",
extraction_method="pdf_extraction",
extraction_source="pdfplumber",
extraction_quality="high",
tags=["machine-learning", "nlp"],
)
session.add(doc)
session.commit()
retrieved = session.query(Document).filter_by(id=doc.id).first()
assert retrieved.title == "Test Paper"
assert retrieved.authors == ["Author One", "Author Two"]
def test_document_status_enum(self, session, source_type):
"""Document status should use enum values."""
doc = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash="status123" * 7 + "a",
file_size=512,
file_type="txt",
status=DocumentStatus.PENDING,
)
session.add(doc)
session.commit()
assert doc.status == DocumentStatus.PENDING
def test_document_unique_hash(self, session, source_type):
"""Document hash should be unique."""
hash_value = "unique_hash" * 5 + "abcd"
doc1 = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash=hash_value,
file_size=100,
file_type="pdf",
status=DocumentStatus.COMPLETED,
)
session.add(doc1)
session.commit()
doc2 = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash=hash_value, # Same hash
file_size=200,
file_type="pdf",
status=DocumentStatus.COMPLETED,
)
session.add(doc2)
with pytest.raises(Exception): # IntegrityError
session.commit()
def test_document_repr(self, session, source_type):
"""Document __repr__ should work."""
doc = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash="repr_test" * 8,
file_size=100,
file_type="pdf",
title="Repr Test",
status=DocumentStatus.COMPLETED,
)
repr_str = repr(doc)
assert "Document" in repr_str
class TestCollectionModel:
"""Tests for Collection model."""
def test_create_collection(self, session):
"""Should create a collection."""
coll = Collection(
id=str(uuid.uuid4()),
name="My Collection",
description="Test description",
)
session.add(coll)
session.commit()
assert coll.id is not None
assert coll.name == "My Collection"
def test_collection_default_values(self, session):
"""Collection should have correct defaults."""
coll = Collection(
id=str(uuid.uuid4()),
name="Default Test",
)
session.add(coll)
session.commit()
assert coll.is_default is False
assert coll.collection_type == "user_collection"
def test_collection_with_embedding_config(self, session):
"""Collection can store embedding configuration."""
coll = Collection(
id=str(uuid.uuid4()),
name="Embedding Collection",
embedding_model="all-MiniLM-L6-v2",
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
embedding_dimension=384,
chunk_size=512,
chunk_overlap=50,
)
session.add(coll)
session.commit()
retrieved = session.query(Collection).filter_by(id=coll.id).first()
assert retrieved.embedding_model == "all-MiniLM-L6-v2"
assert retrieved.embedding_dimension == 384
def test_collection_repr(self, session):
"""Collection __repr__ should work."""
coll = Collection(
id="test-id",
name="Repr Test",
collection_type="user_collection",
)
repr_str = repr(coll)
assert "Collection" in repr_str
class TestDocumentCollectionModel:
"""Tests for DocumentCollection many-to-many model."""
def test_link_document_to_collection(
self, session, source_type, collection
):
"""Should link document to collection."""
doc = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash="link_test" * 8,
file_size=100,
file_type="pdf",
status=DocumentStatus.COMPLETED,
)
session.add(doc)
session.commit()
link = DocumentCollection(
document_id=doc.id,
collection_id=collection.id,
indexed=False,
chunk_count=0,
)
session.add(link)
session.commit()
assert link.id is not None
def test_document_collection_unique_pair(
self, session, source_type, collection
):
"""Document-collection pair should be unique."""
doc = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash="unique_pair" * 6 + "ab",
file_size=100,
file_type="pdf",
status=DocumentStatus.COMPLETED,
)
session.add(doc)
session.commit()
link1 = DocumentCollection(
document_id=doc.id,
collection_id=collection.id,
)
session.add(link1)
session.commit()
link2 = DocumentCollection(
document_id=doc.id,
collection_id=collection.id, # Same pair
)
session.add(link2)
with pytest.raises(Exception): # IntegrityError
session.commit()
def test_document_collection_indexing_status(
self, session, source_type, collection
):
"""Should track indexing status per collection."""
doc = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash="index_status" * 6 + "12",
file_size=100,
file_type="pdf",
status=DocumentStatus.COMPLETED,
)
session.add(doc)
session.commit()
link = DocumentCollection(
document_id=doc.id,
collection_id=collection.id,
indexed=True,
chunk_count=25,
)
session.add(link)
session.commit()
retrieved = (
session.query(DocumentCollection)
.filter_by(document_id=doc.id)
.first()
)
assert retrieved.indexed is True
assert retrieved.chunk_count == 25
class TestDocumentChunkModel:
"""Tests for DocumentChunk model."""
def test_create_document_chunk(self, session):
"""Should create a document chunk."""
chunk = DocumentChunk(
chunk_hash="chunk_hash" * 6 + "ab",
source_type="document",
source_id=str(uuid.uuid4()),
collection_name="collection_abc123",
chunk_text="This is the chunk text content.",
chunk_index=0,
start_char=0,
end_char=31,
word_count=6,
embedding_id=str(uuid.uuid4()),
embedding_model="all-MiniLM-L6-v2",
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
)
session.add(chunk)
session.commit()
assert chunk.id is not None
def test_chunk_unique_per_collection(self, session):
"""Chunk hash should be unique per collection."""
chunk_hash = "duplicate_hash" * 5
chunk1 = DocumentChunk(
chunk_hash=chunk_hash,
source_type="document",
collection_name="collection_1",
chunk_text="Content 1",
chunk_index=0,
start_char=0,
end_char=10,
word_count=2,
embedding_id=str(uuid.uuid4()),
embedding_model="model",
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
)
session.add(chunk1)
session.commit()
# Same hash, same collection should fail
chunk2 = DocumentChunk(
chunk_hash=chunk_hash,
source_type="document",
collection_name="collection_1", # Same collection
chunk_text="Content 2",
chunk_index=1,
start_char=10,
end_char=20,
word_count=2,
embedding_id=str(uuid.uuid4()),
embedding_model="model",
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
)
session.add(chunk2)
with pytest.raises(Exception):
session.commit()
def test_chunk_repr(self, session):
"""DocumentChunk __repr__ should work."""
chunk = DocumentChunk(
chunk_hash="repr_test" * 8,
source_type="document",
collection_name="test_collection",
chunk_text="Test content",
chunk_index=5,
start_char=100,
end_char=200,
word_count=10,
embedding_id=str(uuid.uuid4()),
embedding_model="model",
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
)
repr_str = repr(chunk)
assert "DocumentChunk" in repr_str
class TestRAGIndexModel:
"""Tests for RAGIndex model."""
def test_create_rag_index(self, session):
"""Should create a RAG index."""
index = RAGIndex(
collection_name="collection_abc",
embedding_model="all-MiniLM-L6-v2",
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
embedding_dimension=384,
index_path="/data/indexes/collection_abc.faiss",
index_hash="index_hash" * 6 + "ab",
chunk_size=512,
chunk_overlap=50,
status=RAGIndexStatus.ACTIVE,
)
session.add(index)
session.commit()
assert index.id is not None
def test_rag_index_status_transitions(self, session):
"""RAG index status can transition."""
index = RAGIndex(
collection_name="status_test",
embedding_model="model",
embedding_model_type=EmbeddingProvider.OLLAMA,
embedding_dimension=768,
index_path="/path/to/index.faiss",
index_hash="status_hash" * 6 + "ab",
chunk_size=256,
chunk_overlap=25,
status=RAGIndexStatus.ACTIVE,
)
session.add(index)
session.commit()
# Update status
index.status = RAGIndexStatus.REBUILDING
session.commit()
retrieved = session.query(RAGIndex).filter_by(id=index.id).first()
assert retrieved.status == RAGIndexStatus.REBUILDING
def test_rag_index_repr(self, session):
"""RAGIndex __repr__ should work."""
index = RAGIndex(
collection_name="repr_collection",
embedding_model="test-model",
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
embedding_dimension=384,
index_path="/path/index.faiss",
index_hash="repr_hash" * 8,
chunk_size=512,
chunk_overlap=50,
chunk_count=100,
)
repr_str = repr(index)
assert "RAGIndex" in repr_str
class TestLibraryStatisticsModel:
"""Tests for LibraryStatistics model."""
def test_create_statistics(self, session):
"""Should create library statistics."""
stats = LibraryStatistics(
total_documents=100,
total_pdfs=80,
total_html=15,
total_other=5,
total_size_bytes=1024000,
average_document_size=10240,
)
session.add(stats)
session.commit()
assert stats.id is not None
def test_statistics_download_metrics(self, session):
"""Statistics should track download metrics."""
stats = LibraryStatistics(
total_documents=50,
total_download_attempts=100,
successful_downloads=45,
failed_downloads=5,
pending_downloads=50,
)
session.add(stats)
session.commit()
retrieved = (
session.query(LibraryStatistics).filter_by(id=stats.id).first()
)
assert retrieved.total_download_attempts == 100
assert retrieved.successful_downloads == 45
def test_statistics_repr(self, session):
"""LibraryStatistics __repr__ should work."""
stats = LibraryStatistics(
total_documents=50,
total_size_bytes=500000,
)
repr_str = repr(stats)
assert "LibraryStatistics" in repr_str
class TestDownloadQueueModel:
"""Tests for DownloadQueue model."""
def test_create_queue_item(self, session, collection):
"""Should create a download queue item."""
# Note: This requires a ResearchResource to exist
# For now, test the model structure
queue = DownloadQueue.__table__
columns = {c.name for c in queue.columns}
assert "resource_id" in columns
assert "research_id" in columns
assert "priority" in columns
assert "status" in columns
assert "attempts" in columns
class TestCollectionFolderModel:
"""Tests for CollectionFolder model."""
def test_create_collection_folder(self, session, collection):
"""Should create a collection folder link."""
folder = CollectionFolder(
collection_id=collection.id,
folder_path="/home/user/documents/research",
include_patterns=["*.pdf", "*.txt"],
recursive=True,
)
session.add(folder)
session.commit()
assert folder.id is not None
def test_folder_default_patterns(self, session, collection):
"""Folder should have default include patterns."""
folder = CollectionFolder(
collection_id=collection.id,
folder_path="/path/to/folder",
)
session.add(folder)
session.commit()
# Default patterns should include common document types
assert folder.include_patterns is not None
def test_folder_repr(self, session, collection):
"""CollectionFolder __repr__ should work."""
folder = CollectionFolder(
collection_id=collection.id,
folder_path="/test/path",
file_count=10,
)
repr_str = repr(folder)
assert "CollectionFolder" in repr_str
class TestCollectionFolderFileModel:
"""Tests for CollectionFolderFile model."""
def test_create_folder_file(self, session, collection):
"""Should create a folder file entry."""
folder = CollectionFolder(
collection_id=collection.id,
folder_path="/test/folder",
)
session.add(folder)
session.commit()
file = CollectionFolderFile(
folder_id=folder.id,
relative_path="subdir/document.pdf",
file_hash="file_hash" * 8,
file_size=2048,
file_type="pdf",
indexed=False,
)
session.add(file)
session.commit()
assert file.id is not None
def test_folder_file_unique_path(self, session, collection):
"""File path should be unique within folder."""
folder = CollectionFolder(
collection_id=collection.id,
folder_path="/unique/test",
)
session.add(folder)
session.commit()
file1 = CollectionFolderFile(
folder_id=folder.id,
relative_path="same/path.pdf",
)
session.add(file1)
session.commit()
file2 = CollectionFolderFile(
folder_id=folder.id,
relative_path="same/path.pdf", # Same path
)
session.add(file2)
with pytest.raises(Exception):
session.commit()
def test_folder_file_repr(self):
"""CollectionFolderFile __repr__ should work."""
file = CollectionFolderFile(
relative_path="test/file.pdf",
indexed=True,
)
repr_str = repr(file)
assert "CollectionFolderFile" in repr_str
class TestSourceTypeModel:
"""Tests for SourceType model."""
def test_create_source_type(self, session):
"""Should create a source type."""
st = SourceType(
id=str(uuid.uuid4()),
name="user_upload",
display_name="User Upload",
description="Uploaded by user",
icon="upload",
)
session.add(st)
session.commit()
assert st.id is not None
def test_source_type_unique_name(self, session):
"""Source type name should be unique."""
st1 = SourceType(
id=str(uuid.uuid4()),
name="unique_type",
display_name="Unique Type",
)
session.add(st1)
session.commit()
st2 = SourceType(
id=str(uuid.uuid4()),
name="unique_type", # Same name
display_name="Another Unique Type",
)
session.add(st2)
with pytest.raises(Exception):
session.commit()
def test_source_type_repr(self):
"""SourceType __repr__ should work."""
st = SourceType(
id="test-id",
name="test_type",
display_name="Test Type",
)
repr_str = repr(st)
assert "SourceType" in repr_str
class TestDocumentBlobModel:
"""Tests for DocumentBlob model."""
def test_create_document_blob(self, session, source_type):
"""Should create a document blob."""
doc = Document(
id=str(uuid.uuid4()),
source_type_id=source_type.id,
document_hash="blob_test" * 8,
file_size=1000,
file_type="pdf",
status=DocumentStatus.COMPLETED,
)
session.add(doc)
session.commit()
blob = DocumentBlob(
document_id=doc.id,
pdf_binary=b"PDF binary content here",
blob_hash="binary_hash" * 6 + "ab",
)
session.add(blob)
session.commit()
retrieved = (
session.query(DocumentBlob).filter_by(document_id=doc.id).first()
)
assert retrieved.pdf_binary == b"PDF binary content here"
def test_blob_repr(self, session, source_type):
"""DocumentBlob __repr__ should work."""
doc = Document(
id="test-doc-id-" + "x" * 24,
source_type_id=source_type.id,
document_hash="repr_blob" * 8,
file_size=100,
file_type="pdf",
status=DocumentStatus.COMPLETED,
)
blob = DocumentBlob(
document_id=doc.id,
pdf_binary=b"test",
)
repr_str = repr(blob)
assert "DocumentBlob" in repr_str
@pytest.mark.filterwarnings(
"ignore:UploadBatch is deprecated.*:DeprecationWarning"
)
class TestUploadBatchModel:
"""Tests for UploadBatch model (deprecated — see PR 11a)."""
def test_create_upload_batch(self, session, collection):
"""Should create an upload batch."""
batch = UploadBatch(
id=str(uuid.uuid4()),
collection_id=collection.id,
file_count=5,
total_size=10240,
)
session.add(batch)
session.commit()
assert batch.id is not None
assert batch.file_count == 5
def test_batch_repr(self):
"""UploadBatch __repr__ should work."""
batch = UploadBatch(
id="test-batch-id",
file_count=3,
total_size=5000,
)
repr_str = repr(batch)
assert "UploadBatch" in repr_str
File diff suppressed because it is too large Load Diff
+582
View File
@@ -0,0 +1,582 @@
"""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
# The reload discards the module's cached engine state and lock;
# restore the pre-test module dict so later tests keep working
# against the original objects.
snapshot = dict(auth_module.__dict__)
try:
with patch(
"local_deep_research.database.auth_db.init_auth_database"
) as mock_init:
importlib.reload(auth_module)
mock_init.assert_not_called()
finally:
auth_module.__dict__.clear()
auth_module.__dict__.update(snapshot)
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()
+198
View File
@@ -0,0 +1,198 @@
"""Tests for authentication-related database models."""
from datetime import datetime, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import Base, User
class TestUserModel:
"""Test suite for the User model."""
@pytest.fixture
def engine(self):
"""Create an in-memory SQLite database for testing."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(self, engine):
"""Create a database session for testing."""
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_create_user(self, session):
"""Test creating a new user."""
user = User(username="testuser")
session.add(user)
session.commit()
# Retrieve the user
saved_user = session.query(User).filter_by(username="testuser").first()
assert saved_user is not None
assert saved_user.username == "testuser"
assert saved_user.created_at is not None
assert saved_user.database_version == 1
def test_unique_username_constraint(self, session):
"""Test that usernames must be unique."""
user1 = User(username="testuser")
session.add(user1)
session.commit()
# Try to create another user with the same username
user2 = User(username="testuser")
session.add(user2)
with pytest.raises(IntegrityError):
session.commit()
def test_user_timestamps(self, session):
"""Test that created_at is set correctly."""
user = User(username="testuser")
# Before saving, created_at should be None
assert user.created_at is None
session.add(user)
session.commit()
# After saving, created_at should be set
assert user.created_at is not None
assert isinstance(user.created_at, datetime)
# Update last_login
user.last_login = datetime.now(timezone.utc)
session.commit()
# Verify last_login is set
assert user.last_login is not None
assert user.last_login >= user.created_at
def test_user_last_login(self, session):
"""Test last_login timestamp."""
user = User(username="testuser")
session.add(user)
session.commit()
# Initially last_login should be None
assert user.last_login is None
# Update last_login
login_time = datetime.now(timezone.utc)
user.last_login = login_time
session.commit()
# Verify last_login is set
saved_user = session.query(User).filter_by(username="testuser").first()
assert saved_user.last_login is not None
assert abs((saved_user.last_login - login_time).total_seconds()) < 1
def test_user_representation(self):
"""Test string representation of User model."""
user = User(username="testuser")
# Should have a meaningful string representation
assert repr(user) == "<User testuser>"
def test_database_path_property(self):
"""Test the database_path property generates consistent paths."""
user1 = User(username="testuser")
user2 = User(username="testuser")
user3 = User(username="different")
# Same username should generate same path
assert user1.database_path == user2.database_path
# Different username should generate different path
assert user1.database_path != user3.database_path
# Path should have expected format
assert user1.database_path.startswith("ldr_user_")
assert user1.database_path.endswith(".db")
def test_database_version_default(self, session):
"""Test that database_version defaults to 1."""
user = User(username="testuser")
session.add(user)
session.commit()
assert user.database_version == 1
# Can update version
user.database_version = 2
session.commit()
saved_user = session.query(User).filter_by(username="testuser").first()
assert saved_user.database_version == 2
def test_username_with_special_characters(self, session):
"""Test usernames with special characters."""
special_users = [
"user@email.com",
"user-with-dashes",
"user_with_underscores",
"user.with.dots",
"user123",
"UPPERCASE",
"مستخدم", # Arabic
"用户", # Chinese
"🎉user", # Emoji
]
for username in special_users:
user = User(username=username)
session.add(user)
session.commit()
# All should be saved successfully
assert session.query(User).count() == len(special_users)
# Each should have a valid database path
for username in special_users:
user = session.query(User).filter_by(username=username).first()
assert user is not None
assert user.database_path.startswith("ldr_user_")
assert user.database_path.endswith(".db")
# Path should be filesystem-safe (no special chars)
assert all(c.isalnum() or c in "_." for c in user.database_path)
def test_user_query_operations(self, session):
"""Test various query operations on User model."""
# Create multiple users
users = []
for i in range(5):
user = User(username=f"user{i}")
users.append(user)
session.add(user)
session.commit()
# Query all users
all_users = session.query(User).all()
assert len(all_users) == 5
# Query by username
user2 = session.query(User).filter_by(username="user2").first()
assert user2 is not None
assert user2.username == "user2"
# Query ordered by created_at
ordered_users = session.query(User).order_by(User.created_at).all()
assert len(ordered_users) == 5
# Query with limit
limited_users = session.query(User).limit(3).all()
assert len(limited_users) == 3
+455
View File
@@ -0,0 +1,455 @@
"""Tests for benchmark-related database models."""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import (
Base,
BenchmarkConfig,
BenchmarkProgress,
BenchmarkResult,
BenchmarkRun,
BenchmarkStatus,
DatasetType,
)
class TestBenchmarkModels:
"""Test suite for benchmark-related models."""
@pytest.fixture
def engine(self):
"""Create an in-memory SQLite database for testing."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(self, engine):
"""Create a database session for testing."""
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_benchmark_run_creation(self, session):
"""Test creating a BenchmarkRun."""
run = BenchmarkRun(
run_name="GPT-4 vs Llama Comparison",
config_hash="abc123def456",
query_hash_list=["hash1", "hash2", "hash3"],
search_config={"engine": "google", "max_results": 10},
evaluation_config={"model": "gpt-4", "temperature": 0.0},
datasets_config={"simpleqa": 100, "browsecomp": 50},
status=BenchmarkStatus.PENDING,
total_examples=150,
)
session.add(run)
session.commit()
# Verify the run
saved = session.query(BenchmarkRun).first()
assert saved is not None
assert saved.run_name == "GPT-4 vs Llama Comparison"
assert saved.config_hash == "abc123def456"
assert len(saved.query_hash_list) == 3
assert saved.total_examples == 150
assert saved.status == BenchmarkStatus.PENDING
def test_benchmark_status_progression(self, session):
"""Test status progression of a benchmark run."""
run = BenchmarkRun(
config_hash="test123",
query_hash_list=[],
search_config={},
evaluation_config={},
datasets_config={},
status=BenchmarkStatus.PENDING,
)
session.add(run)
session.commit()
# Progress through statuses
run.status = BenchmarkStatus.IN_PROGRESS
run.start_time = datetime.now(timezone.utc)
session.commit()
assert run.status == BenchmarkStatus.IN_PROGRESS
assert run.start_time is not None
# Complete the run
run.status = BenchmarkStatus.COMPLETED
run.end_time = datetime.now(timezone.utc)
run.overall_accuracy = 0.85
run.processing_rate = 5.2 # examples per minute
session.commit()
assert run.status == BenchmarkStatus.COMPLETED
assert run.end_time is not None
assert run.overall_accuracy == 0.85
def test_benchmark_result_creation(self, session):
"""Test creating BenchmarkResult records."""
# Create a benchmark run first
run = BenchmarkRun(
config_hash="test123",
query_hash_list=["q1", "q2"],
search_config={},
evaluation_config={},
datasets_config={},
status=BenchmarkStatus.IN_PROGRESS,
)
session.add(run)
session.commit()
# Create results
result1 = BenchmarkResult(
benchmark_run_id=run.id,
example_id="simpleqa_001",
query_hash="hash_q1",
dataset_type=DatasetType.SIMPLEQA,
research_id="research-uuid-123",
question="What is the capital of France?",
correct_answer="Paris",
response="The capital of France is Paris.",
extracted_answer="Paris",
confidence="high",
processing_time=2.5,
is_correct=True,
graded_confidence="high",
)
result2 = BenchmarkResult(
benchmark_run_id=run.id,
example_id="browsecomp_001",
query_hash="hash_q2",
dataset_type=DatasetType.BROWSECOMP,
research_id="research-uuid-124",
question="Compare Python and JavaScript",
correct_answer="Python is interpreted, JavaScript runs in browsers",
response="Python and JavaScript are both popular languages...",
extracted_answer="Different use cases",
confidence="medium",
processing_time=5.2,
is_correct=False,
research_error="Timeout during search",
)
session.add_all([result1, result2])
session.commit()
# Verify results
results = session.query(BenchmarkResult).all()
assert len(results) == 2
correct_result = (
session.query(BenchmarkResult).filter_by(is_correct=True).first()
)
assert correct_result.extracted_answer == "Paris"
assert correct_result.dataset_type == DatasetType.SIMPLEQA
failed_result = (
session.query(BenchmarkResult).filter_by(is_correct=False).first()
)
assert failed_result.research_error == "Timeout during search"
def test_benchmark_config_management(self, session):
"""Test BenchmarkConfig for saving and reusing configurations."""
config = BenchmarkConfig(
name="High Accuracy Config",
description="Configuration optimized for accuracy over speed",
config_hash="config_abc123",
search_config={
"engines": ["google", "bing", "semantic_scholar"],
"max_results": 20,
"timeout": 30,
},
evaluation_config={
"model": "gpt-4",
"temperature": 0.0,
"max_retries": 3,
},
datasets_config={
"simpleqa": 200,
"browsecomp": 100,
"sample_ratio": 0.1,
},
is_default=True,
is_public=True,
)
session.add(config)
session.commit()
# Test updating usage stats
config.usage_count += 1
config.last_used = datetime.now(timezone.utc)
config.best_accuracy = 0.92
config.avg_processing_rate = 4.5
session.commit()
# Verify config
saved = (
session.query(BenchmarkConfig).filter_by(is_default=True).first()
)
assert saved is not None
assert saved.name == "High Accuracy Config"
assert saved.usage_count == 1
assert saved.best_accuracy == 0.92
assert "semantic_scholar" in saved.search_config["engines"]
def test_benchmark_progress_tracking(self, session):
"""Test BenchmarkProgress for real-time tracking."""
# Create a benchmark run
run = BenchmarkRun(
config_hash="test123",
query_hash_list=["q1", "q2", "q3"],
search_config={},
evaluation_config={},
datasets_config={},
status=BenchmarkStatus.IN_PROGRESS,
total_examples=100,
)
session.add(run)
session.commit()
# Add progress updates
progress1 = BenchmarkProgress(
benchmark_run_id=run.id,
completed_examples=25,
total_examples=100,
overall_accuracy=0.88,
dataset_accuracies={"simpleqa": 0.90, "browsecomp": 0.85},
processing_rate=3.2,
estimated_completion=datetime.now(timezone.utc)
+ timedelta(minutes=20),
current_dataset=DatasetType.SIMPLEQA,
current_example_id="simpleqa_025",
memory_usage=512.5,
cpu_usage=45.2,
)
progress2 = BenchmarkProgress(
benchmark_run_id=run.id,
completed_examples=50,
total_examples=100,
overall_accuracy=0.86,
dataset_accuracies={"simpleqa": 0.89, "browsecomp": 0.83},
processing_rate=3.5,
estimated_completion=datetime.now(timezone.utc)
+ timedelta(minutes=15),
current_dataset=DatasetType.BROWSECOMP,
current_example_id="browsecomp_010",
)
session.add_all([progress1, progress2])
session.commit()
# Query progress updates
progress_updates = (
session.query(BenchmarkProgress)
.filter_by(benchmark_run_id=run.id)
.order_by(BenchmarkProgress.timestamp)
.all()
)
assert len(progress_updates) == 2
assert progress_updates[0].completed_examples == 25
assert progress_updates[1].completed_examples == 50
assert (
progress_updates[1].processing_rate
> progress_updates[0].processing_rate
)
def test_benchmark_relationships(self, session):
"""Test relationships between benchmark models."""
# Create a run
run = BenchmarkRun(
run_name="Test Run",
config_hash="test123",
query_hash_list=["q1"],
search_config={},
evaluation_config={},
datasets_config={},
status=BenchmarkStatus.COMPLETED,
)
session.add(run)
session.commit()
# Add multiple results
for i in range(5):
result = BenchmarkResult(
benchmark_run_id=run.id,
example_id=f"example_{i}",
query_hash=f"hash_{i}",
dataset_type=DatasetType.SIMPLEQA,
question=f"Question {i}",
correct_answer=f"Answer {i}",
is_correct=i % 2 == 0, # Alternate correct/incorrect
)
session.add(result)
session.commit()
# Test relationship queries
run_with_results = session.query(BenchmarkRun).first()
results = run_with_results.results.all()
assert len(results) == 5
# Count correct results
correct_count = run_with_results.results.filter_by(
is_correct=True
).count()
assert correct_count == 3
def test_unique_constraints(self, session):
"""Test unique constraints on benchmark models."""
run = BenchmarkRun(
config_hash="test123",
query_hash_list=["q1"],
search_config={},
evaluation_config={},
datasets_config={},
)
session.add(run)
session.commit()
# Add a result
result1 = BenchmarkResult(
benchmark_run_id=run.id,
example_id="test_001",
query_hash="unique_hash",
dataset_type=DatasetType.SIMPLEQA,
question="Test question",
correct_answer="Test answer",
)
session.add(result1)
session.commit()
# Try to add duplicate (same run_id and query_hash)
result2 = BenchmarkResult(
benchmark_run_id=run.id,
example_id="test_002",
query_hash="unique_hash", # Same hash
dataset_type=DatasetType.SIMPLEQA,
question="Different question",
correct_answer="Different answer",
)
session.add(result2)
with pytest.raises(IntegrityError):
session.commit()
def test_benchmark_error_handling(self, session):
"""Test error tracking in benchmark runs."""
run = BenchmarkRun(
config_hash="test123",
query_hash_list=[],
search_config={},
evaluation_config={},
datasets_config={},
status=BenchmarkStatus.FAILED,
error_message="Failed to connect to evaluation model API",
)
session.add(run)
session.commit()
# Add failed results
result = BenchmarkResult(
benchmark_run_id=run.id,
example_id="failed_001",
query_hash="hash_failed",
dataset_type=DatasetType.SIMPLEQA,
question="What is 2+2?",
correct_answer="4",
research_error="Search timeout after 30 seconds",
evaluation_error="Could not parse model response",
)
session.add(result)
session.commit()
# Verify error tracking
failed_run = (
session.query(BenchmarkRun)
.filter_by(status=BenchmarkStatus.FAILED)
.first()
)
assert (
failed_run.error_message
== "Failed to connect to evaluation model API"
)
failed_result = (
session.query(BenchmarkResult)
.filter(BenchmarkResult.research_error.isnot(None))
.first()
)
assert "timeout" in failed_result.research_error
assert failed_result.evaluation_error is not None
def test_benchmark_statistics(self, session):
"""Test calculating statistics from benchmark results."""
# Create a completed run
run = BenchmarkRun(
config_hash="test123",
query_hash_list=["q1", "q2", "q3", "q4", "q5"],
search_config={},
evaluation_config={},
datasets_config={},
status=BenchmarkStatus.COMPLETED,
total_examples=5,
completed_examples=5,
)
session.add(run)
session.commit()
# Add results with varying accuracy
accuracies = [True, True, False, True, False] # 3/5 = 60% accuracy
for i, is_correct in enumerate(accuracies):
result = BenchmarkResult(
benchmark_run_id=run.id,
example_id=f"test_{i}",
query_hash=f"hash_{i}",
dataset_type=DatasetType.SIMPLEQA
if i < 3
else DatasetType.BROWSECOMP,
question=f"Question {i}",
correct_answer=f"Answer {i}",
is_correct=is_correct,
processing_time=2.0 + i * 0.5,
)
session.add(result)
session.commit()
# Calculate statistics
results = (
session.query(BenchmarkResult)
.filter_by(benchmark_run_id=run.id)
.all()
)
correct_count = sum(1 for r in results if r.is_correct)
accuracy = correct_count / len(results)
assert accuracy == 0.6
# Calculate per-dataset accuracy
simpleqa_results = [
r for r in results if r.dataset_type == DatasetType.SIMPLEQA
]
simpleqa_accuracy = sum(
1 for r in simpleqa_results if r.is_correct
) / len(simpleqa_results)
assert simpleqa_accuracy == 2 / 3 # ~0.667
# Calculate average processing time
avg_time = sum(r.processing_time for r in results) / len(results)
assert avg_time == 3.0 # (2.0 + 2.5 + 3.0 + 3.5 + 4.0) / 5
+704
View File
@@ -0,0 +1,704 @@
"""Tests for chat database models."""
import pytest
from sqlalchemy import create_engine, event, text
from sqlalchemy.exc import IntegrityError
class TestChatSessionModel:
"""Tests for the ChatSession database model."""
def test_chat_session_table_exists(self, setup_database_for_all_tests):
"""Test that the chat_sessions table exists in the database."""
from src.local_deep_research.database.models import Base
# Get all table names from metadata
table_names = set(Base.metadata.tables.keys())
assert "chat_sessions" in table_names, (
"chat_sessions table is missing from database schema. "
"Ensure the ChatSession model is properly defined and imported."
)
def test_chat_session_has_required_columns(
self, setup_database_for_all_tests
):
"""Test that ChatSession has all required columns."""
from src.local_deep_research.database.models.chat import ChatSession
required_columns = {
"id",
"title",
"status",
"accumulated_context",
"created_at",
"message_count",
}
actual_columns = set(ChatSession.__table__.columns.keys())
missing = required_columns - actual_columns
assert not missing, (
f"ChatSession is missing required columns: {missing}\n"
"This will break chat session storage."
)
def test_chat_session_json_fields_serialize(
self, setup_database_for_all_tests
):
"""Test that accumulated_context JSON field serializes correctly."""
from datetime import datetime, UTC
from src.local_deep_research.database.models.chat import ChatSession
SessionLocal = setup_database_for_all_tests
session = SessionLocal()
try:
test_context = {
"key_entities": ["test1", "test2"],
"topics": ["topic1"],
"summary": "",
}
chat_session = ChatSession(
id="test-session-json-1",
title="Test Session",
status="active",
accumulated_context=test_context,
created_at=datetime.now(UTC),
)
session.add(chat_session)
session.commit()
retrieved = (
session.query(ChatSession)
.filter_by(id="test-session-json-1")
.first()
)
assert retrieved is not None
assert retrieved.accumulated_context == test_context
finally:
session.rollback()
session.close()
def test_chat_session_status_default_is_active(
self, setup_database_for_all_tests
):
"""Test that default status is 'active'."""
from datetime import datetime, UTC
from src.local_deep_research.database.models.chat import ChatSession
SessionLocal = setup_database_for_all_tests
session = SessionLocal()
try:
chat_session = ChatSession(
id="test-session-default-1",
title="Test Session",
created_at=datetime.now(UTC),
)
session.add(chat_session)
session.commit()
retrieved = (
session.query(ChatSession)
.filter_by(id="test-session-default-1")
.first()
)
assert retrieved is not None
assert retrieved.status == "active"
finally:
session.rollback()
session.close()
class TestChatMessageModel:
"""Tests for the ChatMessage database model."""
def test_chat_message_table_exists(self, setup_database_for_all_tests):
"""Test that the chat_messages table exists in the database."""
from src.local_deep_research.database.models import Base
table_names = set(Base.metadata.tables.keys())
assert "chat_messages" in table_names, (
"chat_messages table is missing from database schema. "
"Ensure the ChatMessage model is properly defined and imported."
)
def test_chat_message_has_required_columns(
self, setup_database_for_all_tests
):
"""Test that ChatMessage has all required columns."""
from src.local_deep_research.database.models.chat import ChatMessage
required_columns = {
"id",
"session_id",
"role",
"content",
"message_type",
"research_id",
"sequence_number",
"created_at",
}
actual_columns = set(ChatMessage.__table__.columns.keys())
missing = required_columns - actual_columns
assert not missing, (
f"ChatMessage is missing required columns: {missing}\n"
"This will break chat message storage."
)
def test_chat_message_foreign_key_to_session(
self, setup_database_for_all_tests
):
"""Test that ChatMessage has foreign key relationship to ChatSession."""
from src.local_deep_research.database.models.chat import ChatMessage
# Check foreign keys
foreign_keys = [
fk.target_fullname for fk in ChatMessage.__table__.foreign_keys
]
assert any("chat_sessions" in fk for fk in foreign_keys), (
"ChatMessage should have a foreign key to chat_sessions table"
)
def test_chat_message_research_id_nullable(
self, setup_database_for_all_tests
):
"""Test that research_id column is nullable."""
from datetime import datetime, UTC
from src.local_deep_research.database.models.chat import (
ChatMessage,
ChatSession,
)
SessionLocal = setup_database_for_all_tests
session = SessionLocal()
try:
now = datetime.now(UTC)
# Create session first
chat_session = ChatSession(
id="test-session-nullable-1",
title="Test Session",
created_at=now,
)
session.add(chat_session)
session.commit()
# Create message without research_id
message = ChatMessage(
id="test-msg-nullable-1",
session_id="test-session-nullable-1",
role="user",
content="Test question",
message_type="query",
sequence_number=1,
created_at=now,
# research_id intentionally omitted
)
session.add(message)
session.commit()
# Retrieve and verify
retrieved = (
session.query(ChatMessage)
.filter_by(id="test-msg-nullable-1")
.first()
)
assert retrieved is not None
assert retrieved.research_id is None
finally:
session.rollback()
session.close()
class TestChatModelCascade:
"""Tests for cascade delete behavior between ChatSession and ChatMessage."""
def test_delete_session_cascades_to_messages(
self, setup_database_for_all_tests
):
"""Test that deleting a session also deletes its messages."""
from datetime import datetime, UTC
from src.local_deep_research.database.models.chat import (
ChatSession,
ChatMessage,
)
SessionLocal = setup_database_for_all_tests
session = SessionLocal()
try:
now = datetime.now(UTC)
# Create session
chat_session = ChatSession(
id="test-session-cascade-1",
title="Test Session",
created_at=now,
)
session.add(chat_session)
session.commit()
# Create messages
for i in range(3):
message = ChatMessage(
id=f"test-msg-cascade-{i}",
session_id="test-session-cascade-1",
role="user" if i % 2 == 0 else "assistant",
content=f"Message {i}",
message_type="query" if i % 2 == 0 else "response",
sequence_number=i + 1,
created_at=now,
)
session.add(message)
session.commit()
# Verify messages exist
messages_before = (
session.query(ChatMessage)
.filter_by(session_id="test-session-cascade-1")
.all()
)
assert len(messages_before) == 3
# Delete session
session.delete(chat_session)
session.commit()
# Verify messages were also deleted
messages_after = (
session.query(ChatMessage)
.filter_by(session_id="test-session-cascade-1")
.all()
)
assert len(messages_after) == 0
finally:
session.rollback()
session.close()
def test_messages_ordered_by_sequence_number(
self, setup_database_for_all_tests
):
"""Test that messages are properly ordered by sequence number."""
from datetime import datetime, UTC
from src.local_deep_research.database.models.chat import (
ChatSession,
ChatMessage,
)
SessionLocal = setup_database_for_all_tests
session = SessionLocal()
try:
now = datetime.now(UTC)
# Create session
chat_session = ChatSession(
id="test-session-order-1",
title="Test Session",
created_at=now,
)
session.add(chat_session)
session.commit()
# Create messages in random order
sequence_numbers = [3, 1, 4, 2, 5]
for seq in sequence_numbers:
message = ChatMessage(
id=f"test-msg-order-{seq}",
session_id="test-session-order-1",
role="user",
content=f"Message {seq}",
message_type="query",
sequence_number=seq,
created_at=now,
)
session.add(message)
session.commit()
# Query with order_by
messages = (
session.query(ChatMessage)
.filter_by(session_id="test-session-order-1")
.order_by(ChatMessage.sequence_number)
.all()
)
# Verify order
for i, msg in enumerate(messages):
assert msg.sequence_number == i + 1
finally:
session.rollback()
session.close()
class TestChatSessionStatus:
"""Tests for chat session status management."""
def test_session_can_be_archived(self, setup_database_for_all_tests):
"""Test that a session status can be changed to 'archived'."""
from datetime import datetime, UTC
from src.local_deep_research.database.models.chat import ChatSession
SessionLocal = setup_database_for_all_tests
session = SessionLocal()
try:
chat_session = ChatSession(
id="test-session-archive-1",
title="Test Session",
status="active",
created_at=datetime.now(UTC),
)
session.add(chat_session)
session.commit()
# Update status
chat_session.status = "archived"
session.commit()
retrieved = (
session.query(ChatSession)
.filter_by(id="test-session-archive-1")
.first()
)
assert retrieved.status == "archived"
finally:
session.rollback()
session.close()
class TestChatMessageContentNotNull:
"""Schema invariant: chat_messages.content is NOT NULL.
Negative tests ensuring the schema-level constraint is in place
and the application-layer validator agrees with it.
"""
def test_db_rejects_null_content(self, setup_database_for_all_tests):
"""Inserting a chat_messages row with content=NULL must raise
IntegrityError at commit time.
This pins the migration's ``content NOT NULL`` declaration.
"""
import pytest
from datetime import datetime, UTC
from sqlalchemy.exc import IntegrityError
from src.local_deep_research.database.models.chat import (
ChatMessage,
ChatSession,
)
SessionLocal = setup_database_for_all_tests
session = SessionLocal()
try:
# Parent session row
parent = ChatSession(
id="test-not-null-session",
created_at=datetime.now(UTC),
)
session.add(parent)
session.commit()
# Insert with content=None must fail at commit.
msg = ChatMessage(
id="test-not-null-msg",
session_id="test-not-null-session",
role="assistant",
message_type="response",
content=None,
sequence_number=1,
created_at=datetime.now(UTC),
)
session.add(msg)
with pytest.raises(IntegrityError):
session.commit()
finally:
session.rollback()
session.close()
def test_service_rejects_none_content_via_value_error(self):
"""ChatService.add_message validates content!=None before any DB
write, raising ValueError so the route layer can surface a 400."""
import pytest
from src.local_deep_research.chat.service import ChatService
service = ChatService(username="not-real-for-validator-test")
with pytest.raises(ValueError, match="content is required"):
service.add_message(
session_id="any",
role="assistant",
content=None,
message_type="response",
research_id="any",
)
# ---------------------------------------------------------------------------
# DB-level cascade / FK / uniqueness tests
#
# The shared `setup_database_for_all_tests` fixture in tests/conftest.py does
# NOT enable PRAGMA foreign_keys=ON — SQLite defaults FKs OFF, so DB-level
# `ondelete` rules silently no-op there. The tests below need real FK
# enforcement, so they use a private `fk_enforced_engine` fixture that mirrors
# the pattern at tests/database/test_research_strategy_fk_regression.py.
# ---------------------------------------------------------------------------
@pytest.fixture
def fk_enforced_engine(tmp_path):
"""Fresh-install SQLite engine with FK enforcement on for every connection."""
from src.local_deep_research.database.models import Base
db_path = tmp_path / "fk_test.db"
engine = create_engine(f"sqlite:///{db_path}")
@event.listens_for(engine, "connect")
def _enable_fk(dbapi_connection, _):
dbapi_connection.execute("PRAGMA foreign_keys = ON")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
def _seed_session(conn, sid="s1"):
conn.execute(
text(
"INSERT INTO chat_sessions "
"(id, status, message_count, created_at) "
"VALUES (:id, 'active', 0, '2026-01-01T00:00:00')"
),
{"id": sid},
)
def _seed_research(conn, rid="r1"):
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES (:id, 'q', 'quick', 'completed', "
"'2026-01-01T00:00:00')"
),
{"id": rid},
)
def _insert_chat_message(
conn, *, mid, sid, rid=None, seq=1, role="user", mtype="query"
):
conn.execute(
text(
"INSERT INTO chat_messages "
"(id, session_id, research_id, role, message_type, "
" content, sequence_number, created_at) "
"VALUES (:mid, :sid, :rid, :role, :mtype, 'x', :seq, "
"'2026-01-01T00:00:00')"
),
{
"mid": mid,
"sid": sid,
"rid": rid,
"role": role,
"mtype": mtype,
"seq": seq,
},
)
def _insert_progress_step(conn, *, pid, rid, sid, seq=1):
conn.execute(
text(
"INSERT INTO chat_progress_steps "
"(id, research_id, session_id, content, "
" sequence_number, created_at) "
"VALUES (:pid, :rid, :sid, 'step content', :seq, "
"'2026-01-01T00:00:00')"
),
{"pid": pid, "rid": rid, "sid": sid, "seq": seq},
)
class TestChatModelDBLevelCascade:
"""Cascade rules enforced by the database (FK pragma on, raw SQL DELETE)."""
def test_research_delete_sets_chat_messages_research_id_to_null(
self, fk_enforced_engine
):
with fk_enforced_engine.begin() as conn:
_seed_session(conn, sid="s1")
_seed_research(conn, rid="r1")
_insert_chat_message(conn, mid="m1", sid="s1", rid="r1")
with fk_enforced_engine.begin() as conn:
conn.execute(text("DELETE FROM research_history WHERE id='r1'"))
with fk_enforced_engine.connect() as conn:
row = conn.execute(
text("SELECT research_id FROM chat_messages WHERE id='m1'")
).first()
assert row is not None
assert row.research_id is None
def test_session_delete_cascades_chat_progress_steps(
self, fk_enforced_engine
):
with fk_enforced_engine.begin() as conn:
_seed_session(conn, sid="s1")
_seed_research(conn, rid="r1")
_insert_progress_step(conn, pid="p1", rid="r1", sid="s1")
with fk_enforced_engine.begin() as conn:
conn.execute(text("DELETE FROM chat_sessions WHERE id='s1'"))
with fk_enforced_engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM chat_progress_steps")
).scalar()
assert count == 0
def test_session_delete_cascades_chat_messages(self, fk_enforced_engine):
"""Migration 0009 declares ondelete=CASCADE on
chat_messages.session_id. The ORM-level cascade is exercised by
TestChatModelCascade::test_delete_session_cascades_to_messages,
but until this test was added the DB-level CASCADE behaviour was
only structurally defined, not verified — a migration regression
that silently dropped the ondelete clause would not have failed
any test. Drive the DELETE through raw SQL with the FK pragma on
so we exercise the migration's CASCADE, not SQLAlchemy's.
"""
with fk_enforced_engine.begin() as conn:
_seed_session(conn, sid="s1")
_insert_chat_message(conn, mid="m1", sid="s1", seq=1)
_insert_chat_message(conn, mid="m2", sid="s1", seq=2)
with fk_enforced_engine.begin() as conn:
conn.execute(text("DELETE FROM chat_sessions WHERE id='s1'"))
with fk_enforced_engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM chat_messages")
).scalar()
assert count == 0
def test_session_delete_sets_research_history_chat_session_id_null(
self, fk_enforced_engine
):
"""Migration 0009 declares ondelete=SET NULL on
research_history.chat_session_id so research artefacts survive
a chat-session delete (just unlinked). Until this test was added,
this behaviour was only structurally defined, not verified at the
DB level — only the symmetric chat_messages.research_id SET
NULL was covered. Seed a research row with a chat_session_id,
delete the session, and assert the FK column was nulled rather
than the row being cascade-deleted.
"""
with fk_enforced_engine.begin() as conn:
_seed_session(conn, sid="s1")
# Seed a research row with chat_session_id pointing at s1.
# The standard _seed_research helper does not set the FK,
# so do it inline here.
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at, "
" chat_session_id) "
"VALUES (:id, 'q', 'quick', 'completed', "
"'2026-01-01T00:00:00', :sid)"
),
{"id": "r1", "sid": "s1"},
)
with fk_enforced_engine.begin() as conn:
conn.execute(text("DELETE FROM chat_sessions WHERE id='s1'"))
with fk_enforced_engine.connect() as conn:
row = conn.execute(
text(
"SELECT id, chat_session_id "
"FROM research_history WHERE id='r1'"
)
).first()
# Row must still exist — SET NULL preserves research artefacts.
assert row is not None
assert row.chat_session_id is None
class TestChatModelDBLevelFKEnforcement:
"""Negative tests against DB-level FK + unique constraints."""
def test_chat_messages_fk_session_id_enforced(self, fk_enforced_engine):
# FK fires immediately at execute() under PRAGMA foreign_keys=ON, so
# pytest.raises must wrap the engine.begin() block (otherwise its
# __exit__ tries to commit an aborted DBAPI transaction).
with pytest.raises(IntegrityError):
with fk_enforced_engine.begin() as conn:
_insert_chat_message(conn, mid="m1", sid="nonexistent-session")
def test_chat_messages_unique_session_seq(self, fk_enforced_engine):
with fk_enforced_engine.begin() as conn:
_seed_session(conn, sid="s1")
_insert_chat_message(conn, mid="m1", sid="s1", seq=1)
with pytest.raises(IntegrityError):
with fk_enforced_engine.begin() as conn:
_insert_chat_message(conn, mid="m2", sid="s1", seq=1)
# The chat Enum columns deliberately omit a DB-level CHECK constraint
# (matching the migration). Enum-value enforcement is
# at the ORM/service layer (ChatRole(value) raises ValueError before
# any INSERT). The tests below pin THAT contract — both the fact that
# the raw INSERT succeeds (no CHECK) and that the service-layer guard
# catches invalid values.
def test_chat_messages_role_no_db_check_constraint(
self, fk_enforced_engine
):
"""Raw INSERT of an invalid `role` succeeds because the model has
no `create_constraint=True` — same as the migration. Schema must
agree between create_all (fresh installs) and the upgrade path."""
with fk_enforced_engine.begin() as conn:
_seed_session(conn, sid="s1")
with fk_enforced_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO chat_messages "
"(id, session_id, role, message_type, content, "
" sequence_number, created_at) "
"VALUES ('m1', 's1', 'BOGUS', 'query', 'x', 1, "
"'2026-01-01T00:00:00')"
)
)
def test_chat_messages_message_type_no_db_check_constraint(
self, fk_enforced_engine
):
"""Same as above for `message_type`."""
with fk_enforced_engine.begin() as conn:
_seed_session(conn, sid="s1")
with fk_enforced_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO chat_messages "
"(id, session_id, role, message_type, content, "
" sequence_number, created_at) "
"VALUES ('m1', 's1', 'user', 'BOGUS', 'x', 1, "
"'2026-01-01T00:00:00')"
)
)
def test_chat_sessions_status_no_db_check_constraint(
self, fk_enforced_engine
):
"""Same as above for `status`."""
with fk_enforced_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO chat_sessions "
"(id, status, message_count, created_at) "
"VALUES ('s1', 'BOGUS', 0, '2026-01-01T00:00:00')"
)
)
@@ -0,0 +1,176 @@
"""Concurrency tests for ``DatabaseManager.open_user_database`` cold-open.
Regression test for the race where two simultaneous first-opens of the same
user's database both ran Alembic migrations against one database file at once,
with the loser failing (Alembic's non-thread-safe module-level proxy / the
``alembic_version`` row update). The per-user init lock must serialize the
cold-open so the engine build + migration runs exactly once.
"""
import threading
import time
import pytest
import local_deep_research.database.initialize as init_mod
from local_deep_research.database.encrypted_db import DatabaseManager
@pytest.fixture
def db_manager(tmp_path, monkeypatch):
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: tmp_path,
)
manager = DatabaseManager()
yield manager
for username in list(manager.connections.keys()):
manager.close_user_database(username)
def test_get_init_lock_is_per_user_and_stable(db_manager):
"""Same user -> same lock (serializes); different users -> different locks."""
alice_1 = db_manager._get_init_lock("alice")
alice_2 = db_manager._get_init_lock("alice")
bob = db_manager._get_init_lock("bob")
assert alice_1 is alice_2
assert alice_1 is not bob
def test_concurrent_cold_open_runs_init_once(db_manager, monkeypatch):
"""N simultaneous first-opens of one user trigger exactly one cold-open."""
username, password = "raceuser", "TestPassword123!"
db_manager.create_user_database(username, password)
# Evict (and dispose) the cached engine so the next opens are cold-opens
# that hit the build + migrate path concurrently. The DB file remains.
db_manager.close_user_database(username)
n_threads = 8
calls = []
barrier = threading.Barrier(n_threads)
def counting_init(engine, *args, **kwargs):
# create_user_database already migrated the DB to head, so a no-op is
# correct here; the sleep widens the window so all threads pile up on
# the per-user lock while the first thread is inside the cold-open.
calls.append(threading.current_thread().name)
# Real wall-clock sleep widens the race window so the other threads
# pile up on the per-user lock; freezegun cannot model concurrent
# thread timing, so the sleep is intentional here.
time.sleep(0.25) # allow: unmarked-sleep
monkeypatch.setattr(init_mod, "initialize_database", counting_init)
results = []
errors = []
def worker():
barrier.wait()
try:
results.append(db_manager.open_user_database(username, password))
except Exception as exc: # noqa: BLE001 - record any failure
errors.append(exc)
threads = [
threading.Thread(target=worker, name=f"open-{i}")
for i in range(n_threads)
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30)
assert not errors, f"cold-open raced and failed: {errors!r}"
assert len(results) == n_threads
# The cold-open (hence the migration) ran exactly once despite n_threads
# simultaneous first-opens.
assert len(calls) == 1, (
f"init ran {len(calls)}x; expected 1 (cold-open not serialized)"
)
# Every caller received the single engine the cold-open built and cached.
assert results[0] is not None
assert all(r is results[0] for r in results)
def test_close_user_database_keeps_init_lock(db_manager):
"""close_user_database intentionally retains the per-user init lock.
Dropping it on close would let a concurrent open that already holds a
reference to the old lock race a later open that creates a fresh one --
two cold-opens migrating one DB file at once, the race the lock prevents.
The lock is kept (bounded, one small Lock per username); only
close_all_databases clears the dict wholesale, at shutdown.
"""
username, password = "lockcleanup", "TestPassword123!"
db_manager.create_user_database(username, password)
db_manager._get_init_lock(username) # ensure the lock exists
assert username in db_manager._init_locks
db_manager.close_user_database(username)
# Retained on purpose -- see docstring; only close_all clears it.
assert username in db_manager._init_locks
def test_close_all_databases_clears_init_locks(db_manager):
"""close_all_databases clears the per-user init-lock dict too."""
for name in ("user_a", "user_b"):
db_manager.create_user_database(name, "TestPassword123!")
db_manager._get_init_lock(name)
assert db_manager._init_locks
db_manager.close_all_databases()
assert db_manager._init_locks == {}
def test_concurrent_opens_of_different_users_run_in_parallel(
db_manager, monkeypatch
):
"""Cold-opens of *different* users must not serialize against each other.
The lock is deliberately per-user, not a single global init lock, so two
users opening at once proceed in parallel. This is asserted
deterministically (no timing): a 2-party barrier inside the patched init
only releases when *both* users' cold-opens are inside it simultaneously.
If the opens serialized (e.g. a regression to one global lock), the first
thread would block at the barrier while holding the lock and the second
could never reach it -> the barrier times out with BrokenBarrierError,
surfaced as an error and failing the test.
"""
users = [("alice_par", "TestPassword123!"), ("bob_par", "TestPassword123!")]
for name, pw in users:
db_manager.create_user_database(name, pw)
# Evict the cached engine so the next open is a cold-open.
db_manager.close_user_database(name)
both_inside_init = threading.Barrier(len(users))
def barrier_init(engine, *args, **kwargs):
# Both users' cold-opens must be in here at once for this to return;
# a global lock would deadlock one of them out and trip the timeout.
both_inside_init.wait(timeout=10)
monkeypatch.setattr(init_mod, "initialize_database", barrier_init)
results = []
errors = []
def worker(name, pw):
try:
results.append(db_manager.open_user_database(name, pw))
except Exception as exc: # noqa: BLE001 - record any failure
errors.append(exc)
threads = [
threading.Thread(target=worker, args=(n, p), name=f"open-{n}")
for n, p in users
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30)
assert not errors, f"cross-user opens serialized / failed: {errors!r}"
assert len(results) == len(users)
assert all(r is not None for r in results)
@@ -0,0 +1,90 @@
"""Tests for credential store base class."""
from datetime import timedelta
from freezegun import freeze_time
class ConcreteCredentialStore:
"""Concrete implementation for testing."""
def __init__(self, ttl_seconds: int):
from local_deep_research.database.credential_store_base import (
CredentialStoreBase,
)
class _Impl(CredentialStoreBase):
def store(self, key: str, username: str, password: str):
self._store_credentials(
key, {"username": username, "password": password}
)
def retrieve(self, key: str):
return self._retrieve_credentials(key)
self._impl = _Impl(ttl_seconds)
def store(self, key: str, username: str, password: str):
return self._impl.store(key, username, password)
def retrieve(self, key: str):
return self._impl.retrieve(key)
def clear_entry(self, key: str):
return self._impl.clear_entry(key)
class TestCredentialStoreBase:
def test_init(self):
store = ConcreteCredentialStore(ttl_seconds=3600)
assert store is not None
def test_store_and_retrieve(self):
store = ConcreteCredentialStore(ttl_seconds=3600)
store.store("key1", "user1", "pass1")
result = store.retrieve("key1")
assert result == ("user1", "pass1")
def test_retrieve_nonexistent(self):
store = ConcreteCredentialStore(ttl_seconds=3600)
result = store.retrieve("nonexistent")
assert result is None
def test_clear_entry(self):
store = ConcreteCredentialStore(ttl_seconds=3600)
store.store("key1", "user1", "pass1")
store.clear_entry("key1")
result = store.retrieve("key1")
assert result is None
def test_clear_nonexistent_entry(self):
store = ConcreteCredentialStore(ttl_seconds=3600)
# Should not raise
store.clear_entry("nonexistent")
def test_multiple_entries(self):
store = ConcreteCredentialStore(ttl_seconds=3600)
store.store("key1", "user1", "pass1")
store.store("key2", "user2", "pass2")
assert store.retrieve("key1") == ("user1", "pass1")
assert store.retrieve("key2") == ("user2", "pass2")
def test_overwrite_entry(self):
store = ConcreteCredentialStore(ttl_seconds=3600)
store.store("key1", "user1", "pass1")
store.store("key1", "user1_new", "pass1_new")
result = store.retrieve("key1")
assert result == ("user1_new", "pass1_new")
def test_expired_entry_returns_none(self):
store = ConcreteCredentialStore(ttl_seconds=1) # 1 second TTL
with freeze_time("2024-01-01 00:00:00") as frozen:
store.store("key1", "user1", "pass1")
# Wait for expiration
frozen.tick(timedelta(milliseconds=1100))
result = store.retrieve("key1")
assert result is None
@@ -0,0 +1,372 @@
"""
Behavioral tests for credential_store_base and temp_auth modules.
Tests the credential storage with TTL expiration.
"""
from freezegun import freeze_time
class TestCredentialStoreBaseInit:
"""Tests for CredentialStoreBase initialization."""
def test_temp_auth_store_initializes(self):
"""TemporaryAuthStore can be initialized."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
assert store is not None
def test_temp_auth_store_custom_ttl(self):
"""TemporaryAuthStore accepts custom TTL."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
assert store.ttl == 60
def test_default_ttl_is_10_seconds(self):
"""Default TTL is 10 seconds."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
assert store.ttl == 10
class TestCredentialStoreAuth:
"""Tests for store_auth and retrieve_auth."""
def test_store_auth_returns_token(self):
"""store_auth returns a token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store_auth("testuser", "testpass")
assert token is not None
assert isinstance(token, str)
assert len(token) > 0
def test_store_auth_tokens_are_unique(self):
"""Each store_auth call returns a unique token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token1 = store.store_auth("user1", "pass1")
token2 = store.store_auth("user2", "pass2")
assert token1 != token2
def test_retrieve_auth_returns_credentials(self):
"""retrieve_auth returns stored credentials."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store_auth("testuser", "testpass")
result = store.retrieve_auth(token)
assert result is not None
assert result[0] == "testuser"
assert result[1] == "testpass"
def test_retrieve_auth_removes_entry(self):
"""retrieve_auth removes the entry after retrieval."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store_auth("testuser", "testpass")
store.retrieve_auth(token)
# Second retrieval should return None
result = store.retrieve_auth(token)
assert result is None
def test_retrieve_auth_returns_none_for_invalid_token(self):
"""retrieve_auth returns None for invalid token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
result = store.retrieve_auth("invalid_token")
assert result is None
class TestCredentialStorePeek:
"""Tests for peek_auth functionality."""
def test_peek_auth_returns_credentials(self):
"""peek_auth returns stored credentials."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store_auth("testuser", "testpass")
result = store.peek_auth(token)
assert result is not None
assert result[0] == "testuser"
assert result[1] == "testpass"
def test_peek_auth_does_not_remove_entry(self):
"""peek_auth does not remove the entry."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store_auth("testuser", "testpass")
store.peek_auth(token)
# Entry should still be there
result = store.peek_auth(token)
assert result is not None
assert result[0] == "testuser"
def test_peek_auth_returns_none_for_invalid_token(self):
"""peek_auth returns None for invalid token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
result = store.peek_auth("invalid_token")
assert result is None
class TestCredentialStoreTTL:
"""Tests for TTL expiration."""
def test_credentials_expire_after_ttl(self):
"""Credentials expire after TTL."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
# SUT (credential_store_base) compares time.time() to
# entry["expires_at"], so freezegun can mock the clock fully.
with freeze_time("2026-01-01 00:00:00") as frozen:
store = TemporaryAuthStore(ttl_seconds=1)
token = store.store_auth("testuser", "testpass")
# Advance past expiration
frozen.tick(1.5)
result = store.retrieve_auth(token)
assert result is None
def test_credentials_valid_before_ttl(self):
"""Credentials are valid before TTL."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("testuser", "testpass")
result = store.retrieve_auth(token)
assert result is not None
class TestCredentialStoreClearEntry:
"""Tests for clear_entry functionality."""
def test_clear_entry_removes_credential(self):
"""clear_entry removes a specific credential."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store_auth("testuser", "testpass")
store.clear_entry(token)
result = store.retrieve_auth(token)
assert result is None
def test_clear_entry_only_affects_specified_token(self):
"""clear_entry only removes the specified token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token1 = store.store_auth("user1", "pass1")
token2 = store.store_auth("user2", "pass2")
store.clear_entry(token1)
# token2 should still be valid
result = store.peek_auth(token2)
assert result is not None
assert result[0] == "user2"
def test_clear_entry_handles_nonexistent_token(self):
"""clear_entry handles nonexistent token gracefully."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
# Should not raise
store.clear_entry("nonexistent_token")
class TestCredentialStoreAliases:
"""Tests for store/retrieve aliases."""
def test_store_alias_works(self):
"""store() method works as alias for store_auth()."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store("testuser", "testpass")
assert token is not None
def test_retrieve_alias_works(self):
"""retrieve() method works as alias for retrieve_auth()."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store("testuser", "testpass")
result = store.retrieve(token)
assert result is not None
assert result[0] == "testuser"
class TestCredentialStoreMultipleEntries:
"""Tests for multiple credential entries."""
def test_multiple_users_stored_separately(self):
"""Multiple users are stored separately."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token1 = store.store_auth("user1", "pass1")
token2 = store.store_auth("user2", "pass2")
token3 = store.store_auth("user3", "pass3")
result1 = store.peek_auth(token1)
result2 = store.peek_auth(token2)
result3 = store.peek_auth(token3)
assert result1[0] == "user1"
assert result2[0] == "user2"
assert result3[0] == "user3"
def test_same_user_can_have_multiple_tokens(self):
"""Same user can have multiple tokens."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token1 = store.store_auth("sameuser", "pass1")
token2 = store.store_auth("sameuser", "pass2")
assert token1 != token2
result1 = store.peek_auth(token1)
result2 = store.peek_auth(token2)
assert result1 is not None
assert result2 is not None
class TestCredentialStoreThreadSafety:
"""Tests for thread-safety features."""
def test_store_has_lock(self):
"""Store has a lock for thread safety."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
assert hasattr(store, "_lock")
def test_concurrent_stores_work(self):
"""Concurrent stores work correctly."""
import threading
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
tokens = []
errors = []
def store_auth():
try:
token = store.store_auth(
f"user_{threading.current_thread().name}", "pass"
)
tokens.append(token)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=store_auth) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
assert len(tokens) == 10
assert len(set(tokens)) == 10 # All unique
class TestGlobalTempAuthStore:
"""Tests for the global temp_auth_store instance."""
def test_global_instance_is_temporary_auth_store(self):
"""Global instance is TemporaryAuthStore."""
from local_deep_research.database.temp_auth import (
TemporaryAuthStore,
temp_auth_store,
)
assert isinstance(temp_auth_store, TemporaryAuthStore)
class TestCredentialStoreCleanup:
"""Tests for automatic cleanup of expired entries."""
def test_expired_entries_cleaned_on_store(self):
"""Expired entries are cleaned when storing new credentials."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
with freeze_time("2026-01-01 00:00:00") as frozen:
store = TemporaryAuthStore(ttl_seconds=1)
token1 = store.store_auth("user1", "pass1")
frozen.tick(1.5) # Advance past expiration
# Storing new entry should trigger cleanup
store.store_auth("user2", "pass2")
# token1 should be expired and cleaned
result = store.peek_auth(token1)
assert result is None
class TestCredentialStoreEdgeCases:
"""Tests for edge cases."""
def test_empty_username(self):
"""Handles empty username."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store_auth("", "password")
result = store.retrieve_auth(token)
assert result is not None
assert result[0] == ""
def test_empty_password(self):
"""Handles empty password."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
token = store.store_auth("username", "")
result = store.retrieve_auth(token)
assert result is not None
assert result[1] == ""
def test_special_characters_in_credentials(self):
"""Handles special characters in credentials."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
special_user = "user@example.com"
special_pass = "p@ss!w0rd#$%"
token = store.store_auth(special_user, special_pass)
result = store.retrieve_auth(token)
assert result is not None
assert result[0] == special_user
assert result[1] == special_pass
def test_unicode_in_credentials(self):
"""Handles unicode in credentials."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
unicode_user = "用户名"
unicode_pass = "密码🔐"
token = store.store_auth(unicode_user, unicode_pass)
result = store.retrieve_auth(token)
assert result is not None
assert result[0] == unicode_user
assert result[1] == unicode_pass
def test_long_credentials(self):
"""Handles long credentials."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
long_user = "a" * 1000
long_pass = "b" * 1000
token = store.store_auth(long_user, long_pass)
result = store.retrieve_auth(token)
assert result is not None
assert result[0] == long_user
assert result[1] == long_pass
@@ -0,0 +1,474 @@
"""
Extended tests for credential store base class.
Tests cover:
- TTL expiration behavior
- Concurrent access patterns
- Thread safety
- Edge cases and error conditions
- Memory management
- Multiple credentials handling
"""
import time
import threading
from datetime import timedelta
import pytest
from freezegun import freeze_time
from local_deep_research.database.credential_store_base import (
CredentialStoreBase,
)
class ConcreteCredentialStore(CredentialStoreBase):
"""Concrete implementation for testing."""
def store(self, key: str, username: str, password: str):
self._store_credentials(
key, {"username": username, "password": password}
)
def retrieve(self, key: str, remove: bool = False):
return self._retrieve_credentials(key, remove=remove)
@pytest.fixture
def store():
"""Create a credential store with 1 hour TTL."""
return ConcreteCredentialStore(ttl_seconds=3600)
@pytest.fixture
def short_ttl_store():
"""Create a credential store with very short TTL."""
return ConcreteCredentialStore(ttl_seconds=1)
class TestCredentialStoreInitialization:
"""Tests for credential store initialization."""
def test_store_initializes_with_ttl(self):
"""Store should initialize with given TTL."""
store = ConcreteCredentialStore(ttl_seconds=7200)
assert store.ttl == 7200
def test_store_initializes_empty(self):
"""Store should start empty."""
store = ConcreteCredentialStore(ttl_seconds=3600)
assert len(store._store) == 0
def test_store_has_lock(self):
"""Store should have a threading lock."""
store = ConcreteCredentialStore(ttl_seconds=3600)
assert hasattr(store, "_lock")
def test_zero_ttl_store(self):
"""Store with zero TTL should immediately expire entries."""
store = ConcreteCredentialStore(ttl_seconds=0)
with freeze_time("2024-01-01 00:00:00") as frozen:
store.store("key1", "user", "pass")
# Any forward progress past expires_at causes expiration
frozen.tick(timedelta(microseconds=1))
assert store.retrieve("key1") is None
class TestCredentialStorage:
"""Tests for credential storage operations."""
def test_store_single_credential(self, store):
"""Should store a single credential."""
store.store("key1", "user1", "pass1")
result = store.retrieve("key1")
assert result == ("user1", "pass1")
def test_store_multiple_credentials(self, store):
"""Should store multiple credentials."""
store.store("key1", "user1", "pass1")
store.store("key2", "user2", "pass2")
store.store("key3", "user3", "pass3")
assert store.retrieve("key1") == ("user1", "pass1")
assert store.retrieve("key2") == ("user2", "pass2")
assert store.retrieve("key3") == ("user3", "pass3")
def test_store_overwrites_existing(self, store):
"""Storing with same key should overwrite."""
store.store("key1", "user1", "pass1")
store.store("key1", "user2", "pass2")
result = store.retrieve("key1")
assert result == ("user2", "pass2")
def test_store_with_empty_username(self, store):
"""Should handle empty username."""
store.store("key1", "", "pass1")
result = store.retrieve("key1")
assert result == ("", "pass1")
def test_store_with_empty_password(self, store):
"""Should handle empty password."""
store.store("key1", "user1", "")
result = store.retrieve("key1")
assert result == ("user1", "")
def test_store_with_unicode_credentials(self, store):
"""Should handle unicode credentials."""
store.store("key1", "用户名", "密码")
result = store.retrieve("key1")
assert result == ("用户名", "密码")
def test_store_with_special_characters(self, store):
"""Should handle special characters."""
store.store("key1", "user@domain.com", "p@ss!word#123$")
result = store.retrieve("key1")
assert result == ("user@domain.com", "p@ss!word#123$")
class TestCredentialRetrieval:
"""Tests for credential retrieval operations."""
def test_retrieve_nonexistent_key(self, store):
"""Should return None for nonexistent key."""
assert store.retrieve("nonexistent") is None
def test_retrieve_without_remove(self, store):
"""Retrieve without remove should preserve entry."""
store.store("key1", "user1", "pass1")
store.retrieve("key1", remove=False)
# Should still be retrievable
assert store.retrieve("key1") == ("user1", "pass1")
def test_retrieve_with_remove(self, store):
"""Retrieve with remove should delete entry."""
store.store("key1", "user1", "pass1")
result = store.retrieve("key1", remove=True)
assert result == ("user1", "pass1")
# Should be gone now
assert store.retrieve("key1") is None
def test_retrieve_multiple_times(self, store):
"""Should be able to retrieve multiple times without remove."""
store.store("key1", "user1", "pass1")
for _ in range(10):
result = store.retrieve("key1")
assert result == ("user1", "pass1")
class TestTTLExpiration:
"""Tests for TTL expiration behavior."""
def test_entry_expires_after_ttl(self, short_ttl_store):
"""Entry should expire after TTL.
This test deliberately uses a real ``time.sleep`` instead of
``freeze_time`` so that the rest of the freezegun-based suite is
validated against the actual ``time.time()`` clock at least once.
Don't migrate this one without keeping another integration anchor.
"""
short_ttl_store.store("key1", "user1", "pass1")
time.sleep(1.5) # allow: unmarked-sleep # Wait for TTL + buffer
assert short_ttl_store.retrieve("key1") is None
def test_entry_valid_before_ttl(self, short_ttl_store):
"""Entry should be valid before TTL expires."""
with freeze_time("2024-01-01 00:00:00") as frozen:
short_ttl_store.store("key1", "user1", "pass1")
frozen.tick(timedelta(milliseconds=500)) # Half of 1s TTL
assert short_ttl_store.retrieve("key1") == ("user1", "pass1")
def test_each_entry_has_own_ttl(self):
"""Each entry should have its own expiration time."""
store = ConcreteCredentialStore(ttl_seconds=2)
with freeze_time("2024-01-01 00:00:00") as frozen:
store.store("key1", "user1", "pass1")
frozen.tick(timedelta(seconds=1))
store.store("key2", "user2", "pass2") # Added 1s later
frozen.tick(timedelta(milliseconds=1500))
# key1 should be expired (2.5s old)
# key2 should still be valid (1.5s old)
assert store.retrieve("key1") is None
assert store.retrieve("key2") == ("user2", "pass2")
def test_overwrite_resets_ttl(self):
"""Overwriting an entry should reset its TTL."""
store = ConcreteCredentialStore(ttl_seconds=1)
with freeze_time("2024-01-01 00:00:00") as frozen:
store.store("key1", "user1", "pass1")
frozen.tick(timedelta(milliseconds=700))
store.store("key1", "user1", "pass1") # Reset TTL
frozen.tick(timedelta(milliseconds=700))
# Should still be valid (0.7s since reset, TTL is 1s)
assert store.retrieve("key1") == ("user1", "pass1")
class TestCleanupExpired:
"""Tests for cleanup of expired entries."""
def test_cleanup_removes_expired(self):
"""Cleanup should remove expired entries."""
store = ConcreteCredentialStore(ttl_seconds=1)
with freeze_time("2024-01-01 00:00:00") as frozen:
store.store("key1", "user1", "pass1")
store.store("key2", "user2", "pass2")
frozen.tick(timedelta(milliseconds=1500))
# Trigger cleanup by storing new entry
store.store("key3", "user3", "pass3")
# Old entries should be cleaned up
assert store.retrieve("key1") is None
assert store.retrieve("key2") is None
assert store.retrieve("key3") == ("user3", "pass3")
def test_cleanup_preserves_valid_entries(self, store):
"""Cleanup should preserve non-expired entries."""
store.store("key1", "user1", "pass1")
store.store("key2", "user2", "pass2")
# Trigger cleanup
store._cleanup_expired()
# All should still be valid (TTL is 1 hour)
assert store.retrieve("key1") == ("user1", "pass1")
assert store.retrieve("key2") == ("user2", "pass2")
class TestClearEntry:
"""Tests for clear_entry method."""
def test_clear_existing_entry(self, store):
"""Should clear an existing entry."""
store.store("key1", "user1", "pass1")
store.clear_entry("key1")
assert store.retrieve("key1") is None
def test_clear_nonexistent_entry(self, store):
"""Should handle clearing nonexistent entry."""
# Should not raise
store.clear_entry("nonexistent")
def test_clear_does_not_affect_other_entries(self, store):
"""Clearing one entry should not affect others."""
store.store("key1", "user1", "pass1")
store.store("key2", "user2", "pass2")
store.clear_entry("key1")
assert store.retrieve("key1") is None
assert store.retrieve("key2") == ("user2", "pass2")
class TestThreadSafety:
"""Tests for thread safety."""
def test_concurrent_stores(self, store):
"""Concurrent stores should be thread-safe."""
results = {"errors": []}
def store_entry(key, username, password):
try:
store.store(key, username, password)
except Exception as e:
results["errors"].append(str(e))
threads = [
threading.Thread(
target=store_entry, args=(f"key{i}", f"user{i}", f"pass{i}")
)
for i in range(100)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(results["errors"]) == 0
def test_concurrent_retrieves(self, store):
"""Concurrent retrieves should be thread-safe."""
store.store("shared_key", "user", "pass")
results = []
lock = threading.Lock()
def retrieve_entry():
result = store.retrieve("shared_key")
with lock:
results.append(result)
threads = [threading.Thread(target=retrieve_entry) for _ in range(100)]
for t in threads:
t.start()
for t in threads:
t.join()
assert all(r == ("user", "pass") for r in results)
def test_concurrent_store_and_retrieve(self, store):
"""Concurrent stores and retrieves should be thread-safe."""
results = {"errors": [], "retrievals": []}
lock = threading.Lock()
def store_entry():
try:
store.store("key1", "user", "pass")
except Exception as e:
with lock:
results["errors"].append(str(e))
def retrieve_entry():
try:
result = store.retrieve("key1")
with lock:
results["retrievals"].append(result)
except Exception as e:
with lock:
results["errors"].append(str(e))
# First store, then concurrent operations
store.store("key1", "user", "pass")
threads = []
for i in range(50):
threads.append(threading.Thread(target=store_entry))
threads.append(threading.Thread(target=retrieve_entry))
for t in threads:
t.start()
for t in threads:
t.join()
assert len(results["errors"]) == 0
def test_concurrent_clear_and_retrieve(self, store):
"""Concurrent clears and retrieves should be thread-safe."""
errors = []
lock = threading.Lock()
def clear_and_retrieve():
try:
store.store("key", "user", "pass")
store.clear_entry("key")
store.retrieve("key")
except Exception as e:
with lock:
errors.append(str(e))
threads = [
threading.Thread(target=clear_and_retrieve) for _ in range(50)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
class TestMemoryManagement:
"""Tests for memory management."""
def test_many_entries_stored(self, store):
"""Should handle many entries."""
for i in range(1000):
store.store(f"key{i}", f"user{i}", f"pass{i}")
# Spot check some entries
assert store.retrieve("key0") == ("user0", "pass0")
assert store.retrieve("key500") == ("user500", "pass500")
assert store.retrieve("key999") == ("user999", "pass999")
def test_entries_cleaned_up_over_time(self):
"""Old entries should be cleaned up."""
store = ConcreteCredentialStore(ttl_seconds=1)
with freeze_time("2024-01-01 00:00:00") as frozen:
# Add many entries
for i in range(100):
store.store(f"key{i}", f"user{i}", f"pass{i}")
frozen.tick(timedelta(milliseconds=1500))
# Add new entry to trigger cleanup
store.store("new_key", "new_user", "new_pass")
# Old entries should be gone
assert store.retrieve("key0") is None
assert store.retrieve("new_key") == ("new_user", "new_pass")
class TestEdgeCases:
"""Tests for edge cases."""
def test_very_long_key(self, store):
"""Should handle very long keys."""
long_key = "k" * 10000
store.store(long_key, "user", "pass")
assert store.retrieve(long_key) == ("user", "pass")
def test_very_long_credentials(self, store):
"""Should handle very long credentials."""
long_username = "u" * 10000
long_password = "p" * 10000
store.store("key1", long_username, long_password)
assert store.retrieve("key1") == (long_username, long_password)
def test_empty_key(self, store):
"""Should handle empty key."""
store.store("", "user", "pass")
assert store.retrieve("") == ("user", "pass")
def test_key_with_null_bytes(self, store):
"""Should handle keys with null bytes."""
key = "key\x00with\x00nulls"
store.store(key, "user", "pass")
assert store.retrieve(key) == ("user", "pass")
def test_credentials_with_newlines(self, store):
"""Should handle credentials with newlines."""
store.store("key1", "user\nwith\nnewlines", "pass\nwith\nnewlines")
assert store.retrieve("key1") == (
"user\nwith\nnewlines",
"pass\nwith\nnewlines",
)
def test_whitespace_key(self, store):
"""Should handle whitespace-only key."""
store.store(" ", "user", "pass")
assert store.retrieve(" ") == ("user", "pass")
assert store.retrieve("") is None # Different key
class TestAbstractMethods:
"""Tests for abstract method enforcement."""
def test_cannot_instantiate_base_class(self):
"""Should not be able to instantiate abstract base class."""
with pytest.raises(TypeError):
CredentialStoreBase(ttl_seconds=3600)
def test_must_implement_store(self):
"""Subclass must implement store method."""
class IncompleteStore(CredentialStoreBase):
def retrieve(self, key):
pass
with pytest.raises(TypeError):
IncompleteStore(ttl_seconds=3600)
def test_must_implement_retrieve(self):
"""Subclass must implement retrieve method."""
class IncompleteStore(CredentialStoreBase):
def store(self, key, username, password):
pass
with pytest.raises(TypeError):
IncompleteStore(ttl_seconds=3600)
+104
View File
@@ -0,0 +1,104 @@
"""Tests for CredentialStoreBase TTL boundary conditions and remove parameter.
Complements test_credential_store_base.py and test_credential_store_extended.py
by testing precise TTL boundaries using mocked time.time().
"""
import time as time_module
from unittest.mock import patch
from local_deep_research.database.credential_store_base import (
CredentialStoreBase,
)
class ConcreteStore(CredentialStoreBase):
"""Minimal concrete subclass for testing base class logic."""
def store(self, key, username, password):
self._store_credentials(
key, {"username": username, "password": password}
)
def retrieve(self, key, remove=False):
return self._retrieve_credentials(key, remove=remove)
# ---------------------------------------------------------------------------
# _retrieve_credentials boundary conditions
# ---------------------------------------------------------------------------
class TestRetrieveCredentialsBoundary:
"""TTL boundary tests using mocked time."""
def test_not_expired_at_exact_boundary(self):
"""time.time() == expires_at → NOT expired (uses > not >=)."""
store = ConcreteStore(ttl_seconds=100)
with patch.object(time_module, "time", return_value=1000.0):
store.store("k", "user", "pass")
# expires_at = 1000.0 + 100 = 1100.0
with patch.object(time_module, "time", return_value=1100.0):
result = store.retrieve("k")
assert result == ("user", "pass")
def test_expired_just_past_boundary(self):
"""time.time() = expires_at + epsilon → expired."""
store = ConcreteStore(ttl_seconds=100)
with patch.object(time_module, "time", return_value=1000.0):
store.store("k", "user", "pass")
with patch.object(time_module, "time", return_value=1100.001):
result = store.retrieve("k")
assert result is None
def test_expired_entry_deleted_from_store(self):
"""Expired retrieval deletes the entry."""
store = ConcreteStore(ttl_seconds=10)
with patch.object(time_module, "time", return_value=1000.0):
store.store("k", "user", "pass")
with patch.object(time_module, "time", return_value=1011.0):
store.retrieve("k") # triggers deletion
assert "k" not in store._store
# ---------------------------------------------------------------------------
# _cleanup_expired
# ---------------------------------------------------------------------------
class TestCleanupExpired:
"""Tests for _cleanup_expired logic with mocked time."""
def test_some_expired(self):
store = ConcreteStore(ttl_seconds=100)
with patch.object(time_module, "time", return_value=1000.0):
store.store("old", "u1", "p1")
# Insert "new" directly, bypassing _store_credentials' implicit cleanup
with patch.object(time_module, "time", return_value=2000.0):
store._store["new"] = {
"username": "u2",
"password": "p2",
"expires_at": time_module.time() + store.ttl, # 2100
}
store._cleanup_expired()
assert "old" not in store._store
assert "new" in store._store
def test_all_expired(self):
store = ConcreteStore(ttl_seconds=10)
with patch.object(time_module, "time", return_value=1000.0):
store.store("a", "u1", "p1")
store.store("b", "u2", "p2")
with patch.object(time_module, "time", return_value=2000.0):
store._cleanup_expired()
assert len(store._store) == 0
def test_empty_store_no_error(self):
store = ConcreteStore(ttl_seconds=3600)
store._cleanup_expired() # should not raise
assert len(store._store) == 0
+472
View File
@@ -0,0 +1,472 @@
"""Tests for database initialization and encryption functionality."""
import shutil
import tempfile
import uuid
from pathlib import Path
import pytest
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import (
Base,
ResearchHistory,
Setting,
User,
)
class TestDatabaseInitialization:
"""Test suite for database initialization and setup."""
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for test databases."""
temp_dir = tempfile.mkdtemp()
yield temp_dir
shutil.rmtree(temp_dir)
@pytest.fixture
def db_engine(self, temp_dir):
"""Create and dispose a SQLite engine with all tables."""
db_path = str(Path(temp_dir) / "test.db")
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def db_session(self, db_engine):
"""Create a session bound to db_engine, closed on teardown."""
Session = sessionmaker(bind=db_engine)
session = Session()
yield session
session.close()
def test_basic_database_creation(self, temp_dir):
"""Test creating a basic SQLite database."""
db_path = str(Path(temp_dir) / "test.db")
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(engine)
# Verify database file exists
assert Path(db_path).exists()
# Verify tables were created
inspector = inspect(engine)
tables = inspector.get_table_names()
# Check for essential tables
assert "users" in tables
assert "research_history" in tables
assert "settings" in tables
assert "research_resources" in tables
assert "token_usage" in tables
engine.dispose()
def test_database_creation_with_function(self, temp_dir):
"""Test database creation through standard SQLAlchemy."""
db_path = str(Path(temp_dir) / "test_user.db")
# Create engine and initialize database
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(engine)
# Verify engine is created
assert engine is not None
# Verify database exists
assert Path(db_path).exists()
# Test connection
from sqlalchemy import text
with engine.connect() as conn:
result = conn.execute(text("SELECT 1"))
assert result.fetchone()[0] == 1
engine.dispose()
def test_encrypted_database_creation(self, temp_dir, monkeypatch):
"""A user database created via DatabaseManager is real SQLCipher-encrypted.
Uses the production creation path (DatabaseManager + sqlcipher3), not
the obsolete ``sqlite+pysqlcipher://`` dialect, and skips only when a
functional SQLCipher backend is genuinely unavailable -- checked via
``DatabaseManager.has_encryption`` rather than guessing a package name.
The previous version probed ``pysqlcipher3`` (which this project does
not install -- it uses ``sqlcipher3``), so it silently skipped
everywhere, including CI.
"""
from sqlalchemy import text
from sqlalchemy.exc import DatabaseError
from local_deep_research.database.encrypted_db import DatabaseManager
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: Path(temp_dir),
)
manager = DatabaseManager()
if not manager.has_encryption:
pytest.skip("Functional SQLCipher backend not available")
username, password = "encuser", "TestPassword123!"
engine = manager.create_user_database(username, password)
plain_engine = None
try:
# The encrypted engine is functional.
with engine.connect() as conn:
assert conn.execute(text("SELECT 1")).scalar() == 1
db_path = manager._get_user_db_path(username)
assert db_path.exists()
# Opening the same file as PLAINTEXT SQLite must fail with "file is
# not a database": the bytes are encrypted, so SQLite cannot parse
# the header. DatabaseError (SQLAlchemy's OperationalError subclasses
# it) is specific enough that the test can't pass for an unrelated
# reason -- the actual proof that encryption was applied.
plain_engine = create_engine(f"sqlite:///{db_path}")
with pytest.raises(DatabaseError):
with plain_engine.connect() as conn:
conn.execute(text("SELECT name FROM sqlite_master"))
finally:
if plain_engine is not None:
plain_engine.dispose()
manager.close_user_database(username)
def test_database_schema_completeness(self, db_engine):
"""Test that all expected tables and columns are created."""
inspector = inspect(db_engine)
# Test ResearchHistory table schema
research_columns = {
col["name"] for col in inspector.get_columns("research_history")
}
expected_columns = {
"id",
"query",
"mode",
"status",
"created_at",
"completed_at",
"duration_seconds",
"progress",
"report_path",
"report_content",
"title",
"progress_log",
"research_meta",
}
assert expected_columns.issubset(research_columns)
# Test User table schema
user_columns = {col["name"] for col in inspector.get_columns("users")}
expected_user_columns = {
"id",
"username",
"created_at",
"last_login",
"database_version",
}
assert expected_user_columns.issubset(user_columns), (
f"Missing columns: {expected_user_columns - user_columns}"
)
# Test Settings table schema
settings_columns = {
col["name"] for col in inspector.get_columns("settings")
}
expected_settings_columns = {
"id",
"key",
"value",
"type",
"category",
"description",
"name",
"ui_element",
"options",
"min_value",
"max_value",
"step",
"visible",
"editable",
"created_at",
"updated_at",
}
assert expected_settings_columns.issubset(settings_columns)
def test_database_indexes(self, db_engine):
"""Test that proper indexes are created."""
inspector = inspect(db_engine)
# Check indexes on research_history
research_indexes = inspector.get_indexes("research_history")
# Should have indexes on commonly queried fields
index_columns = set()
for idx in research_indexes:
index_columns.update(idx["column_names"])
# Status should be indexed for filtering
# Created_at should be indexed for sorting
# These might be part of composite indexes
# Check unique constraints
# Username and email should have unique constraints in users table
def test_database_foreign_keys(self, db_engine, db_session):
"""Test that foreign key relationships work correctly."""
# Create a research record
research = ResearchHistory(
id=str(uuid.uuid4()),
query="Test query",
mode="quick",
status="completed",
created_at="2024-01-01T00:00:00",
)
db_session.add(research)
db_session.commit()
# Create related records
from local_deep_research.database.models import (
ResearchResource,
TokenUsage,
)
# Add a resource
resource = ResearchResource(
research_id=research.id,
title="Test Resource",
url="https://example.com",
created_at="2024-01-01T00:01:00",
)
db_session.add(resource)
# Add token usage
usage = TokenUsage(
research_id=str(research.id),
model_provider="openai",
model_name="gpt-4",
prompt_tokens=80,
completion_tokens=20,
total_tokens=100,
)
db_session.add(usage)
db_session.commit()
# Verify relationships
assert resource.research_id == research.id
assert usage.research_id == str(research.id)
def test_database_cascade_deletes(self, db_engine, db_session):
"""Test cascade delete behavior."""
# Create a benchmark run with results
from local_deep_research.database.models import (
BenchmarkResult,
BenchmarkRun,
DatasetType,
)
run = BenchmarkRun(
config_hash="test123",
query_hash_list=[],
search_config={},
evaluation_config={},
datasets_config={},
)
db_session.add(run)
db_session.commit()
# Add results
for i in range(3):
result = BenchmarkResult(
benchmark_run_id=run.id,
example_id=f"test_{i}",
query_hash=f"hash_{i}",
dataset_type=DatasetType.SIMPLEQA,
question=f"Question {i}",
correct_answer=f"Answer {i}",
)
db_session.add(result)
db_session.commit()
# Verify results exist
result_count = (
db_session.query(BenchmarkResult)
.filter_by(benchmark_run_id=run.id)
.count()
)
assert result_count == 3
# Delete the run
db_session.delete(run)
db_session.commit()
# Verify cascade delete worked
result_count = (
db_session.query(BenchmarkResult)
.filter_by(benchmark_run_id=run.id)
.count()
)
assert result_count == 0
def test_database_transactions(self, db_engine, db_session):
"""Test transaction rollback behavior."""
# Add a user
user = User(username="testuser")
db_session.add(user)
db_session.commit()
# Start a transaction that will fail
try:
# Add another user with duplicate username (should fail)
user2 = User(username="testuser")
db_session.add(user2)
# Add a valid setting
setting = Setting(
key="test.setting",
value="test_value",
type="string",
category="test",
)
db_session.add(setting)
# This should fail due to unique constraint
db_session.commit()
except Exception:
db_session.rollback()
# Verify rollback worked - setting should not exist
setting_count = (
db_session.query(Setting).filter_by(key="test.setting").count()
)
assert setting_count == 0
# Original user should still exist
user_count = db_session.query(User).count()
assert user_count == 1
def test_database_performance_with_large_dataset(
self, db_engine, db_session
):
"""Test database performance with larger datasets."""
# Add many research records
research_count = 1000
for i in range(research_count):
research = ResearchHistory(
id=str(uuid.uuid4()),
query=f"Test query {i}",
mode="quick",
status="completed" if i % 2 == 0 else "failed",
created_at=f"2024-01-{(i % 28) + 1:02d}T00:00:00",
duration_seconds=100 + i % 500,
progress=100 if i % 2 == 0 else 50,
)
db_session.add(research)
# Commit in batches
if i % 100 == 0:
db_session.commit()
db_session.commit()
# Test query performance
import time
# Query completed research
start = time.time()
completed = (
db_session.query(ResearchHistory)
.filter_by(status="completed")
.count()
)
query_time = time.time() - start
assert completed == 500
assert query_time < 0.1 # Should be fast with indexes
# Test ordering
start = time.time()
recent = (
db_session.query(ResearchHistory)
.order_by(ResearchHistory.created_at.desc())
.limit(10)
.all()
)
order_time = time.time() - start
assert len(recent) == 10
assert order_time < 0.1
def test_user_specific_database_path(self, temp_dir):
"""Test user-specific database paths for multi-user support."""
# Test database path generation for different users
user1_path = str(Path(temp_dir) / "user1" / "user1_encrypted.db")
user2_path = str(Path(temp_dir) / "user2" / "user2_encrypted.db")
# Create directories
Path(user1_path).parent.mkdir(parents=True, exist_ok=True)
Path(user2_path).parent.mkdir(parents=True, exist_ok=True)
# Create separate databases
engine1 = create_engine(f"sqlite:///{user1_path}")
engine2 = create_engine(f"sqlite:///{user2_path}")
Base.metadata.create_all(engine1)
Base.metadata.create_all(engine2)
# Add data to each
Session1 = sessionmaker(bind=engine1)
Session2 = sessionmaker(bind=engine2)
session1 = Session1()
session2 = Session2()
# User 1 research
research1 = ResearchHistory(
id=str(uuid.uuid4()),
query="User 1 research",
mode="quick",
status="completed",
created_at="2024-01-01T00:00:00",
)
session1.add(research1)
session1.commit()
# User 2 research
research2 = ResearchHistory(
id=str(uuid.uuid4()),
query="User 2 research",
mode="quick",
status="completed",
created_at="2024-01-01T00:00:00",
)
session2.add(research2)
session2.commit()
# Verify isolation
user1_count = session1.query(ResearchHistory).count()
user2_count = session2.query(ResearchHistory).count()
assert user1_count == 1
assert user2_count == 1
# Verify different content
user1_research = session1.query(ResearchHistory).first()
user2_research = session2.query(ResearchHistory).first()
assert user1_research.query == "User 1 research"
assert user2_research.query == "User 2 research"
session1.close()
session2.close()
engine1.dispose()
engine2.dispose()
@@ -0,0 +1,467 @@
"""
Extended Tests for Database Manager
Phase 21: Database & Encryption - Database Manager Tests
Tests encrypted database management, connection pooling, and thread safety.
"""
import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
class TestDatabaseEncryption:
"""Tests for database encryption functionality"""
@patch("local_deep_research.database.encrypted_db.get_sqlcipher_module")
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_encryption_key_validation_valid(
self, mock_data_dir, mock_sqlcipher
):
"""Test valid encryption key is accepted"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
assert manager._is_valid_encryption_key("valid_password") is True
assert manager._is_valid_encryption_key("a") is True
assert manager._is_valid_encryption_key("complex!@#$%") is True
@patch("local_deep_research.database.encrypted_db.get_sqlcipher_module")
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_encryption_key_validation_invalid(
self, mock_data_dir, mock_sqlcipher
):
"""Test invalid encryption keys are rejected"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
assert manager._is_valid_encryption_key(None) is False
assert manager._is_valid_encryption_key("") is False
@patch("local_deep_research.database.encrypted_db.get_sqlcipher_module")
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_database_creation_invalid_password(
self, mock_data_dir, mock_sqlcipher
):
"""Test database creation fails with invalid password"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with pytest.raises(ValueError, match="Invalid encryption key"):
manager.create_user_database("testuser", "")
with pytest.raises(ValueError, match="Invalid encryption key"):
manager.create_user_database("testuser", None)
@patch("local_deep_research.database.encrypted_db.get_sqlcipher_module")
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_database_open_invalid_password(
self, mock_data_dir, mock_sqlcipher
):
"""Test opening database fails with invalid password"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with pytest.raises(ValueError, match="Invalid encryption key"):
manager.open_user_database("testuser", "")
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_sqlcipher_unavailable_fallback(self, mock_data_dir):
"""Test fallback when SQLCipher not available"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
with patch.object(
DatabaseManager,
"_check_encryption_available",
return_value=False,
):
manager = DatabaseManager()
assert manager.has_encryption is False
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_encryption_check_available(self, mock_data_dir):
"""Test encryption availability check"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
# With encryption available
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
assert manager.has_encryption is True
class TestConnectionPooling:
"""Tests for connection pooling functionality"""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_pool_kwargs_static_pool(self, mock_data_dir):
"""Test pool kwargs for static pool (testing mode)"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.dict("os.environ", {"TESTING": "true"}):
with patch.object(
DatabaseManager,
"_check_encryption_available",
return_value=True,
):
manager = DatabaseManager()
kwargs = manager._get_pool_kwargs()
assert kwargs == {}
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_pool_kwargs_queue_pool(self, mock_data_dir):
"""Test pool kwargs for queue pool (production mode)"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.dict("os.environ", {}, clear=True):
with patch.object(
DatabaseManager,
"_check_encryption_available",
return_value=True,
):
manager = DatabaseManager()
manager._use_static_pool = False
kwargs = manager._get_pool_kwargs()
assert "pool_size" in kwargs
assert kwargs["pool_size"] == 20
assert kwargs["max_overflow"] == 40
assert kwargs["pool_timeout"] == 10
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_connection_storage(self, mock_data_dir):
"""Test connections are stored properly"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
# Mock engine
mock_engine = MagicMock()
manager.connections["testuser"] = mock_engine
assert "testuser" in manager.connections
assert manager.connections["testuser"] is mock_engine
class TestThreadSafety:
"""Tests for thread safety functionality"""
class TestDatabaseOperations:
"""Tests for database operations"""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_get_session_no_connection(self, mock_data_dir):
"""Test get_session when no connection exists"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
result = manager.get_session("nonexistent_user")
assert result is None
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_get_session_with_connection(self, mock_data_dir):
"""Test get_session when connection exists"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
manager.connections["testuser"] = mock_engine
# This will try to create a real session, mock the sessionmaker
with patch(
"local_deep_research.database.encrypted_db.sessionmaker"
) as mock_sm:
mock_session = MagicMock()
mock_sm.return_value = MagicMock(return_value=mock_session)
result = manager.get_session("testuser")
assert result is mock_session
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_close_user_database(self, mock_data_dir):
"""Test closing user database"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
manager.connections["testuser"] = mock_engine
manager.close_user_database("testuser")
mock_engine.dispose.assert_called_once()
assert "testuser" not in manager.connections
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_get_memory_usage(self, mock_data_dir):
"""Test memory usage statistics"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
manager.connections["user1"] = MagicMock()
manager.connections["user2"] = MagicMock()
usage = manager.get_memory_usage()
assert usage["active_connections"] == 2
assert "thread_engines" not in usage
assert "estimated_memory_mb" in usage
class TestDatabaseIntegrity:
"""Tests for database integrity checking"""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_check_integrity_no_connection(self, mock_data_dir):
"""Test integrity check when no connection exists"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
result = manager.check_database_integrity("nonexistent")
assert result is False
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_check_integrity_success(self, mock_data_dir):
"""Test successful integrity check"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(
return_value=mock_conn
)
mock_engine.connect.return_value.__exit__ = MagicMock(
return_value=False
)
# Mock successful integrity checks
mock_conn.execute.side_effect = [
MagicMock(
fetchone=MagicMock(return_value=("ok",))
), # quick_check
iter([]), # cipher_integrity_check - no failures
]
manager.connections["testuser"] = mock_engine
result = manager.check_database_integrity("testuser")
assert result is True
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_check_integrity_failure(self, mock_data_dir):
"""Test failed integrity check"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
mock_engine = MagicMock()
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(
return_value=mock_conn
)
mock_engine.connect.return_value.__exit__ = MagicMock(
return_value=False
)
# Mock failed integrity check
mock_conn.execute.return_value.fetchone.return_value = ("corrupt",)
manager.connections["testuser"] = mock_engine
result = manager.check_database_integrity("testuser")
assert result is False
class TestPasswordChange:
"""Tests for password change functionality"""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_change_password_no_encryption(self, mock_data_dir):
"""Test password change when encryption not available"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
manager = DatabaseManager()
result = manager.change_password("user", "old", "new")
assert result is False
class TestUserExists:
"""Tests for user existence check"""
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_user_exists_true(self, mock_data_dir):
"""Test user exists returns true"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
# Mock the internal method call
with patch.object(
manager, "user_exists", return_value=True
) as mock_method:
result = mock_method("testuser")
assert result is True
@patch("local_deep_research.database.encrypted_db.get_data_directory")
def test_user_exists_false(self, mock_data_dir):
"""Test user exists returns false"""
from local_deep_research.database.encrypted_db import DatabaseManager
mock_data_dir.return_value = Path("/tmp/test_data")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=True
):
manager = DatabaseManager()
with patch.object(
manager, "user_exists", return_value=False
) as mock_method:
result = mock_method("nonexistent")
assert result is False
class TestUserDatabasePath:
"""Tests for database path generation"""
def test_get_user_db_path(self):
"""Test user database path generation"""
from local_deep_research.database.encrypted_db import DatabaseManager
# Use a temp directory that exists
import tempfile
temp_dir = Path(tempfile.gettempdir())
with patch(
"local_deep_research.database.encrypted_db.get_data_directory",
return_value=temp_dir,
):
with patch.object(
DatabaseManager,
"_check_encryption_available",
return_value=True,
):
with patch(
"local_deep_research.database.encrypted_db.get_user_database_filename",
return_value="user_test.db",
):
manager = DatabaseManager()
path = manager._get_user_db_path("testuser")
# Path should include the filename
assert "user_test.db" in str(path)
class TestGlobalInstance:
"""Tests for global database manager instance"""
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_global_instance_exists(self):
"""Test global db_manager instance is available"""
# This will fail if the module can't be imported
# but we mock the initialization
pass # Just a placeholder - actual import tested elsewhere
+106
View File
@@ -0,0 +1,106 @@
"""Regression tests for DB-file permission hardening (PR #3135).
``DatabaseManager.create_user_database`` tightens the on-disk database file to
owner-only (0o600). The encrypted (SQLCipher) branch chmods right after
creating the file; the unencrypted fallback — which writes PLAINTEXT data and
is therefore the higher-risk path — chmods after ``initialize_database``
materializes the file. This test pins the unencrypted behavior, mirroring the
salt-file permission test in ``test_encryption_constants.py``.
"""
import os
import stat
from unittest.mock import patch
from tests.test_utils import add_src_to_path
add_src_to_path()
class TestDatabaseFilePermissions:
def test_unencrypted_db_file_has_restrictive_permissions(
self, tmp_path, monkeypatch
):
"""The unencrypted-fallback DB file must be created 0o600.
It holds plaintext user data, so leaving it at umask-default perms
(commonly 0o644) would expose it to other local accounts.
"""
from local_deep_research.database.encrypted_db import DatabaseManager
monkeypatch.setenv("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED", "true")
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get_sqlcipher:
mock_get_sqlcipher.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
with patch(
"local_deep_research.database.encrypted_db.get_data_directory",
return_value=tmp_path,
):
manager = DatabaseManager()
assert manager.has_encryption is False, (
"Test requires the unencrypted fallback path"
)
engine = manager.create_user_database(
"permuser", "test-password-123"
)
try:
db_path = manager._get_user_db_path("permuser")
assert db_path.exists(), "DB file was not created"
mode = stat.S_IMODE(os.stat(db_path).st_mode)
assert mode == 0o600, (
f"Unencrypted DB file should be 0o600, got {oct(mode)}"
)
# The per-user DB directory is owner-only (0o700), so the
# plaintext WAL/SHM sidecars and any temp files SQLite may
# create alongside the DB are not sibling-readable either.
dir_mode = stat.S_IMODE(os.stat(manager.data_dir).st_mode)
assert dir_mode == 0o700, (
f"DB directory should be 0o700, got {oct(dir_mode)}"
)
finally:
engine.dispose()
def test_db_creation_survives_chmod_failure(self, tmp_path, monkeypatch):
"""Permission hardening must never break DB creation.
On filesystems that reject POSIX chmod (some Docker bind mounts,
network/FUSE volumes — e.g. Docker Desktop on macOS/Windows),
os.chmod can raise OSError. The chmods are best-effort, so
create_user_database must still succeed and return a usable engine.
"""
from sqlalchemy import text
from local_deep_research.database.encrypted_db import DatabaseManager
monkeypatch.setenv("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED", "true")
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module",
side_effect=ImportError("No module named 'sqlcipher3'"),
):
with patch(
"local_deep_research.database.encrypted_db.get_data_directory",
return_value=tmp_path,
):
# Simulate a volume that rejects chmod for every call.
with patch(
"local_deep_research.database.encrypted_db.os.chmod",
side_effect=OSError("Operation not permitted"),
):
manager = DatabaseManager()
engine = manager.create_user_database(
"chmoduser", "test-password-123"
)
try:
# DB must be created and queryable despite chmod failing.
with engine.connect() as conn:
assert conn.execute(text("SELECT 1")).scalar() == 1
finally:
engine.dispose()
@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""Test ORM operations with encrypted user databases."""
import sys
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
import pytest
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 (
APIKey,
Report,
ResearchHistory,
ResearchLog,
ResearchResource,
ResearchTask,
SearchQuery,
SearchResult,
UserSettings,
)
class TestEncryptedDatabaseORM:
"""Test ORM operations in encrypted user databases."""
@pytest.fixture
def temp_data_dir(self):
"""Create a temporary directory for test databases."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def db_manager(self, temp_data_dir, monkeypatch):
"""Create a database manager with temporary directory."""
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: temp_data_dir,
)
manager = DatabaseManager()
yield manager
# Cleanup
for username in list(manager.connections.keys()):
manager.close_user_database(username)
@pytest.fixture
def test_user_session(self, db_manager):
"""Create a test user with encrypted database and return session."""
username = "testuser"
password = "TestPassword123!"
# Create user database
db_manager.create_user_database(username, password)
# Get session
session = db_manager.get_session(username)
yield session
# Cleanup
session.close()
db_manager.close_user_database(username)
def test_user_settings_crud(self, test_user_session):
"""Test CRUD operations on UserSettings."""
session = test_user_session
# Create settings
setting1 = UserSettings(
key="llm.provider",
value={"provider": "openai", "model": "gpt-4"},
category="llm",
)
setting2 = UserSettings(
key="search.engine",
value={"engine": "duckduckgo", "safe_search": True},
category="search",
)
session.add_all([setting1, setting2])
session.commit()
# Read settings
llm_setting = (
session.query(UserSettings).filter_by(key="llm.provider").first()
)
assert llm_setting is not None
assert llm_setting.value["provider"] == "openai"
assert llm_setting.category == "llm"
# Update setting
llm_setting.value = {"provider": "anthropic", "model": "claude-3"}
session.commit()
# Verify update
updated = (
session.query(UserSettings).filter_by(key="llm.provider").first()
)
assert updated.value["provider"] == "anthropic"
# Delete setting
session.delete(setting2)
session.commit()
# Verify deletion
remaining = session.query(UserSettings).count()
assert remaining == 1
def test_api_keys_encryption(self, test_user_session):
"""Test API key storage and retrieval."""
session = test_user_session
# Store API keys
openai_key = APIKey(
provider="openai",
key="sk-test123456789", # This will be encrypted in the database
is_active=True,
)
anthropic_key = APIKey(
provider="anthropic", key="sk-ant-test987654321", is_active=False
)
session.add_all([openai_key, anthropic_key])
session.commit()
# Retrieve and verify
stored_key = session.query(APIKey).filter_by(provider="openai").first()
assert stored_key is not None
assert stored_key.key == "sk-test123456789" # Should decrypt properly
assert stored_key.is_active is True
# Query by active status
active_keys = session.query(APIKey).filter_by(is_active=True).all()
assert len(active_keys) == 1
assert active_keys[0].provider == "openai"
def test_research_history_with_resources(self, test_user_session):
"""Test ResearchHistory with related ResearchResource objects."""
session = test_user_session
# Create research history
research_id = str(uuid.uuid4())
research = ResearchHistory(
id=research_id,
query="quantum computing applications",
mode="detailed",
status="completed",
created_at=datetime.now(timezone.utc).isoformat(),
completed_at=datetime.now(timezone.utc).isoformat(),
progress=100,
research_meta={
"model": "gpt-4",
"temperature": 0.7,
"iterations": 3,
},
)
session.add(research)
session.commit()
# Add resources
resources = [
ResearchResource(
research_id=research_id,
title="Quantum Computing in Drug Discovery",
url="https://example.com/quantum-drug",
content_preview="Recent advances in quantum computing...",
source_type="article",
resource_metadata={"author": "Dr. Smith", "year": 2024},
created_at=datetime.now(timezone.utc).isoformat(),
),
ResearchResource(
research_id=research_id,
title="IBM Quantum Network",
url="https://example.com/ibm-quantum",
content_preview="IBM's quantum computing initiative...",
source_type="web",
resource_metadata={"company": "IBM", "relevance": 0.95},
created_at=datetime.now(timezone.utc).isoformat(),
),
]
session.add_all(resources)
session.commit()
# Query with relationship
stored_research = (
session.query(ResearchHistory).filter_by(id=research_id).first()
)
assert stored_research is not None
assert len(stored_research.resources) == 2
# Query resources directly
quantum_resources = (
session.query(ResearchResource)
.filter(ResearchResource.title.contains("Quantum"))
.all()
)
assert len(quantum_resources) == 2
def test_research_task_workflow(self, test_user_session):
"""Test complete research task workflow with queries and results."""
session = test_user_session
# Create research task
task = ResearchTask(
title="AI Safety Research",
description="Comprehensive research on AI alignment and safety",
status="in_progress",
)
session.add(task)
session.commit()
# Add search queries
query1 = SearchQuery(
research_task_id=task.id,
query="AI alignment problem solutions",
search_engine="google",
search_type="academic",
status="completed",
)
query2 = SearchQuery(
research_task_id=task.id,
query="AI safety research organizations",
search_engine="duckduckgo",
search_type="web",
status="completed",
)
session.add_all([query1, query2])
session.commit()
# Add search results
results = [
SearchResult(
research_task_id=task.id,
search_query_id=query1.id,
title="Concrete Problems in AI Safety",
url="https://example.com/ai-safety-paper",
snippet="A comprehensive survey of AI safety challenges...",
relevance_score=0.98,
position=1,
),
SearchResult(
research_task_id=task.id,
search_query_id=query2.id,
title="Center for AI Safety",
url="https://example.com/cais",
snippet="Leading research organization focused on AI safety...",
relevance_score=0.95,
position=1,
),
]
session.add_all(results)
session.commit()
# Add report
report = Report(
research_task_id=task.id,
title="AI Safety Research Report",
content="# AI Safety Research\n\n## Executive Summary\n...",
format="markdown",
is_draft=False,
word_count=1500,
section_count=5,
)
session.add(report)
session.commit()
# Update task status
task.status = "completed"
task.completed_at = datetime.now(timezone.utc)
session.commit()
# Verify relationships
completed_task = (
session.query(ResearchTask).filter_by(id=task.id).first()
)
assert completed_task.status == "completed"
assert len(completed_task.searches) == 2
assert len(completed_task.results) == 2
assert len(completed_task.reports) == 1
# Query high relevance results
high_relevance = (
session.query(SearchResult)
.filter(SearchResult.relevance_score > 0.9)
.all()
)
assert len(high_relevance) == 2
def test_research_logs(self, test_user_session):
"""Test research logging functionality."""
session = test_user_session
# ResearchLog.research_id is a String(36) FK to
# research_history.id (UUID), not research.id (Integer).
# Production writes UUIDs via log_utils, so the test must too.
research = ResearchHistory(
id=str(uuid.uuid4()),
query="Test research for logging",
mode="detailed",
status="in_progress",
created_at=datetime.now(timezone.utc).isoformat(),
)
session.add(research)
session.commit()
# Add various log entries
logs = [
ResearchLog(
research_id=research.id,
timestamp=datetime.now(timezone.utc),
level="INFO",
message="Starting research process",
module="research_service",
function="start_research",
line_no=100,
),
ResearchLog(
research_id=research.id,
timestamp=datetime.now(timezone.utc),
level="DEBUG",
message="Executing search query",
module="search_engine",
function="search",
line_no=250,
),
ResearchLog(
research_id=research.id,
timestamp=datetime.now(timezone.utc),
level="ERROR",
message="API rate limit exceeded",
module="api_client",
function="make_request",
line_no=75,
),
]
session.add_all(logs)
session.commit()
# Query logs by level
error_logs = (
session.query(ResearchLog)
.filter_by(research_id=research.id, level="ERROR")
.all()
)
assert len(error_logs) == 1
assert "rate limit" in error_logs[0].message
# Query all logs for research
all_logs = (
session.query(ResearchLog)
.filter_by(research_id=research.id)
.order_by(ResearchLog.timestamp)
.all()
)
assert len(all_logs) == 3
def test_database_integrity(self, db_manager):
"""Test database integrity checks."""
username = "integrity_test_user"
password = "IntegrityTest123!"
# Create and open database
db_manager.create_user_database(username, password)
# Check integrity
integrity_ok = db_manager.check_database_integrity(username)
assert integrity_ok is True
# Cleanup
db_manager.close_user_database(username)
def test_concurrent_users(self, db_manager):
"""Test multiple users with separate encrypted databases."""
users = [
("alice", "AlicePass123!"),
("bob", "BobPass456!"),
("charlie", "CharliePass789!"),
]
# Create databases for each user
for username, password in users:
engine = db_manager.create_user_database(username, password)
assert engine is not None
# Add a setting specific to each user
session = db_manager.get_session(username)
setting = UserSettings(
key="user.theme",
value={"theme": f"{username}_theme"},
category="ui",
)
session.add(setting)
session.commit()
session.close()
# Verify each user has their own data
for username, password in users:
session = db_manager.get_session(username)
setting = (
session.query(UserSettings).filter_by(key="user.theme").first()
)
assert setting is not None
assert setting.value["theme"] == f"{username}_theme"
session.close()
# Cleanup
for username, _ in users:
db_manager.close_user_database(username)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,202 @@
"""Coverage tests for encrypted_db.py using regular SQLite (no SQLCipher required).
Covers:
- DatabaseManager initialisation helpers
- _is_valid_encryption_key
- is_user_connected
- get_connected_usernames
- get_memory_usage
- close_user_database / close_all_databases
- check_database_integrity success/failure paths
- get_pool_kwargs
"""
import threading
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest # noqa: F401
MODULE = "local_deep_research.database.encrypted_db"
# ---------------------------------------------------------------------------
# Helpers create a DatabaseManager with encryption disabled
# ---------------------------------------------------------------------------
@contextmanager
def _unencrypted_manager():
"""Yield a DatabaseManager that has encryption disabled (SQLCipher not available)."""
with (
patch(
f"{MODULE}.get_data_directory",
return_value=MagicMock(
__truediv__=lambda self, other: MagicMock(
mkdir=MagicMock(),
__truediv__=lambda self2, other2: MagicMock(),
)
),
),
patch(f"{MODULE}.get_env_setting", return_value=True),
patch(
f"{MODULE}.get_sqlcipher_module",
side_effect=ImportError("no sqlcipher"),
),
):
from local_deep_research.database.encrypted_db import DatabaseManager
mgr = DatabaseManager.__new__(DatabaseManager)
mgr.connections = {}
mgr._connections_lock = threading.RLock()
# __init__ is bypassed here; mirror its per-user init-lock dict so the
# close_*/open paths that reference mgr._init_locks don't AttributeError.
mgr._init_locks = {}
mgr.has_encryption = False
import os
mgr._use_static_pool = bool(os.environ.get("TESTING"))
from sqlalchemy.pool import StaticPool, QueuePool
mgr._pool_class = StaticPool if mgr._use_static_pool else QueuePool
yield mgr
# ---------------------------------------------------------------------------
# _is_valid_encryption_key
# ---------------------------------------------------------------------------
class TestIsValidEncryptionKey:
def test_none_is_invalid(self):
with _unencrypted_manager() as mgr:
assert mgr._is_valid_encryption_key(None) is False
def test_empty_string_is_invalid(self):
with _unencrypted_manager() as mgr:
assert mgr._is_valid_encryption_key("") is False
def test_whitespace_only_is_invalid(self):
with _unencrypted_manager() as mgr:
assert mgr._is_valid_encryption_key(" ") is False
def test_valid_password(self):
with _unencrypted_manager() as mgr:
assert mgr._is_valid_encryption_key("secret123") is True
def test_single_char_password(self):
with _unencrypted_manager() as mgr:
assert mgr._is_valid_encryption_key("x") is True
# ---------------------------------------------------------------------------
# is_user_connected / get_connected_usernames
# ---------------------------------------------------------------------------
class TestConnectionState:
def test_is_user_connected_false_when_absent(self):
with _unencrypted_manager() as mgr:
assert mgr.is_user_connected("alice") is False
def test_is_user_connected_true_when_present(self):
with _unencrypted_manager() as mgr:
mgr.connections["alice"] = MagicMock()
assert mgr.is_user_connected("alice") is True
def test_get_connected_usernames_empty(self):
with _unencrypted_manager() as mgr:
assert mgr.get_connected_usernames() == set()
def test_get_connected_usernames_snapshot(self):
with _unencrypted_manager() as mgr:
mgr.connections["alice"] = MagicMock()
mgr.connections["bob"] = MagicMock()
names = mgr.get_connected_usernames()
assert names == {"alice", "bob"}
# ---------------------------------------------------------------------------
# get_memory_usage
# ---------------------------------------------------------------------------
class TestGetMemoryUsage:
def test_empty_state(self):
with _unencrypted_manager() as mgr:
stats = mgr.get_memory_usage()
assert stats["active_connections"] == 0
assert "thread_engines" not in stats
assert stats["estimated_memory_mb"] == 0.0
def test_with_connections(self):
with _unencrypted_manager() as mgr:
mgr.connections["user1"] = MagicMock()
mgr.connections["user2"] = MagicMock()
stats = mgr.get_memory_usage()
assert stats["active_connections"] == 2
assert stats["estimated_memory_mb"] == pytest.approx(2 * 3.5)
# ---------------------------------------------------------------------------
# _get_pool_kwargs
# ---------------------------------------------------------------------------
class TestGetPoolKwargs:
def test_static_pool_returns_empty(self):
with _unencrypted_manager() as mgr:
mgr._use_static_pool = True
result = mgr._get_pool_kwargs()
assert result == {}
def test_queue_pool_returns_kwargs(self):
with _unencrypted_manager() as mgr:
mgr._use_static_pool = False
result = mgr._get_pool_kwargs()
assert "pool_size" in result
assert "max_overflow" in result
# ---------------------------------------------------------------------------
# close_user_database
# ---------------------------------------------------------------------------
class TestCloseUserDatabase:
def test_closes_and_removes_connection(self):
with _unencrypted_manager() as mgr:
mock_engine = MagicMock()
mgr.connections["alice"] = mock_engine
mgr.close_user_database("alice")
mock_engine.dispose.assert_called_once()
assert "alice" not in mgr.connections
def test_no_error_when_user_not_connected(self):
with _unencrypted_manager() as mgr:
mgr.close_user_database("nonexistent")
def test_dispose_error_handled_gracefully(self):
with _unencrypted_manager() as mgr:
mock_engine = MagicMock()
mock_engine.dispose.side_effect = RuntimeError("dispose failed")
mgr.connections["alice"] = mock_engine
mgr.close_user_database("alice")
assert "alice" not in mgr.connections
# ---------------------------------------------------------------------------
# close_all_databases
# ---------------------------------------------------------------------------
class TestCloseAllDatabases:
def test_disposes_all_engines(self):
with _unencrypted_manager() as mgr:
e1, e2 = MagicMock(), MagicMock()
mgr.connections["a"] = e1
mgr.connections["b"] = e2
mgr.close_all_databases()
e1.dispose.assert_called_once()
e2.dispose.assert_called_once()
assert mgr.connections == {}
File diff suppressed because it is too large Load Diff
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""
Test that encryption constants never change.
These tests protect against breaking changes to encryption parameters.
If ANY of these tests fail, it means ALL existing encrypted databases
will become unreadable. REVERT THE CHANGE IMMEDIATELY.
This test file exists because a "documentation only" commit changed
the PBKDF2 salt and broke all existing user databases.
Note: New databases (v2+) use per-database random salts stored in .salt files.
Legacy databases continue to use LEGACY_PBKDF2_SALT for backwards compatibility.
"""
import hashlib
from hashlib import pbkdf2_hmac
from pathlib import Path
import pytest
class TestEncryptionConstants:
"""
Verify that encryption constants haven't changed.
CRITICAL: These values are used to derive encryption keys.
Changing ANY of them will make existing databases unreadable.
"""
def test_legacy_salt_value_is_stable(self):
"""
Ensure the legacy PBKDF2 salt value hasn't changed.
WARNING: Changing this salt will break ALL existing legacy databases!
If this test fails, you MUST revert the salt change.
Note: New databases use per-database salts, but legacy databases
still depend on this constant value.
"""
from local_deep_research.database.sqlcipher_utils import (
LEGACY_PBKDF2_SALT,
PBKDF2_PLACEHOLDER_SALT,
)
expected_salt = b"no salt"
assert LEGACY_PBKDF2_SALT == expected_salt, (
f"CRITICAL: Legacy PBKDF2 salt has changed!\n"
f"Expected: {expected_salt!r}\n"
f"Actual: {LEGACY_PBKDF2_SALT!r}\n\n"
"This will break ALL existing legacy encrypted databases!\n"
"Users will be unable to log in.\n"
"REVERT THIS CHANGE IMMEDIATELY."
)
# Also verify the alias is still pointing to the same value
assert PBKDF2_PLACEHOLDER_SALT == LEGACY_PBKDF2_SALT, (
"PBKDF2_PLACEHOLDER_SALT should be an alias for LEGACY_PBKDF2_SALT"
)
def test_kdf_iterations_stable(self):
"""
Ensure KDF iterations haven't changed.
Changing this will make existing databases unreadable.
"""
from local_deep_research.database.sqlcipher_utils import (
DEFAULT_KDF_ITERATIONS,
)
expected_iterations = 256000
assert DEFAULT_KDF_ITERATIONS == expected_iterations, (
f"CRITICAL: KDF iterations changed!\n"
f"Expected: {expected_iterations}\n"
f"Actual: {DEFAULT_KDF_ITERATIONS}\n\n"
"This will break ALL existing encrypted databases!\n"
"REVERT THIS CHANGE IMMEDIATELY."
)
def test_hmac_algorithm_stable(self):
"""
Ensure HMAC algorithm hasn't changed.
Changing this will make existing databases unreadable.
"""
from local_deep_research.database.sqlcipher_utils import (
DEFAULT_HMAC_ALGORITHM,
)
expected_algorithm = "HMAC_SHA512"
assert DEFAULT_HMAC_ALGORITHM == expected_algorithm, (
f"CRITICAL: HMAC algorithm changed!\n"
f"Expected: {expected_algorithm}\n"
f"Actual: {DEFAULT_HMAC_ALGORITHM}\n\n"
"This will break ALL existing encrypted databases!\n"
"REVERT THIS CHANGE IMMEDIATELY."
)
def test_page_size_stable(self):
"""
Ensure cipher page size hasn't changed.
Changing this will make existing databases unreadable.
"""
from local_deep_research.database.sqlcipher_utils import (
DEFAULT_PAGE_SIZE,
)
expected_page_size = 16384 # 16KB
assert DEFAULT_PAGE_SIZE == expected_page_size, (
f"CRITICAL: Page size changed!\n"
f"Expected: {expected_page_size}\n"
f"Actual: {DEFAULT_PAGE_SIZE}\n\n"
"This will break ALL existing encrypted databases!\n"
"REVERT THIS CHANGE IMMEDIATELY."
)
def test_kdf_algorithm_stable(self):
"""
Ensure KDF algorithm hasn't changed.
Changing this will make existing databases unreadable.
"""
from local_deep_research.database.sqlcipher_utils import (
DEFAULT_KDF_ALGORITHM,
)
expected_algorithm = "PBKDF2_HMAC_SHA512"
assert DEFAULT_KDF_ALGORITHM == expected_algorithm, (
f"CRITICAL: KDF algorithm changed!\n"
f"Expected: {expected_algorithm}\n"
f"Actual: {DEFAULT_KDF_ALGORITHM}\n\n"
"This will break ALL existing encrypted databases!\n"
"REVERT THIS CHANGE IMMEDIATELY."
)
def test_key_derivation_produces_expected_output(self):
"""
Verify the key derivation function produces the expected output.
This test uses a known password and verifies the derived key matches
the expected hash. This catches ANY change to the key derivation:
- Salt changes
- Iteration count changes
- Algorithm changes
- Any other parameter changes
If this test fails, existing databases WILL NOT be openable.
"""
from local_deep_research.database.sqlcipher_utils import (
PBKDF2_PLACEHOLDER_SALT,
DEFAULT_KDF_ITERATIONS,
)
# Use a known test password
test_password = "test_password_for_key_derivation_check"
# Derive the key the same way the production code does
derived_key = pbkdf2_hmac(
"sha512",
test_password.encode(),
PBKDF2_PLACEHOLDER_SALT,
DEFAULT_KDF_ITERATIONS,
)
# This is the expected hash of the derived key
# Generated with: hashlib.sha256(derived_key).hexdigest()
# If this changes, ALL existing databases will break!
# DevSkim: ignore DS173237 - This is a verification hash, not a secret
expected_key_hash = (
"cfac783084917231b28210859f7722be29b54120161f43709363c07cfc6c63ed"
)
actual_key_hash = hashlib.sha256(derived_key).hexdigest()
assert actual_key_hash == expected_key_hash, (
f"CRITICAL: Key derivation output has changed!\n"
f"Expected hash: {expected_key_hash}\n"
f"Actual hash: {actual_key_hash}\n\n"
"This means the encryption key for the same password is now different.\n"
"ALL existing user databases will be unreadable!\n"
"REVERT WHATEVER CHANGE CAUSED THIS IMMEDIATELY."
)
class TestPerDatabaseSalt:
"""
Test the per-database salt functionality (v2 databases).
New databases use random per-database salts stored in .salt files.
This provides better security than the shared legacy salt.
"""
def test_salt_file_path_generation(self):
"""
Verify salt file paths are generated correctly.
"""
from local_deep_research.database.sqlcipher_utils import (
get_salt_file_path,
)
db_path = Path("/data/user.db")
salt_path = get_salt_file_path(db_path)
assert salt_path == Path("/data/user.db.salt")
def test_create_database_salt_generates_correct_size(self, tmp_path):
"""
Verify that created salts are the correct size.
"""
from local_deep_research.database.sqlcipher_utils import (
create_database_salt,
SALT_SIZE,
)
db_path = tmp_path / "test.db"
salt = create_database_salt(db_path)
assert len(salt) == SALT_SIZE
assert db_path.with_suffix(".db.salt").exists()
def test_create_database_salt_is_random(self, tmp_path):
"""
Verify that each call to create_database_salt generates a unique salt.
"""
from local_deep_research.database.sqlcipher_utils import (
create_database_salt,
)
db_path1 = tmp_path / "test1.db"
db_path2 = tmp_path / "test2.db"
salt1 = create_database_salt(db_path1)
salt2 = create_database_salt(db_path2)
assert salt1 != salt2, "Each database should have a unique salt"
def test_get_salt_for_database_with_salt_file(self, tmp_path):
"""
Verify that get_salt_for_database returns the salt from the file.
"""
from local_deep_research.database.sqlcipher_utils import (
create_database_salt,
get_salt_for_database,
)
db_path = tmp_path / "test.db"
created_salt = create_database_salt(db_path)
retrieved_salt = get_salt_for_database(db_path)
assert retrieved_salt == created_salt
def test_get_salt_for_database_without_salt_file(self, tmp_path):
"""
Verify that get_salt_for_database returns legacy salt when no .salt file exists.
"""
from local_deep_research.database.sqlcipher_utils import (
get_salt_for_database,
LEGACY_PBKDF2_SALT,
)
db_path = tmp_path / "legacy.db"
# Don't create a salt file - simulating a legacy database
salt = get_salt_for_database(db_path)
assert salt == LEGACY_PBKDF2_SALT, (
"Should return legacy salt for databases without .salt file"
)
def test_has_per_database_salt(self, tmp_path):
"""
Verify has_per_database_salt correctly identifies v2 databases.
"""
from local_deep_research.database.sqlcipher_utils import (
create_database_salt,
has_per_database_salt,
)
new_db = tmp_path / "new.db"
legacy_db = tmp_path / "legacy.db"
# Create salt for new database
create_database_salt(new_db)
assert has_per_database_salt(new_db) is True
assert has_per_database_salt(legacy_db) is False
def test_get_salt_for_database_raises_on_corrupted_salt(self, tmp_path):
"""
Verify that get_salt_for_database raises ValueError for corrupted salt files.
If a salt file exists but has wrong size, it indicates corruption.
Falling back to legacy salt would fail anyway (wrong key), so we
raise an exception to make the failure explicit.
"""
from local_deep_research.database.sqlcipher_utils import (
get_salt_for_database,
get_salt_file_path,
)
db_path = tmp_path / "corrupted.db"
salt_file = get_salt_file_path(db_path)
# Write a corrupted salt file (wrong size)
salt_file.write_bytes(b"too short")
with pytest.raises(ValueError) as exc_info:
get_salt_for_database(db_path)
assert "unexpected size" in str(exc_info.value)
assert "corrupted" in str(exc_info.value).lower()
def test_create_database_salt_refuses_to_overwrite(self, tmp_path):
"""
Verify that create_database_salt raises FileExistsError if a salt
file already exists, preventing accidental data loss.
"""
from local_deep_research.database.sqlcipher_utils import (
create_database_salt,
)
db_path = tmp_path / "test.db"
create_database_salt(db_path)
with pytest.raises(FileExistsError):
create_database_salt(db_path)
def test_same_password_different_salts_produce_different_keys(self):
"""
Verify the core security guarantee: the same password with different
salts must produce different encryption keys.
"""
from local_deep_research.database.sqlcipher_utils import (
_get_key_from_password,
)
password = "same_password"
salt1 = b"a" * 32
salt2 = b"b" * 32
key1 = _get_key_from_password(password, salt1, 1000)
key2 = _get_key_from_password(password, salt2, 1000)
assert key1 != key2, (
"Same password with different salts MUST produce different keys"
)
def test_salt_file_has_restrictive_permissions(self, tmp_path):
"""
Verify that salt files are created with owner-only permissions (0o600).
"""
import os
import stat
from local_deep_research.database.sqlcipher_utils import (
create_database_salt,
get_salt_file_path,
)
db_path = tmp_path / "test.db"
create_database_salt(db_path)
salt_file = get_salt_file_path(db_path)
file_stat = os.stat(salt_file)
mode = stat.S_IMODE(file_stat.st_mode)
assert mode == 0o600, (
f"Salt file should have 0o600 permissions, got {oct(mode)}"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+183
View File
@@ -0,0 +1,183 @@
"""
Tests for encryption key passing to background threads.
Verifies that research threads can access encrypted databases with the correct password.
"""
import threading
import pytest
from unittest.mock import patch
from tests.test_utils import add_src_to_path
add_src_to_path()
class TestThreadContextPasswordStorage:
"""Test that thread context correctly stores and retrieves passwords."""
def test_set_and_get_password_in_same_thread(self):
"""Password set via set_search_context should be retrievable."""
from local_deep_research.utilities.thread_context import (
set_search_context,
get_search_context,
)
set_search_context(
{
"username": "test_user",
"user_password": "secret123",
}
)
ctx = get_search_context()
assert ctx is not None
assert ctx.get("user_password") == "secret123"
def test_context_includes_all_fields(self):
"""All fields in context should be preserved."""
from local_deep_research.utilities.thread_context import (
set_search_context,
get_search_context,
)
context = {
"research_id": "res_123",
"username": "alice",
"user_password": "pass456",
"custom_field": "custom_value",
}
set_search_context(context)
retrieved = get_search_context()
assert retrieved["research_id"] == "res_123"
assert retrieved["username"] == "alice"
assert retrieved["user_password"] == "pass456"
assert retrieved["custom_field"] == "custom_value"
class TestThreadContextIsolation:
"""Test that thread context is properly isolated between threads."""
def test_child_thread_does_not_inherit_context(self):
"""A new thread should NOT see the parent thread's context."""
from local_deep_research.utilities.thread_context import (
set_search_context,
get_search_context,
)
child_result = []
def child_thread():
ctx = get_search_context()
child_result.append(ctx)
# Set context in main thread
set_search_context({"user_password": "main_thread_pass"})
# Child thread should not see it
thread = threading.Thread(target=child_thread)
thread.start()
thread.join(timeout=2)
assert child_result[0] is None, (
"Child thread should not inherit parent's context"
)
def test_child_thread_can_set_own_context(self):
"""A child thread can set and retrieve its own context."""
from local_deep_research.utilities.thread_context import (
set_search_context,
get_search_context,
)
child_result = []
def child_thread():
set_search_context({"user_password": "child_pass"})
ctx = get_search_context()
child_result.append(ctx)
thread = threading.Thread(target=child_thread)
thread.start()
thread.join(timeout=2)
assert child_result[0] is not None
assert child_result[0]["user_password"] == "child_pass"
class TestGetUserDbSessionPasswordRetrieval:
"""Test that get_user_db_session retrieves password from thread context."""
def test_password_retrieved_from_thread_context(self):
"""get_user_db_session should use password from thread context."""
from local_deep_research.utilities.thread_context import (
set_search_context,
)
from local_deep_research.database.session_context import (
get_user_db_session,
)
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.database import thread_local_session
set_search_context(
{
"username": "test_user",
"user_password": "thread_context_password",
}
)
captured_passwords = []
def capture(username, password):
captured_passwords.append(password)
raise Exception("Captured")
with patch(
"local_deep_research.database.session_context.has_app_context",
return_value=False,
):
with patch.object(db_manager, "has_encryption", True):
with patch.object(
thread_local_session,
"get_metrics_session",
side_effect=capture,
):
try:
with get_user_db_session("test_user"):
pass
except Exception:
pass
assert len(captured_passwords) == 1
assert captured_passwords[0] == "thread_context_password"
def test_none_password_causes_error_with_encryption(self):
"""If password is None and encryption is enabled, should raise error."""
from local_deep_research.utilities.thread_context import (
set_search_context,
)
from local_deep_research.database.session_context import (
get_user_db_session,
DatabaseSessionError,
)
from local_deep_research.database.encrypted_db import db_manager
# Set context with None password
set_search_context(
{
"username": "test_user",
"user_password": None,
}
)
with patch(
"local_deep_research.database.session_context.has_app_context",
return_value=False,
):
with patch.object(db_manager, "has_encryption", True):
with pytest.raises(DatabaseSessionError) as exc_info:
with get_user_db_session("test_user"):
pass
assert "requires password" in str(exc_info.value).lower()
+41
View File
@@ -0,0 +1,41 @@
"""Static check: every FK in Base.metadata points at a real table+column.
Catches the R4 class of bug — ``ResearchStrategy.research_id`` declared
``ForeignKey("research.id")`` while the live code wrote UUID strings from
``research_history``. PRAGMA-OFF hid this for ~10 months; PRAGMA-ON in
v1.6.0 made every save fail.
"""
from __future__ import annotations
from local_deep_research.database.models import Base
def test_every_fk_target_table_and_column_exists():
tables = {t.name: t for t in Base.metadata.sorted_tables}
problems: list[str] = []
for table in Base.metadata.sorted_tables:
for fk in table.foreign_keys:
target_table_name = fk.column.table.name
target_col_name = fk.column.name
target = tables.get(target_table_name)
if target is None:
problems.append(
f"{table.name}.{fk.parent.name} -> "
f"{target_table_name}.{target_col_name}: target table missing"
)
continue
if target_col_name not in target.columns:
problems.append(
f"{table.name}.{fk.parent.name} -> "
f"{target_table_name}.{target_col_name}: target column missing"
)
continue
target_col = target.columns[target_col_name]
if str(fk.parent.type) != str(target_col.type):
problems.append(
f"{table.name}.{fk.parent.name} ({fk.parent.type}) -> "
f"{target_table_name}.{target_col_name} ({target_col.type}): "
"type mismatch"
)
assert not problems, "FK declaration problems:\n " + "\n ".join(problems)
+291
View File
@@ -0,0 +1,291 @@
"""Tests for database initialize module functions."""
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from local_deep_research.database.models import Base
class TestCheckDatabaseSchema:
"""Tests for check_database_schema function."""
def test_returns_dict_with_tables_key(self):
"""check_database_schema returns dict with 'tables' key."""
from local_deep_research.database.initialize import (
check_database_schema,
)
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create tables
Base.metadata.create_all(engine)
result = check_database_schema(engine)
assert isinstance(result, dict)
assert "tables" in result
finally:
engine.dispose()
def test_lists_existing_tables(self):
"""check_database_schema lists existing tables."""
from local_deep_research.database.initialize import (
check_database_schema,
)
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create tables
Base.metadata.create_all(engine)
result = check_database_schema(engine)
# Should have tables dict
assert isinstance(result["tables"], dict)
finally:
engine.dispose()
def test_lists_missing_tables(self):
"""check_database_schema identifies missing tables."""
from local_deep_research.database.initialize import (
check_database_schema,
)
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Don't create any tables
result = check_database_schema(engine)
assert "missing_tables" in result
assert isinstance(result["missing_tables"], list)
finally:
engine.dispose()
def test_detects_news_tables(self):
"""check_database_schema detects news tables presence."""
from local_deep_research.database.initialize import (
check_database_schema,
)
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create tables
Base.metadata.create_all(engine)
result = check_database_schema(engine)
assert "has_news_tables" in result
assert isinstance(result["has_news_tables"], bool)
finally:
engine.dispose()
def test_returns_columns_for_each_table(self):
"""check_database_schema returns column names for existing tables."""
from local_deep_research.database.initialize import (
check_database_schema,
)
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create tables
Base.metadata.create_all(engine)
result = check_database_schema(engine)
# Each table in tables dict should have a list of columns
for table_name, columns in result["tables"].items():
assert isinstance(columns, list)
finally:
engine.dispose()
class TestInitializeDefaultSettings:
"""Tests for _initialize_default_settings function."""
def test_calls_settings_manager(self):
"""_initialize_default_settings calls SettingsManager methods."""
from local_deep_research.database.initialize import (
_initialize_default_settings,
)
mock_session = Mock(spec=Session)
with patch(
"local_deep_research.settings.manager.SettingsManager"
) as MockSettingsManager:
mock_settings_mgr = Mock()
mock_settings_mgr.db_version_matches_package.return_value = False
MockSettingsManager.return_value = mock_settings_mgr
_initialize_default_settings(mock_session)
MockSettingsManager.assert_called_once_with(mock_session)
mock_settings_mgr.db_version_matches_package.assert_called_once()
mock_settings_mgr.load_from_defaults_file.assert_called_once()
mock_settings_mgr.update_db_version.assert_called_once()
def test_skips_when_version_matches(self):
"""_initialize_default_settings skips update when version matches."""
from local_deep_research.database.initialize import (
_initialize_default_settings,
)
mock_session = Mock(spec=Session)
with patch(
"local_deep_research.settings.manager.SettingsManager"
) as MockSettingsManager:
mock_settings_mgr = Mock()
mock_settings_mgr.db_version_matches_package.return_value = True
MockSettingsManager.return_value = mock_settings_mgr
_initialize_default_settings(mock_session)
# Should not call load_from_defaults_file
mock_settings_mgr.load_from_defaults_file.assert_not_called()
def test_handles_errors_gracefully(self):
"""_initialize_default_settings swallows SettingsManager errors.
Background: PR #2235 originally tried to make DB errors propagate
through this code path. PR #2118 (Feb 22 2026, commit 76524cc4de)
walked that change back because masking failures here was causing
runtime bugs / CI failure masking — startup must be resilient
even when the user's settings DB is corrupt or missing. The
current contract is: SettingsManager errors during initial
defaults seeding are logged-and-swallowed, not raised. This test
pins that contract.
PUNCHLIST historically flagged this as H5_SWALLOWS_ERROR. That
flag is a false positive against the current SUT — see
settings/manager.py:780-786 for the catch-and-log site.
"""
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: FIX).
from local_deep_research.database.initialize import (
_initialize_default_settings,
)
mock_session = Mock(spec=Session)
with patch(
"local_deep_research.settings.manager.SettingsManager"
) as MockSettingsManager:
MockSettingsManager.side_effect = Exception("Settings error")
# Must not raise — startup-resilience contract per PR #2118.
_initialize_default_settings(mock_session)
class TestInitializeDatabase:
"""Tests for initialize_database function."""
def test_creates_all_tables(self):
"""initialize_database creates all tables from Base.metadata."""
from local_deep_research.database.initialize import initialize_database
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
initialize_database(engine)
# Verify tables were created
from sqlalchemy import inspect
inspector = inspect(engine)
tables = inspector.get_table_names()
# Should have at least some tables
assert len(tables) > 0
finally:
engine.dispose()
def test_calls_run_migrations(self):
"""initialize_database calls run_migrations."""
from local_deep_research.database.initialize import initialize_database
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
with patch(
"local_deep_research.database.initialize.run_migrations"
) as mock_migrations:
initialize_database(engine)
mock_migrations.assert_called_once_with(engine)
finally:
engine.dispose()
def test_initializes_settings_when_session_provided(self):
"""initialize_database initializes settings when session provided."""
from local_deep_research.database.initialize import initialize_database
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
mock_session = Mock(spec=Session)
with patch(
"local_deep_research.database.initialize._initialize_default_settings"
) as mock_init_settings:
initialize_database(engine, db_session=mock_session)
mock_init_settings.assert_called_once_with(mock_session)
finally:
engine.dispose()
def test_skips_settings_when_no_session(self):
"""initialize_database skips settings init when no session provided."""
from local_deep_research.database.initialize import initialize_database
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
with patch(
"local_deep_research.database.initialize._initialize_default_settings"
) as mock_init_settings:
initialize_database(engine)
mock_init_settings.assert_not_called()
finally:
engine.dispose()
def test_handles_checkfirst_for_existing_tables(self):
"""initialize_database uses checkfirst=True for existing tables."""
from local_deep_research.database.initialize import initialize_database
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create tables first
Base.metadata.create_all(engine)
# Run initialize again - should not fail
initialize_database(engine)
# Verify tables still exist
from sqlalchemy import inspect
inspector = inspect(engine)
tables = inspector.get_table_names()
assert len(tables) > 0
finally:
engine.dispose()
@@ -0,0 +1,116 @@
"""Journal-quality migration regression test against a SQLCipher-encrypted DB.
The existing `test_encrypted_database_orm.py` exercises ORM operations
but never explicitly walks the new journal-quality chain. This test
creates a fresh user DB via :class:`DatabaseManager`, runs migrations
to head, inserts a Journal row carrying every kept column, closes the
engine, reopens it with the same key, and reads the row back.
Why this matters: SQLCipher-keyed engines route every statement through
the sqlcipher_utils key-first ordering, and batch_alter_table rebuilds
the journals table. A key-management regression would show up here.
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
import pytest
pytest.importorskip(
"sqlcipher3",
reason="SQLCipher is not available on this platform; the encrypted "
"migration test requires it to exercise the encrypted engine path.",
)
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 Journal
@pytest.fixture
def temp_data_dir(monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir)
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: path,
)
yield path
@pytest.fixture
def db_manager(temp_data_dir):
m = DatabaseManager()
yield m
for username in list(m.connections.keys()):
m.close_user_database(username)
def test_journal_roundtrip_through_encrypted_migrations(db_manager):
"""Create → migrate → write → reopen → read on a keyed DB."""
assert db_manager.has_encryption, (
"sqlcipher3 imports but DatabaseManager reported has_encryption=False; "
"check for LDR_BOOTSTRAP_ALLOW_UNENCRYPTED or a broken SQLCipher install."
)
username = "journalman"
password = "StrongPassword1!"
# Fresh encrypted DB — create_user_database runs migrations to head
# via initialize_database/run_migrations.
db_manager.create_user_database(username, password)
with db_manager.get_session(username) as session:
row = Journal(
name="Journal Of Encrypted Test Cases",
name_lower="journal of encrypted test cases",
quality=9,
score_source="llm",
quality_model="gpt-test-2026",
quality_analysis_time=1_700_000_000,
)
session.add(row)
session.commit()
row_id = row.id
# Close and reopen — new engine, same key — and verify persistence.
db_manager.close_user_database(username)
db_manager.open_user_database(username, password)
with db_manager.get_session(username) as session:
persisted = session.query(Journal).filter_by(id=row_id).one()
assert persisted.name == "Journal Of Encrypted Test Cases"
assert persisted.name_lower == "journal of encrypted test cases"
assert persisted.quality == 9
assert persisted.score_source == "llm"
assert persisted.quality_model == "gpt-test-2026"
def test_journal_column_set_after_encrypted_migration(db_manager):
"""Post-migration schema must match the 7-column final shape."""
assert db_manager.has_encryption, (
"sqlcipher3 imports but DatabaseManager reported has_encryption=False; "
"check for LDR_BOOTSTRAP_ALLOW_UNENCRYPTED or a broken SQLCipher install."
)
from sqlalchemy import inspect
username = "shapetester"
password = "StrongPassword1!"
db_manager.create_user_database(username, password)
engine = db_manager.connections[username]
cols = {c["name"] for c in inspect(engine).get_columns("journals")}
assert cols == {
"id",
"name",
"name_lower",
"quality",
"score_source",
"quality_model",
"quality_analysis_time",
}, cols
@@ -0,0 +1,119 @@
"""Data-preservation guarantees for the journals-table rebuild.
Adding ``name_lower`` and its index in the journal-quality migration
triggers a SQLite ``batch_alter_table`` rebuild (ALTER ADD COLUMN
+ index is rewritten as a full copy under the hood). This test
populates the table with 100 diverse rows *before* the migrations
touch it, runs the chain, and asserts every row survives with its
seeded columns preserved (``name``, ``quality_analysis_time``) and
``name_lower`` correctly backfilled — including diacritic, CJK, and
padded-whitespace name variants.
The ``batch_alter_table`` rebuild happens inside an Alembic
transaction, so SQLite's atomicity guarantees the table is either
fully rebuilt or untouched; a simulated mid-rebuild crash is
covered by SQLite's transaction rollback, not by our code. The
test therefore focuses on what the *output* of a successful rebuild
must look like: zero data loss, backfilled Unicode.
"""
from __future__ import annotations
import tempfile
import unicodedata
from pathlib import Path
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import run_migrations
from local_deep_research.database.models import Base
def _expected_name_lower(name: str) -> str:
"""Mirror the migration's backfill expression so the test locks in
NFKC + lower + strip semantics, not bare str.lower(). Divergence
between writers produces silent cache misses — see
0006_journal_quality_system.py Step 1/3.
"""
return unicodedata.normalize("NFKC", name).lower().strip()
def _make_engine():
tmp = Path(tempfile.mkdtemp()) / "rebuild.db"
return create_engine(f"sqlite:///{tmp}")
def _seed(engine, n: int) -> list[tuple[str, int]]:
"""Insert ``n`` journal rows with a mix of ASCII and Unicode names."""
rows = []
with engine.begin() as conn:
conn.execute(text("DELETE FROM journals"))
for i in range(n):
# Mix: diacritics, Asian, cased, whitespace.
if i % 4 == 0:
name = f"Café Research {i}"
elif i % 4 == 1:
name = f"JOURNAL {i}"
elif i % 4 == 2:
name = f"日本の学術誌 {i}"
else:
name = f" Spaced Title {i} "
q_time = 1_700_000_000 + i
conn.execute(
text(
"INSERT INTO journals (name, quality_analysis_time) "
"VALUES (:n, :t)"
),
{"n": name, "t": q_time},
)
rows.append((name, q_time))
return rows
def test_all_rows_survive_the_chain_with_correct_backfill():
engine = _make_engine()
try:
Base.metadata.create_all(engine)
seed_rows = _seed(engine, 100)
# Wipe any name_lower the ORM default might have set so the backfill
# branch is the one under test.
with engine.begin() as conn:
conn.execute(text("UPDATE journals SET name_lower = NULL"))
run_migrations(engine)
# 100 rows still there; name / quality_analysis_time preserved;
# name_lower backfilled correctly.
with engine.begin() as conn:
actual = conn.execute(
text(
"SELECT name, name_lower, quality_analysis_time "
"FROM journals ORDER BY id"
)
).all()
assert len(actual) == len(seed_rows), (
f"Row count regression: {len(actual)} vs {len(seed_rows)}"
)
for (seed_name, seed_t), row in zip(seed_rows, actual):
assert row.name == seed_name
assert row.name_lower == _expected_name_lower(seed_name)
assert row.quality_analysis_time == seed_t
finally:
engine.dispose()
def test_no_orphan_tmp_table_after_migration():
"""Alembic's batch rebuild must not leave ``_alembic_tmp_journals``."""
engine = _make_engine()
try:
Base.metadata.create_all(engine)
_seed(engine, 10)
run_migrations(engine)
insp = inspect(engine)
table_names = set(insp.get_table_names())
orphans = {t for t in table_names if t.startswith("_alembic_tmp_")}
assert not orphans, f"Orphan rebuild tables remain: {orphans}"
finally:
engine.dispose()
+140
View File
@@ -0,0 +1,140 @@
"""
Tests for _get_min_kdf_iterations() in database/sqlcipher_utils.py
Tests cover:
- Production mode (no test env vars) returns 100K iterations
- PYTEST_CURRENT_TEST env var triggers test mode (1 iteration)
- LDR_TEST_MODE env var triggers test mode (1 iteration)
- Both env vars set → still returns 1
- Constants have correct values
"""
import pytest
class TestGetMinKdfIterations:
"""Tests for _get_min_kdf_iterations()."""
def test_production_mode_returns_100k(self, monkeypatch):
"""No test env vars → production iterations (100_000)."""
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.delenv("LDR_TEST_MODE", raising=False)
from local_deep_research.database.sqlcipher_utils import (
_get_min_kdf_iterations,
)
assert _get_min_kdf_iterations() == 100_000
def test_pytest_current_test_triggers_test_mode(self, monkeypatch):
"""PYTEST_CURRENT_TEST set → test iterations (1)."""
monkeypatch.setenv("PYTEST_CURRENT_TEST", "tests/test_foo.py::test_bar")
monkeypatch.delenv("LDR_TEST_MODE", raising=False)
from local_deep_research.database.sqlcipher_utils import (
_get_min_kdf_iterations,
)
assert _get_min_kdf_iterations() == 1
def test_ldr_test_mode_triggers_test_mode(self, monkeypatch):
"""LDR_TEST_MODE set → test iterations (1)."""
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.setenv("LDR_TEST_MODE", "1")
from local_deep_research.database.sqlcipher_utils import (
_get_min_kdf_iterations,
)
assert _get_min_kdf_iterations() == 1
def test_both_env_vars_set(self, monkeypatch):
"""Both env vars set → still returns test iterations."""
monkeypatch.setenv("PYTEST_CURRENT_TEST", "tests/test_foo.py::test_bar")
monkeypatch.setenv("LDR_TEST_MODE", "1")
from local_deep_research.database.sqlcipher_utils import (
_get_min_kdf_iterations,
)
assert _get_min_kdf_iterations() == 1
def test_empty_pytest_current_test_is_production(self, monkeypatch):
"""Empty string for PYTEST_CURRENT_TEST is falsy → production mode."""
monkeypatch.setenv("PYTEST_CURRENT_TEST", "")
monkeypatch.delenv("LDR_TEST_MODE", raising=False)
from local_deep_research.database.sqlcipher_utils import (
_get_min_kdf_iterations,
)
assert _get_min_kdf_iterations() == 100_000
def test_empty_ldr_test_mode_is_production(self, monkeypatch):
"""Empty string for LDR_TEST_MODE is falsy → production mode."""
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.setenv("LDR_TEST_MODE", "")
from local_deep_research.database.sqlcipher_utils import (
_get_min_kdf_iterations,
)
assert _get_min_kdf_iterations() == 100_000
@pytest.mark.parametrize(
"falsey", ["0", "false", "False", "no", "off", "banana"]
)
def test_falsey_ldr_test_mode_is_production(self, monkeypatch, falsey):
"""LDR_TEST_MODE is parsed as a boolean: explicit falsey values (and
unrecognised strings like 'banana') must NOT relax the floor. A bare
truthiness check would treat any non-empty string as enabled and
silently weaken encryption.
"""
# Load-bearing: _get_min_kdf_iterations() also relaxes the floor when
# PYTEST_CURRENT_TEST is present (which it always is under pytest), so
# we must clear it to isolate the LDR_TEST_MODE behaviour under test.
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.setenv("LDR_TEST_MODE", falsey)
from local_deep_research.database.sqlcipher_utils import (
_get_min_kdf_iterations,
)
assert _get_min_kdf_iterations() == 100_000
@pytest.mark.parametrize("truthy", ["true", "TRUE", "yes", "on", "enabled"])
def test_extended_truthy_ldr_test_mode_relaxes(self, monkeypatch, truthy):
"""LDR_TEST_MODE accepts the full boolean truthy set (to_bool), not
just '1'/'true' — locks that behavior so a narrower parser would be
caught.
"""
# Clear PYTEST_CURRENT_TEST so the relaxation we observe is attributable
# to LDR_TEST_MODE, not pytest's own presence (see falsey test above).
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.setenv("LDR_TEST_MODE", truthy)
from local_deep_research.database.sqlcipher_utils import (
_get_min_kdf_iterations,
)
assert _get_min_kdf_iterations() == 1
class TestKdfConstants:
"""Tests for KDF iteration constants."""
def test_production_constant_is_100k(self):
"""MIN_KDF_ITERATIONS_PRODUCTION should be 100_000."""
from local_deep_research.database.sqlcipher_utils import (
MIN_KDF_ITERATIONS_PRODUCTION,
)
assert MIN_KDF_ITERATIONS_PRODUCTION == 100_000
def test_testing_constant_is_1(self):
"""MIN_KDF_ITERATIONS_TESTING should be 1."""
from local_deep_research.database.sqlcipher_utils import (
MIN_KDF_ITERATIONS_TESTING,
)
assert MIN_KDF_ITERATIONS_TESTING == 1
+432
View File
@@ -0,0 +1,432 @@
"""
Tests for database/library_init.py.
Tests the library initialization module which handles:
- Seeding source_types table with predefined types
- Creating the default "Library" collection
- Full library initialization orchestration
"""
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.exc import IntegrityError
from tests.test_utils import add_src_to_path
add_src_to_path()
from local_deep_research.database.library_init import ( # noqa: E402
seed_source_types,
ensure_default_library_collection,
ensure_research_history_collection,
initialize_library_for_user,
get_default_library_id,
get_source_type_id,
)
class TestSeedSourceTypes:
"""Tests for seed_source_types function."""
@patch("local_deep_research.database.library_init.get_user_db_session")
@patch("local_deep_research.database.library_init.uuid.uuid4")
def test_creates_all_predefined_types_when_none_exist(
self, mock_uuid, mock_get_session
):
"""Should create all predefined source types."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
# Simulate no existing types
mock_session.query.return_value.filter_by.return_value.first.return_value = None
mock_uuid.return_value = "test-uuid-123"
seed_source_types("testuser", "testpass")
# Should have queried for each type and added 6 new ones
# (research_download, user_upload, manual_entry, research_report,
# research_source, zotero)
assert mock_session.add.call_count == 6
mock_session.commit.assert_called_once()
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_skips_existing_types(self, mock_get_session):
"""Should not duplicate existing source types."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
# Simulate all types already exist
mock_existing = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_existing
seed_source_types("testuser", "testpass")
# Should not add any new types
mock_session.add.assert_not_called()
mock_session.commit.assert_called_once()
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_handles_integrity_error_gracefully(self, mock_get_session):
"""Should handle IntegrityError gracefully without raising."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
mock_session.commit.side_effect = IntegrityError(
"statement", "params", "orig"
)
# Should not raise
seed_source_types("testuser", "testpass")
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_raises_on_unexpected_error(self, mock_get_session):
"""Should re-raise unexpected exceptions."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.side_effect = RuntimeError(
"Database connection failed"
)
with pytest.raises(RuntimeError, match="Database connection failed"):
seed_source_types("testuser", "testpass")
@patch("local_deep_research.database.library_init.logger")
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_logs_creation_messages(self, mock_get_session, mock_logger):
"""Should log when creating source types."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
seed_source_types("testuser", "testpass")
# Should log info for each created type and final success
assert mock_logger.info.call_count >= 3
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_creates_types_with_correct_attributes(self, mock_get_session):
"""Should create source types with correct name, display_name, description, icon."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
seed_source_types("testuser")
# Check the types created
added_types = [call.args[0] for call in mock_session.add.call_args_list]
type_names = [t.name for t in added_types]
assert "research_download" in type_names
assert "user_upload" in type_names
assert "manual_entry" in type_names
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_password_is_optional(self, mock_get_session):
"""Should work when password is not provided."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = MagicMock()
# Should not raise when password is None
seed_source_types("testuser")
mock_get_session.assert_called_once_with("testuser", None)
class TestEnsureDefaultLibraryCollection:
"""Tests for ensure_default_library_collection function."""
@patch("local_deep_research.database.library_init.get_user_db_session")
@patch("local_deep_research.database.library_init.uuid.uuid4")
def test_creates_collection_when_none_exists(
self, mock_uuid, mock_get_session
):
"""Should create default Library collection when none exists."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
mock_uuid.return_value = "new-library-uuid"
result = ensure_default_library_collection("testuser", "testpass")
assert result == "new-library-uuid"
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_returns_existing_collection_id(self, mock_get_session):
"""Should return ID of existing default collection."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_existing = MagicMock()
mock_existing.id = "existing-library-uuid"
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_existing
result = ensure_default_library_collection("testuser", "testpass")
assert result == "existing-library-uuid"
mock_session.add.assert_not_called()
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_creates_with_is_default_true(self, mock_get_session):
"""Should set is_default=True on new collection."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
ensure_default_library_collection("testuser")
# Check that the collection was created with is_default=True
added_collection = mock_session.add.call_args.args[0]
assert added_collection.is_default is True
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_creates_with_correct_name_and_type(self, mock_get_session):
"""Should create collection with name='Library' and type='default_library'."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
ensure_default_library_collection("testuser")
added_collection = mock_session.add.call_args.args[0]
assert added_collection.name == "Library"
assert added_collection.collection_type == "default_library"
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_raises_on_database_error(self, mock_get_session):
"""Should re-raise exceptions from database operations."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.side_effect = RuntimeError("Database error")
with pytest.raises(RuntimeError, match="Database error"):
ensure_default_library_collection("testuser")
class TestEnsureResearchHistoryCollection:
"""Tests for ensure_research_history_collection function."""
@patch("local_deep_research.database.library_init.get_user_db_session")
@patch("local_deep_research.database.library_init.uuid.uuid4")
def test_creates_collection_when_none_exists(
self, mock_uuid, mock_get_session
):
"""Should create Research History collection when none exists."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
mock_uuid.return_value = "new-history-uuid"
result = ensure_research_history_collection("testuser", "testpass")
assert result == "new-history-uuid"
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_returns_existing_collection_id(self, mock_get_session):
"""Should return ID of existing Research History collection."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_existing = MagicMock()
mock_existing.id = "existing-history-uuid"
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_existing
result = ensure_research_history_collection("testuser", "testpass")
assert result == "existing-history-uuid"
mock_session.add.assert_not_called()
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_reraises_exception(self, mock_get_session):
"""Should re-raise exceptions from database operations."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.side_effect = RuntimeError("Database error")
with pytest.raises(RuntimeError, match="Database error"):
ensure_research_history_collection("testuser")
class TestInitializeLibraryForUser:
"""Tests for initialize_library_for_user function."""
@patch(
"local_deep_research.database.library_init.ensure_research_history_collection"
)
@patch(
"local_deep_research.database.library_init.ensure_default_library_collection"
)
@patch("local_deep_research.database.library_init.seed_source_types")
def test_returns_success_result(self, mock_seed, mock_ensure, mock_history):
"""Should return dict with success=True on success."""
mock_ensure.return_value = "library-uuid-123"
mock_history.return_value = "history-uuid-456"
result = initialize_library_for_user("testuser", "testpass")
assert result["success"] is True
assert result["source_types_seeded"] is True
assert result["library_collection_id"] == "library-uuid-123"
assert result["research_history_collection_id"] == "history-uuid-456"
assert "error" not in result
@patch(
"local_deep_research.database.library_init.ensure_research_history_collection"
)
@patch(
"local_deep_research.database.library_init.ensure_default_library_collection"
)
@patch("local_deep_research.database.library_init.seed_source_types")
def test_returns_error_on_seed_failure(
self, mock_seed, mock_ensure, mock_history
):
"""Should include error message when seed_source_types fails."""
mock_seed.side_effect = RuntimeError("Seeding failed")
result = initialize_library_for_user("testuser")
assert result["success"] is False
assert result["source_types_seeded"] is False
assert "error" in result
assert "Seeding failed" in result["error"]
@patch(
"local_deep_research.database.library_init.ensure_research_history_collection"
)
@patch(
"local_deep_research.database.library_init.ensure_default_library_collection"
)
@patch("local_deep_research.database.library_init.seed_source_types")
def test_returns_error_on_ensure_failure(
self, mock_seed, mock_ensure, mock_history
):
"""Should include error message when ensure_default_library_collection fails."""
mock_ensure.side_effect = RuntimeError("Collection creation failed")
result = initialize_library_for_user("testuser")
assert result["success"] is False
assert result["source_types_seeded"] is True # Seeding succeeded
assert result["library_collection_id"] is None
assert "error" in result
assert "Collection creation failed" in result["error"]
@patch(
"local_deep_research.database.library_init.ensure_research_history_collection"
)
@patch(
"local_deep_research.database.library_init.ensure_default_library_collection"
)
@patch("local_deep_research.database.library_init.seed_source_types")
def test_calls_seed_and_ensure_in_order(
self, mock_seed, mock_ensure, mock_history
):
"""Should call both seed_source_types and ensure_default_library_collection."""
mock_ensure.return_value = "library-uuid"
call_order = []
mock_seed.side_effect = lambda *args, **kwargs: call_order.append(
"seed"
)
mock_ensure.side_effect = lambda *args, **kwargs: (
call_order.append("ensure"),
"library-uuid",
)[1]
initialize_library_for_user("testuser", "testpass")
assert call_order == ["seed", "ensure"]
mock_seed.assert_called_once_with("testuser", "testpass")
mock_ensure.assert_called_once_with("testuser", "testpass")
@patch(
"local_deep_research.database.library_init.ensure_default_library_collection"
)
@patch("local_deep_research.database.library_init.seed_source_types")
def test_returns_all_expected_keys(self, mock_seed, mock_ensure):
"""Should return dict with all expected keys."""
mock_ensure.return_value = "lib-id"
result = initialize_library_for_user("testuser")
assert "source_types_seeded" in result
assert "library_collection_id" in result
assert "success" in result
class TestGetDefaultLibraryId:
"""Tests for get_default_library_id function."""
@patch(
"local_deep_research.database.library_init.ensure_default_library_collection"
)
def test_returns_library_id(self, mock_ensure):
"""Should return the library collection ID."""
mock_ensure.return_value = "default-lib-uuid"
result = get_default_library_id("testuser", "testpass")
assert result == "default-lib-uuid"
mock_ensure.assert_called_once_with("testuser", "testpass")
@patch(
"local_deep_research.database.library_init.ensure_default_library_collection"
)
def test_creates_library_if_missing(self, mock_ensure):
"""Should create library if it doesn't exist (via ensure_default_library_collection)."""
mock_ensure.return_value = "new-lib-uuid"
result = get_default_library_id("testuser")
assert result == "new-lib-uuid"
class TestGetSourceTypeId:
"""Tests for get_source_type_id function."""
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_returns_id_for_valid_type(self, mock_get_session):
"""Should return ID for existing source type."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_source_type = MagicMock()
mock_source_type.id = "research-download-uuid"
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_source_type
result = get_source_type_id("testuser", "research_download", "testpass")
assert result == "research-download-uuid"
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_raises_value_error_for_unknown_type(self, mock_get_session):
"""Should raise ValueError for non-existent type."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.filter_by.return_value.first.return_value = None
with pytest.raises(ValueError, match="Source type not found"):
get_source_type_id("testuser", "nonexistent_type")
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_raises_on_database_error(self, mock_get_session):
"""Should re-raise database errors after logging."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_session.query.side_effect = RuntimeError("Connection lost")
with pytest.raises(RuntimeError, match="Connection lost"):
get_source_type_id("testuser", "user_upload")
@patch("local_deep_research.database.library_init.get_user_db_session")
def test_password_is_optional(self, mock_get_session):
"""Should work when password is not provided."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__.return_value = mock_session
mock_source_type = MagicMock()
mock_source_type.id = "type-uuid"
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_source_type
result = get_source_type_id("testuser", "manual_entry")
assert result == "type-uuid"
mock_get_session.assert_called_once_with("testuser", None)
+316
View File
@@ -0,0 +1,316 @@
"""Tests for metrics tracking database models."""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import (
Base,
ModelUsage,
ResearchRating,
SearchCall,
TokenUsage,
)
class TestMetricsModels:
"""Test suite for metrics tracking models."""
@pytest.fixture
def engine(self):
"""Create an in-memory SQLite database for testing."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(self, engine):
"""Create a database session for testing."""
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_token_usage_tracking(self, session):
"""Test TokenUsage model for tracking LLM token consumption."""
usage = TokenUsage(
research_id="research-123",
model_provider="openai",
model_name="gpt-4",
prompt_tokens=500,
completion_tokens=150,
total_tokens=650,
prompt_cost=0.015,
completion_cost=0.0045,
total_cost=0.0195,
timestamp=datetime.now(timezone.utc),
operation_type="synthesis",
operation_details={
"temperature": 0.7,
"purpose": "synthesis",
"request_id": "req_abc123",
},
)
session.add(usage)
session.commit()
# Verify the usage record
saved = session.query(TokenUsage).first()
assert saved is not None
assert saved.model_provider == "openai"
assert saved.model_name == "gpt-4"
assert saved.total_tokens == 650
assert saved.total_cost == 0.0195
assert saved.operation_type == "synthesis"
assert saved.operation_details["purpose"] == "synthesis"
def test_model_usage_aggregation(self, session):
"""Test ModelUsage for aggregating model usage statistics."""
model_usage = ModelUsage(
model_provider="anthropic",
model_name="claude-3-opus",
total_calls=5,
total_tokens=1450,
total_cost=0.10,
avg_response_time_ms=250.5,
error_count=0,
success_rate=100.0,
first_used_at=datetime.now(timezone.utc),
last_used_at=datetime.now(timezone.utc),
)
session.add(model_usage)
session.commit()
# Verify aggregated stats
saved = session.query(ModelUsage).first()
assert saved is not None
assert saved.model_provider == "anthropic"
assert saved.model_name == "claude-3-opus"
assert saved.total_calls == 5
assert saved.total_tokens == 1450
assert saved.total_cost == 0.10
assert saved.success_rate == 100.0
def test_research_rating(self, session):
"""Test ResearchRating model for user feedback."""
rating = ResearchRating(
research_id="research-456",
rating=4,
accuracy=5,
completeness=4,
relevance=5,
readability=3,
feedback="Great research results, but the summary could be clearer.",
created_at=datetime.now(timezone.utc),
)
session.add(rating)
session.commit()
# Verify rating
saved = session.query(ResearchRating).first()
assert saved is not None
assert saved.rating == 4
assert saved.accuracy == 5
assert saved.relevance == 5
assert "summary could be clearer" in saved.feedback
def test_search_call_tracking(self, session):
"""Test SearchCall model for tracking search engine calls."""
search = SearchCall(
research_id="research-789",
search_engine="google",
query="quantum computing applications",
num_results_requested=10,
num_results_returned=10,
response_time_ms=150.5,
success=1,
error_message=None,
rate_limited=0,
timestamp=datetime.now(timezone.utc),
)
session.add(search)
session.commit()
# Verify search call
saved = session.query(SearchCall).first()
assert saved is not None
assert saved.search_engine == "google"
assert saved.query == "quantum computing applications"
assert saved.success == 1
assert saved.response_time_ms == 150.5
def test_metrics_relationships(self, session):
"""Test relationships between metrics models."""
research_id = "research-shared-123"
# Create related metrics for the same research
token_usage = TokenUsage(
research_id=research_id,
model_provider="openai",
model_name="gpt-4",
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
total_cost=0.0045,
)
search_call = SearchCall(
research_id=research_id,
search_engine="bing",
query="test query",
num_results_returned=5,
)
rating = ResearchRating(
research_id=research_id, rating=5, feedback="Excellent"
)
session.add_all([token_usage, search_call, rating])
session.commit()
# Query by research_id
tokens = (
session.query(TokenUsage).filter_by(research_id=research_id).all()
)
searches = (
session.query(SearchCall).filter_by(research_id=research_id).all()
)
ratings = (
session.query(ResearchRating)
.filter_by(research_id=research_id)
.all()
)
assert len(tokens) == 1
assert len(searches) == 1
assert len(ratings) == 1
def test_cost_tracking(self, session):
"""Test cost tracking across different models."""
# Add multiple token usage records
for i in range(3):
usage = TokenUsage(
research_id=f"research-cost-{i}",
model_provider="openai",
model_name="gpt-4",
prompt_tokens=1000,
completion_tokens=500,
total_tokens=1500,
prompt_cost=0.03,
completion_cost=0.015,
total_cost=0.045,
)
session.add(usage)
session.commit()
# Calculate total costs
from sqlalchemy import func
total_cost = session.query(func.sum(TokenUsage.total_cost)).scalar()
assert total_cost == 0.135 # 3 * 0.045
def test_search_engine_performance(self, session):
"""Test tracking search engine performance metrics."""
engines = ["google", "bing", "duckduckgo"]
for engine in engines:
for i in range(5):
search = SearchCall(
research_id=f"research-perf-{engine}-{i}",
search_engine=engine,
query=f"test query {i}",
num_results_requested=10,
num_results_returned=10 if i != 2 else 0, # One failure
response_time_ms=100 + i * 50,
success=1 if i != 2 else 0,
error_message=None if i != 2 else "Network error",
)
session.add(search)
session.commit()
# Analyze performance by engine
from sqlalchemy import func
engine_stats = (
session.query(
SearchCall.search_engine,
func.count(SearchCall.id).label("total_calls"),
func.avg(SearchCall.response_time_ms).label(
"avg_response_time"
),
func.sum(SearchCall.success).label("successful_calls"),
)
.group_by(SearchCall.search_engine)
.all()
)
assert len(engine_stats) == 3
for stat in engine_stats:
assert stat.total_calls == 5
assert stat.successful_calls == 4 # 4 out of 5 successful
def test_rating_aggregation(self, session):
"""Test aggregating user ratings."""
# Create multiple ratings
for i in range(10):
rating = ResearchRating(
research_id=f"research-rate-{i}",
rating=3 + (i % 3), # Ratings: 3, 4, 5, 3, 4, 5...
accuracy=4 if i % 2 == 0 else 5,
completeness=3 + (i % 2),
relevance=5,
readability=4,
)
session.add(rating)
session.commit()
# Calculate average ratings
from sqlalchemy import func
avg_rating = session.query(func.avg(ResearchRating.rating)).scalar()
avg_accuracy = session.query(func.avg(ResearchRating.accuracy)).scalar()
assert avg_rating > 3.5
assert avg_accuracy > 4.0
def test_time_based_metrics(self, session):
"""Test querying metrics by time ranges."""
now = datetime.now(timezone.utc)
# Create token usage over different time periods
for days_ago in range(7):
for i in range(3):
usage = TokenUsage(
research_id=f"research-time-{days_ago}-{i}",
model_provider="anthropic",
model_name="claude-3",
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
total_cost=0.005,
timestamp=now - timedelta(days=days_ago),
)
session.add(usage)
session.commit()
# Query last 3 days
three_days_ago = now - timedelta(days=3)
recent_usage = (
session.query(TokenUsage)
.filter(TokenUsage.timestamp >= three_days_ago)
.count()
)
# Should have 3 days * 3 records per day = 9 records
assert recent_usage == 12 # days 0, 1, 2, 3 = 4 days * 3 records
@@ -0,0 +1,601 @@
"""
Tests for migration 0003: Add research indexes.
Tests cover:
- Index creation on research_tasks and research_history
- Full migration chain from empty database
- Idempotency (running migrations multiple times)
- Downgrade behavior
- Data preservation during migration
- Edge cases (partial tables, empty tables, in-memory databases)
"""
from alembic import command
import pytest
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
get_current_revision,
get_head_revision,
needs_migration,
run_migrations,
)
# Expected indexes from migration 0003
RESEARCH_TASKS_INDEXES = {
"ix_research_tasks_status": ["status"],
"ix_research_tasks_created_at": ["created_at"],
"idx_research_task_status_created": ["status", "created_at"],
"idx_research_task_priority_status": ["priority", "status"],
}
RESEARCH_HISTORY_INDEXES = {
"ix_research_history_mode": ["mode"],
"ix_research_history_status": ["status"],
"ix_research_history_created_at": ["created_at"],
"idx_research_history_status_created": ["status", "created_at"],
"idx_research_history_mode_status": ["mode", "status"],
}
def _get_indexes_by_name(engine, table_name):
"""Get a dict of {index_name: [column_names]} for a table."""
insp = inspect(engine)
if not insp.has_table(table_name):
return {}
return {
idx["name"]: idx["column_names"]
for idx in insp.get_indexes(table_name)
if idx["name"] is not None
}
def _run_upgrade_to(engine, revision):
"""Run migrations up to a specific revision."""
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
"""Run downgrade to a specific revision."""
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
@pytest.fixture
def fresh_engine(tmp_path):
"""Create a fresh SQLite engine (empty database, no tables)."""
db_path = tmp_path / "fresh_0003_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
@pytest.fixture
def migrated_to_0002_engine(tmp_path):
"""Create a database migrated to revision 0002 (tables exist, no research indexes)."""
db_path = tmp_path / "migrated_0002_test.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0002")
yield engine
engine.dispose()
@pytest.fixture
def fully_migrated_engine(tmp_path):
"""Create a database migrated up to revision 0003 (this file's target).
Originally this upgraded all the way to head, but later non-reversible
migrations (0010 raises NotImplementedError on downgrade) make the
downgrade tests below unrunnable when going through head. Since every
test in this file is scoped to 0003 behaviour, stop the upgrade there.
"""
db_path = tmp_path / "fully_migrated_0003_test.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0003")
yield engine
engine.dispose()
class TestMigration0003UpgradeIndexes:
"""Tests that verify index creation on upgrade to 0003."""
def test_creates_research_task_single_column_indexes(
self, fully_migrated_engine
):
"""Verify ix_research_tasks_status and ix_research_tasks_created_at exist."""
indexes = _get_indexes_by_name(fully_migrated_engine, "research_tasks")
assert "ix_research_tasks_status" in indexes
assert indexes["ix_research_tasks_status"] == ["status"]
assert "ix_research_tasks_created_at" in indexes
assert indexes["ix_research_tasks_created_at"] == ["created_at"]
def test_creates_research_task_composite_indexes(
self, fully_migrated_engine
):
"""Verify composite indexes on research_tasks with correct column order."""
indexes = _get_indexes_by_name(fully_migrated_engine, "research_tasks")
assert "idx_research_task_status_created" in indexes
assert indexes["idx_research_task_status_created"] == [
"status",
"created_at",
]
assert "idx_research_task_priority_status" in indexes
assert indexes["idx_research_task_priority_status"] == [
"priority",
"status",
]
def test_creates_research_history_single_column_indexes(
self, fully_migrated_engine
):
"""Verify single-column indexes on research_history."""
indexes = _get_indexes_by_name(
fully_migrated_engine, "research_history"
)
assert "ix_research_history_mode" in indexes
assert indexes["ix_research_history_mode"] == ["mode"]
assert "ix_research_history_status" in indexes
assert indexes["ix_research_history_status"] == ["status"]
assert "ix_research_history_created_at" in indexes
assert indexes["ix_research_history_created_at"] == ["created_at"]
def test_creates_research_history_composite_indexes(
self, fully_migrated_engine
):
"""Verify composite indexes on research_history with correct column order."""
indexes = _get_indexes_by_name(
fully_migrated_engine, "research_history"
)
assert "idx_research_history_status_created" in indexes
assert indexes["idx_research_history_status_created"] == [
"status",
"created_at",
]
assert "idx_research_history_mode_status" in indexes
assert indexes["idx_research_history_mode_status"] == ["mode", "status"]
def test_all_indexes_are_non_unique(self, fully_migrated_engine):
"""None of the 9 migration indexes should be unique."""
insp = inspect(fully_migrated_engine)
for table_name in ("research_tasks", "research_history"):
for idx in insp.get_indexes(table_name):
all_expected = {
**RESEARCH_TASKS_INDEXES,
**RESEARCH_HISTORY_INDEXES,
}
if idx["name"] in all_expected:
assert idx["unique"] == 0, (
f"Index {idx['name']} should not be unique"
)
def test_total_index_count_research_tasks(self, fully_migrated_engine):
"""research_tasks should have exactly 4 new indexes from this migration."""
indexes = _get_indexes_by_name(fully_migrated_engine, "research_tasks")
migration_indexes = {
name for name in indexes if name in RESEARCH_TASKS_INDEXES
}
assert len(migration_indexes) == 4
def test_total_index_count_research_history(self, fully_migrated_engine):
"""research_history should have exactly 5 new indexes from this migration."""
indexes = _get_indexes_by_name(
fully_migrated_engine, "research_history"
)
migration_indexes = {
name for name in indexes if name in RESEARCH_HISTORY_INDEXES
}
assert len(migration_indexes) == 5
class TestMigration0003FromFreshDatabase:
"""Tests that verify the full migration chain on a fresh database."""
def test_fresh_db_full_migration_creates_indexes(self, fresh_engine):
"""Running all migrations on empty DB should create all indexes."""
run_migrations(fresh_engine)
for table_name, expected_indexes in [
("research_tasks", RESEARCH_TASKS_INDEXES),
("research_history", RESEARCH_HISTORY_INDEXES),
]:
indexes = _get_indexes_by_name(fresh_engine, table_name)
for idx_name, idx_columns in expected_indexes.items():
assert idx_name in indexes, (
f"Missing index {idx_name} on {table_name}"
)
assert indexes[idx_name] == idx_columns
def test_head_revision_is_current(self):
"""get_head_revision() returns a real 4-digit revision id."""
head = get_head_revision()
assert head is not None and head.isdigit() and len(head) == 4
def test_current_revision_is_head_after_migrate(self, fresh_engine):
"""After full migration, current revision should match head."""
run_migrations(fresh_engine)
assert get_current_revision(fresh_engine) == get_head_revision()
def test_needs_migration_false_after_full_upgrade(self, fresh_engine):
"""After full migration, needs_migration() should return False."""
run_migrations(fresh_engine)
assert needs_migration(fresh_engine) is False
class TestMigration0003Idempotency:
"""Tests that verify safe re-runs of migrations."""
def test_run_migrations_twice_no_error(self, fresh_engine):
"""Calling run_migrations() twice should not raise."""
run_migrations(fresh_engine)
run_migrations(fresh_engine) # Should not raise
def test_indexes_unchanged_after_double_migration(self, fresh_engine):
"""Indexes should be identical after running migrations twice."""
run_migrations(fresh_engine)
indexes_first = {
"research_tasks": _get_indexes_by_name(
fresh_engine, "research_tasks"
),
"research_history": _get_indexes_by_name(
fresh_engine, "research_history"
),
}
run_migrations(fresh_engine)
indexes_second = {
"research_tasks": _get_indexes_by_name(
fresh_engine, "research_tasks"
),
"research_history": _get_indexes_by_name(
fresh_engine, "research_history"
),
}
assert indexes_first == indexes_second
def test_pre_existing_indexes_not_duplicated(self, migrated_to_0002_engine):
"""Manually creating an index before migration should not cause duplicates."""
engine = migrated_to_0002_engine
# Manually create one of the indexes before migration
with engine.begin() as conn:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_research_tasks_status "
"ON research_tasks (status)"
)
)
# Now run migration to 0003
_run_upgrade_to(engine, "0003")
# Verify no duplicate — only one index with that name
indexes = _get_indexes_by_name(engine, "research_tasks")
assert "ix_research_tasks_status" in indexes
assert indexes["ix_research_tasks_status"] == ["status"]
class TestMigration0003Downgrade:
"""Tests for rollback behavior."""
def test_downgrade_to_0002_removes_all_research_indexes(
self, fully_migrated_engine
):
"""Downgrade from 0003 to 0002 should remove all 9 indexes."""
_run_downgrade_to(fully_migrated_engine, "0002")
# Fresh inspect after DDL
all_expected = {**RESEARCH_TASKS_INDEXES, **RESEARCH_HISTORY_INDEXES}
for table_name in ("research_tasks", "research_history"):
indexes = _get_indexes_by_name(fully_migrated_engine, table_name)
for idx_name in all_expected:
assert idx_name not in indexes, (
f"Index {idx_name} should have been removed by downgrade"
)
def test_downgrade_preserves_tables(self, fully_migrated_engine):
"""Tables should still exist after downgrade (only indexes removed)."""
_run_downgrade_to(fully_migrated_engine, "0002")
insp = inspect(fully_migrated_engine)
assert insp.has_table("research_tasks")
assert insp.has_table("research_history")
def test_downgrade_preserves_data(self, fully_migrated_engine):
"""Data inserted before downgrade should survive."""
engine = fully_migrated_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history (id, query, mode, status, created_at) "
"VALUES ('test-downgrade-1', 'test query', 'quick', 'completed', '2025-01-01')"
)
)
_run_downgrade_to(engine, "0002")
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT id, query FROM research_history WHERE id = 'test-downgrade-1'"
)
).fetchone()
assert result is not None
assert result[0] == "test-downgrade-1"
assert result[1] == "test query"
def test_downgrade_then_upgrade_roundtrip(self, fully_migrated_engine):
"""Downgrade to 0002 then upgrade back to 0003 should restore indexes."""
engine = fully_migrated_engine
_run_downgrade_to(engine, "0002")
assert get_current_revision(engine) == "0002"
_run_upgrade_to(engine, "0003")
assert get_current_revision(engine) == "0003"
# Verify indexes are back
for table_name, expected_indexes in [
("research_tasks", RESEARCH_TASKS_INDEXES),
("research_history", RESEARCH_HISTORY_INDEXES),
]:
indexes = _get_indexes_by_name(engine, table_name)
for idx_name in expected_indexes:
assert idx_name in indexes, (
f"Index {idx_name} not restored after roundtrip"
)
class TestMigration0003DataPreservation:
"""Tests that verify migration is non-destructive."""
def test_data_preserved_in_research_tasks(self, migrated_to_0002_engine):
"""Data in research_tasks should survive the migration."""
engine = migrated_to_0002_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_tasks (title, status, priority, created_at) "
"VALUES ('Test Task', 'pending', 5, '2025-01-01 00:00:00')"
)
)
_run_upgrade_to(engine, "0003")
with engine.connect() as conn:
result = conn.execute(
text("SELECT title, status, priority FROM research_tasks")
).fetchone()
assert result is not None
assert result[0] == "Test Task"
assert result[1] == "pending"
assert result[2] == 5
def test_data_preserved_in_research_history(self, migrated_to_0002_engine):
"""Data in research_history should survive the migration."""
engine = migrated_to_0002_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history (id, query, mode, status, created_at) "
"VALUES ('preserve-test', 'test query', 'detailed', 'completed', '2025-01-01')"
)
)
_run_upgrade_to(engine, "0003")
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT id, query, mode, status FROM research_history "
"WHERE id = 'preserve-test'"
)
).fetchone()
assert result is not None
assert result[0] == "preserve-test"
assert result[1] == "test query"
assert result[2] == "detailed"
assert result[3] == "completed"
def test_data_queryable_after_index_creation(self, migrated_to_0002_engine):
"""Queries using indexed columns should work after migration."""
engine = migrated_to_0002_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_tasks (title, status, priority, created_at) "
"VALUES ('Task A', 'completed', 10, '2025-01-01 00:00:00')"
)
)
conn.execute(
text(
"INSERT INTO research_tasks (title, status, priority, created_at) "
"VALUES ('Task B', 'pending', 5, '2025-01-02 00:00:00')"
)
)
_run_upgrade_to(engine, "0003")
with engine.connect() as conn:
# Query using indexed column
result = conn.execute(
text(
"SELECT title FROM research_tasks WHERE status = 'completed'"
)
).fetchall()
assert len(result) == 1
assert result[0][0] == "Task A"
# Query using composite index columns
result = conn.execute(
text(
"SELECT title FROM research_tasks "
"WHERE priority = 10 AND status = 'completed'"
)
).fetchall()
assert len(result) == 1
assert result[0][0] == "Task A"
class TestMigration0003EdgeCases:
"""Tests for edge cases and robustness."""
def test_partial_tables_only_research_tasks(self, tmp_path):
"""Migration should work when only research_tasks exists."""
db_path = tmp_path / "partial_tasks_only.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create just research_tasks manually
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE research_tasks ("
"id INTEGER PRIMARY KEY, "
"title VARCHAR(500) NOT NULL, "
"status VARCHAR(50), "
"priority INTEGER DEFAULT 0, "
"created_at DATETIME"
")"
)
)
# Create alembic_version and stamp at 0002
conn.execute(
text(
"CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(
text("INSERT INTO alembic_version VALUES ('0002')")
)
_run_upgrade_to(engine, "0003")
indexes = _get_indexes_by_name(engine, "research_tasks")
for idx_name in RESEARCH_TASKS_INDEXES:
assert idx_name in indexes, f"Missing {idx_name}"
# research_history indexes should not exist (table doesn't exist)
insp = inspect(engine)
assert not insp.has_table("research_history")
finally:
engine.dispose()
def test_partial_tables_only_research_history(self, tmp_path):
"""Migration should work when only research_history exists."""
db_path = tmp_path / "partial_history_only.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE research_history ("
"id VARCHAR(36) PRIMARY KEY, "
"query TEXT NOT NULL, "
"mode TEXT NOT NULL, "
"status TEXT NOT NULL, "
"created_at TEXT NOT NULL"
")"
)
)
conn.execute(
text(
"CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(
text("INSERT INTO alembic_version VALUES ('0002')")
)
_run_upgrade_to(engine, "0003")
indexes = _get_indexes_by_name(engine, "research_history")
for idx_name in RESEARCH_HISTORY_INDEXES:
assert idx_name in indexes, f"Missing {idx_name}"
insp = inspect(engine)
assert not insp.has_table("research_tasks")
finally:
engine.dispose()
def test_neither_table_exists(self, tmp_path):
"""Migration should be a no-op when neither table exists."""
db_path = tmp_path / "neither_table.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(
text("INSERT INTO alembic_version VALUES ('0002')")
)
# Should not raise
_run_upgrade_to(engine, "0003")
assert get_current_revision(engine) == "0003"
finally:
engine.dispose()
def test_empty_tables_get_indexes(self, migrated_to_0002_engine):
"""Tables with zero rows should still get indexes."""
engine = migrated_to_0002_engine
# Verify tables are empty
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM research_tasks")
).scalar()
assert count == 0
_run_upgrade_to(engine, "0003")
indexes = _get_indexes_by_name(engine, "research_tasks")
for idx_name in RESEARCH_TASKS_INDEXES:
assert idx_name in indexes
def test_in_memory_database(self):
"""Migration should work on in-memory SQLite database."""
engine = create_engine("sqlite:///:memory:")
try:
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
for table_name, expected_indexes in [
("research_tasks", RESEARCH_TASKS_INDEXES),
("research_history", RESEARCH_HISTORY_INDEXES),
]:
indexes = _get_indexes_by_name(engine, table_name)
for idx_name in expected_indexes:
assert idx_name in indexes, (
f"Missing {idx_name} on {table_name} in memory DB"
)
finally:
engine.dispose()
@@ -0,0 +1,410 @@
"""
Tests for migration 0004: Migrate legacy app.* settings.
Tests cover:
- Deprecated settings deletion (app.enable_fact_checking, app.output_dir)
- Re-scoping app.* keys to general.*/search.*/llm.*
- Corrected key mappings (search_engine, openai_endpoint_url, lmstudio_url)
- No overwrite when new key already exists
- Idempotency (no error when old keys don't exist)
- Downgrade is a no-op
"""
import pytest
from alembic import command
from sqlalchemy import create_engine, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
get_current_revision,
get_head_revision,
run_migrations,
)
def _run_upgrade_to(engine, revision):
"""Run migrations up to a specific revision."""
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
"""Run downgrade to a specific revision."""
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
def _insert_setting(conn, key, value, setting_type="app", name=None):
"""Insert a test setting row.
The value column is JSON type. Pass the raw value to store;
it will be stored as-is (no extra JSON encoding by raw SQL).
"""
conn.execute(
text(
"INSERT INTO settings (key, value, type, name, ui_element, visible, editable) "
"VALUES (:key, :value, :type, :name, 'text', 1, 1)"
),
{
"key": key,
"value": value,
"type": setting_type,
"name": name or key,
},
)
def _get_setting(conn, key):
"""Get a setting row by key, or None."""
return conn.execute(
text("SELECT key, value, type, name FROM settings WHERE key = :key"),
{"key": key},
).fetchone()
def _count_settings(conn, key_prefix):
"""Count settings with a key prefix."""
return conn.execute(
text("SELECT COUNT(*) FROM settings WHERE key LIKE :prefix"),
{"prefix": f"{key_prefix}%"},
).scalar()
@pytest.fixture
def migrated_to_0003_engine(tmp_path):
"""Create a database migrated to revision 0003 (before app settings migration)."""
db_path = tmp_path / "test_0004.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0003")
yield engine
engine.dispose()
@pytest.fixture
def fresh_engine(tmp_path):
"""Create a fresh SQLite engine (empty database)."""
db_path = tmp_path / "fresh_0004_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
class TestMigration0004DeprecatedSettings:
"""Tests for deletion of deprecated settings."""
def test_deletes_enable_fact_checking(self, migrated_to_0003_engine):
"""app.enable_fact_checking should be deleted."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.enable_fact_checking", "true")
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
assert _get_setting(conn, "app.enable_fact_checking") is None
def test_deletes_output_dir(self, migrated_to_0003_engine):
"""app.output_dir should be deleted."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.output_dir", '"/tmp/output"')
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
assert _get_setting(conn, "app.output_dir") is None
def test_no_error_when_deprecated_keys_absent(
self, migrated_to_0003_engine
):
"""Migration should succeed even if deprecated keys don't exist."""
engine = migrated_to_0003_engine
# Don't insert any deprecated keys
_run_upgrade_to(engine, "0004")
assert get_current_revision(engine) == "0004"
class TestMigration0004GeneralSettings:
"""Tests for re-scoping app.* to general.*."""
def test_migrates_knowledge_accumulation(self, migrated_to_0003_engine):
"""app.knowledge_accumulation should move to general.knowledge_accumulation."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.knowledge_accumulation", "true")
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
new = _get_setting(conn, "general.knowledge_accumulation")
assert new is not None
assert new[2] == "app" # type set to app
assert _get_setting(conn, "app.knowledge_accumulation") is None
def test_migrates_knowledge_accumulation_context_limit(
self, migrated_to_0003_engine
):
"""app.knowledge_accumulation_context_limit should move to general.*."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(
conn, "app.knowledge_accumulation_context_limit", "5000"
)
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
new = _get_setting(
conn, "general.knowledge_accumulation_context_limit"
)
assert new is not None
assert (
_get_setting(conn, "app.knowledge_accumulation_context_limit")
is None
)
class TestMigration0004SearchSettings:
"""Tests for re-scoping app.* to search.*."""
def test_migrates_questions_per_iteration(self, migrated_to_0003_engine):
"""app.questions_per_iteration should move to search.questions_per_iteration."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.questions_per_iteration", "3")
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
new = _get_setting(conn, "search.questions_per_iteration")
assert new is not None
assert new[2] == "search"
assert _get_setting(conn, "app.questions_per_iteration") is None
def test_corrects_search_engine_key(self, migrated_to_0003_engine):
"""app.search_engine should map to search.engine.DEFAULT_SEARCH_ENGINE (not search.search_engine)."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.search_engine", '"duckduckgo"')
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
new = _get_setting(conn, "search.engine.DEFAULT_SEARCH_ENGINE")
assert new is not None
# Verify old naive key was NOT created
assert _get_setting(conn, "search.search_engine") is None
assert _get_setting(conn, "app.search_engine") is None
def test_migrates_all_search_keys(self, migrated_to_0003_engine):
"""All search keys should be migrated correctly."""
engine = migrated_to_0003_engine
search_keys = [
("app.iterations", "search.iterations"),
("app.max_results", "search.max_results"),
("app.region", "search.region"),
("app.safe_search", "search.safe_search"),
("app.search_language", "search.search_language"),
("app.snippets_only", "search.snippets_only"),
]
with engine.begin() as conn:
for old_key, _ in search_keys:
_insert_setting(conn, old_key, f'"{old_key}_val"')
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
for old_key, new_key in search_keys:
new = _get_setting(conn, new_key)
assert new is not None, f"Expected {new_key} to exist"
assert new[2] == "search"
assert _get_setting(conn, old_key) is None
class TestMigration0004LlmSettings:
"""Tests for re-scoping app.* to llm.*."""
def test_migrates_basic_llm_keys(self, migrated_to_0003_engine):
"""app.model, app.provider, etc. should move to llm.*."""
engine = migrated_to_0003_engine
basic_llm_keys = [
("app.model", "llm.model"),
("app.provider", "llm.provider"),
("app.temperature", "llm.temperature"),
("app.max_tokens", "llm.max_tokens"),
("app.llamacpp_model_path", "llm.llamacpp_model_path"),
]
with engine.begin() as conn:
for old_key, _ in basic_llm_keys:
_insert_setting(conn, old_key, f'"{old_key}_val"')
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
for old_key, new_key in basic_llm_keys:
new = _get_setting(conn, new_key)
assert new is not None, f"Expected {new_key} to exist"
assert new[2] == "llm"
assert _get_setting(conn, old_key) is None
def test_corrects_openai_endpoint_url_key(self, migrated_to_0003_engine):
"""app.openai_endpoint_url should map to llm.openai_endpoint.url (not llm.openai_endpoint_url)."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(
conn, "app.openai_endpoint_url", '"http://localhost:1234"'
)
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
new = _get_setting(conn, "llm.openai_endpoint.url")
assert new is not None
assert _get_setting(conn, "llm.openai_endpoint_url") is None
assert _get_setting(conn, "app.openai_endpoint_url") is None
def test_corrects_lmstudio_url_key(self, migrated_to_0003_engine):
"""app.lmstudio_url should map to llm.lmstudio.url (not llm.lmstudio_url)."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.lmstudio_url", '"http://localhost:1234"')
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
new = _get_setting(conn, "llm.lmstudio.url")
assert new is not None
assert _get_setting(conn, "llm.lmstudio_url") is None
assert _get_setting(conn, "app.lmstudio_url") is None
class TestMigration0004NoOverwrite:
"""Tests that existing new-key settings are preserved."""
def test_preserves_existing_new_key(self, migrated_to_0003_engine):
"""If the new key already exists, migration should preserve it and still delete old key."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.model", "old_model")
_insert_setting(conn, "llm.model", "user_current_model", "llm")
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
new = _get_setting(conn, "llm.model")
assert new is not None
assert new[1] == "user_current_model" # preserved
assert _get_setting(conn, "app.model") is None # still deleted
def test_preserves_existing_search_engine_key(
self, migrated_to_0003_engine
):
"""If search.engine.DEFAULT_SEARCH_ENGINE exists, don't overwrite."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.search_engine", "old_engine")
_insert_setting(
conn,
"search.engine.DEFAULT_SEARCH_ENGINE",
"current_engine",
"search",
)
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
new = _get_setting(conn, "search.engine.DEFAULT_SEARCH_ENGINE")
assert new[1] == "current_engine" # preserved
assert _get_setting(conn, "app.search_engine") is None
class TestMigration0004Idempotency:
"""Tests that migration handles missing keys gracefully."""
def test_no_error_when_no_app_keys_exist(self, migrated_to_0003_engine):
"""Migration should be a no-op when no app.* keys exist."""
engine = migrated_to_0003_engine
_run_upgrade_to(engine, "0004")
assert get_current_revision(engine) == "0004"
def test_no_error_with_partial_app_keys(self, migrated_to_0003_engine):
"""Migration should handle only some app.* keys existing."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.model", '"gpt-4"')
# Don't insert any other app.* keys
_run_upgrade_to(engine, "0004")
with engine.connect() as conn:
assert _get_setting(conn, "llm.model") is not None
assert _count_settings(conn, "app.") == 0
def test_full_migration_from_fresh_db(self, fresh_engine):
"""Full migration chain on empty DB should work (no settings to migrate)."""
run_migrations(fresh_engine)
assert get_current_revision(fresh_engine) == get_head_revision()
class TestMigration0004Downgrade:
"""Tests for downgrade behavior."""
def test_downgrade_does_not_crash(self, migrated_to_0003_engine):
"""Downgrade from 0004 to 0003 should not raise."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.model", '"gpt-4"')
_run_upgrade_to(engine, "0004")
_run_downgrade_to(engine, "0003")
assert get_current_revision(engine) == "0003"
def test_downgrade_does_not_recreate_old_keys(
self, migrated_to_0003_engine
):
"""Downgrade should not recreate the old app.* keys (they're stale)."""
engine = migrated_to_0003_engine
with engine.begin() as conn:
_insert_setting(conn, "app.model", '"gpt-4"')
_insert_setting(conn, "app.provider", '"openai"')
_run_upgrade_to(engine, "0004")
_run_downgrade_to(engine, "0003")
with engine.connect() as conn:
# Old keys should NOT be restored
assert _get_setting(conn, "app.model") is None
assert _get_setting(conn, "app.provider") is None
# New keys should still exist (downgrade is no-op)
assert _get_setting(conn, "llm.model") is not None
assert _get_setting(conn, "llm.provider") is not None
def test_downgrade_then_upgrade_roundtrip(self, migrated_to_0003_engine):
"""Downgrade then re-upgrade should work without errors."""
engine = migrated_to_0003_engine
_run_upgrade_to(engine, "0004")
_run_downgrade_to(engine, "0003")
_run_upgrade_to(engine, "0004")
assert get_current_revision(engine) == "0004"
@@ -0,0 +1,602 @@
"""
Tests for migration 0005: Add document_id column to research_resources.
Tests cover:
- Column creation with correct type and nullability
- Index creation (ix_research_resources_document_id)
- Full migration chain from empty database
- Idempotency (running migrations multiple times)
- Downgrade behavior (column and index removal)
- Data preservation during upgrade and downgrade
- Edge cases (missing table, pre-existing column, in-memory database)
"""
from alembic import command
import pytest
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
get_current_revision,
get_head_revision,
needs_migration,
run_migrations,
)
def _run_upgrade_to(engine, revision):
"""Run migrations up to a specific revision."""
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
"""Run downgrade to a specific revision."""
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
def _get_columns(engine, table_name):
"""Get a dict of {column_name: column_info} for a table."""
insp = inspect(engine)
if not insp.has_table(table_name):
return {}
return {col["name"]: col for col in insp.get_columns(table_name)}
def _get_indexes_by_name(engine, table_name):
"""Get a dict of {index_name: [column_names]} for a table."""
insp = inspect(engine)
if not insp.has_table(table_name):
return {}
return {
idx["name"]: idx["column_names"]
for idx in insp.get_indexes(table_name)
if idx["name"] is not None
}
@pytest.fixture
def fresh_engine(tmp_path):
"""Create a fresh SQLite engine (empty database, no tables)."""
db_path = tmp_path / "fresh_0005_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
@pytest.fixture
def migrated_to_0004_engine(tmp_path):
"""Create a database migrated to revision 0004 (before document_id)."""
db_path = tmp_path / "migrated_0004_test.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0004")
yield engine
engine.dispose()
@pytest.fixture
def fully_migrated_engine(tmp_path):
"""Create a database migrated up to revision 0005 (this file's target).
Stops at 0005 instead of head — the downgrade tests below would
otherwise have to roll back through migration 0010, which is
documented as non-reversible (raises NotImplementedError). Every
test in this file is scoped to 0005 behaviour.
"""
db_path = tmp_path / "fully_migrated_0005_test.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0005")
yield engine
engine.dispose()
class TestMigration0005UpgradeColumn:
"""Tests that verify document_id column creation on upgrade."""
def test_document_id_column_exists(self, fully_migrated_engine):
"""document_id column should exist on research_resources after migration."""
columns = _get_columns(fully_migrated_engine, "research_resources")
assert "document_id" in columns
def test_document_id_column_is_nullable(self, fully_migrated_engine):
"""document_id should be nullable (existing rows have no value)."""
columns = _get_columns(fully_migrated_engine, "research_resources")
assert columns["document_id"]["nullable"] is True
def test_document_id_column_type_is_varchar(self, fully_migrated_engine):
"""document_id should be VARCHAR(36) to hold UUIDs."""
columns = _get_columns(fully_migrated_engine, "research_resources")
col_type = str(columns["document_id"]["type"])
assert "VARCHAR" in col_type or "CHAR" in col_type
def test_document_id_index_exists(self, fully_migrated_engine):
"""Index ix_research_resources_document_id should exist."""
indexes = _get_indexes_by_name(
fully_migrated_engine, "research_resources"
)
assert "ix_research_resources_document_id" in indexes
assert indexes["ix_research_resources_document_id"] == ["document_id"]
def test_document_id_defaults_to_null(self, fully_migrated_engine):
"""Inserting a row without document_id should default to NULL."""
engine = fully_migrated_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at) "
"VALUES ('test-rh-1', 'Test Resource', "
"'https://example.com', '2026-01-01')"
)
)
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT document_id FROM research_resources "
"WHERE research_id = 'test-rh-1'"
)
).fetchone()
assert result[0] is None
def test_document_id_can_store_uuid(self, fully_migrated_engine):
"""document_id should accept a UUID string value."""
engine = fully_migrated_engine
test_uuid = "abcdef01-2345-6789-abcd-ef0123456789"
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at, document_id) "
"VALUES ('test-rh-2', 'Linked Resource', "
"'https://example.com', '2026-01-01', :doc_id)"
),
{"doc_id": test_uuid},
)
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT document_id FROM research_resources "
"WHERE research_id = 'test-rh-2'"
)
).fetchone()
assert result[0] == test_uuid
class TestMigration0005UpgradeFromPrior:
"""Tests that verify the upgrade path for databases missing document_id.
Migration 0001 uses Base.metadata.create_all() which includes document_id
from the current model. To test the real-world scenario (database created
before document_id was added to the model), we manually create the table
WITHOUT document_id and stamp at 0004.
"""
@pytest.fixture
def legacy_engine(self, tmp_path):
"""Create a database with research_resources missing document_id.
This simulates a database created before commit 2033f977e added
document_id to the ResearchResource model.
"""
db_path = tmp_path / "legacy_no_docid.db"
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
# Create research_history (needed for FK)
conn.execute(
text(
"CREATE TABLE research_history ("
"id VARCHAR(36) PRIMARY KEY, "
"query TEXT NOT NULL, "
"mode TEXT NOT NULL, "
"status TEXT NOT NULL, "
"created_at TEXT NOT NULL"
")"
)
)
# Create research_resources WITHOUT document_id
conn.execute(
text(
"CREATE TABLE research_resources ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"research_id VARCHAR(36) NOT NULL "
" REFERENCES research_history(id) ON DELETE CASCADE, "
"title TEXT, "
"url TEXT, "
"content_preview TEXT, "
"source_type TEXT, "
"metadata JSON, "
"created_at VARCHAR NOT NULL"
")"
)
)
# Stamp at 0004 so migration 0005 runs
conn.execute(
text(
"CREATE TABLE alembic_version "
"(version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(text("INSERT INTO alembic_version VALUES ('0004')"))
yield engine
engine.dispose()
def test_upgrade_adds_column_to_legacy_table(self, legacy_engine):
"""Upgrading a database missing document_id should add the column."""
columns = _get_columns(legacy_engine, "research_resources")
assert "document_id" not in columns
_run_upgrade_to(legacy_engine, "0005")
columns = _get_columns(legacy_engine, "research_resources")
assert "document_id" in columns
def test_upgrade_adds_index_to_legacy_table(self, legacy_engine):
"""Upgrading should create the document_id index."""
_run_upgrade_to(legacy_engine, "0005")
indexes = _get_indexes_by_name(legacy_engine, "research_resources")
assert "ix_research_resources_document_id" in indexes
def test_revision_is_0005_after_upgrade(self, legacy_engine):
"""Current revision should be 0005 after targeted upgrade."""
_run_upgrade_to(legacy_engine, "0005")
assert get_current_revision(legacy_engine) == "0005"
def test_existing_data_preserved_after_upgrade(self, legacy_engine):
"""Rows inserted before upgrade should survive with NULL document_id."""
with legacy_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES ('rh-legacy', 'old query', 'quick', "
"'completed', '2025-06-01')"
)
)
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at) "
"VALUES ('rh-legacy', 'Old Resource', "
"'https://old.com', '2025-06-01')"
)
)
_run_upgrade_to(legacy_engine, "0005")
with legacy_engine.connect() as conn:
result = conn.execute(
text(
"SELECT title, url, document_id "
"FROM research_resources "
"WHERE research_id = 'rh-legacy'"
)
).fetchone()
assert result[0] == "Old Resource"
assert result[1] == "https://old.com"
assert result[2] is None
def test_idempotent_on_fresh_db(self, migrated_to_0004_engine):
"""On a fresh DB (0001 already created document_id), 0005 is a no-op."""
engine = migrated_to_0004_engine
# 0001's create_all already added document_id from current model
columns_before = _get_columns(engine, "research_resources")
assert "document_id" in columns_before
_run_upgrade_to(engine, "0005")
columns_after = _get_columns(engine, "research_resources")
assert "document_id" in columns_after
assert get_current_revision(engine) == "0005"
class TestMigration0005FromFreshDatabase:
"""Tests that verify the full migration chain on a fresh database."""
def test_fresh_db_full_migration_creates_column(self, fresh_engine):
"""Running all migrations on empty DB should create document_id."""
run_migrations(fresh_engine)
columns = _get_columns(fresh_engine, "research_resources")
assert "document_id" in columns
def test_head_revision_is_real_id(self):
"""get_head_revision() returns a real 4-digit revision id."""
head = get_head_revision()
assert head is not None and head.isdigit() and len(head) == 4
def test_current_revision_is_head_after_migrate(self, fresh_engine):
"""After full migration, current revision should match head."""
run_migrations(fresh_engine)
assert get_current_revision(fresh_engine) == get_head_revision()
def test_needs_migration_false_after_full_upgrade(self, fresh_engine):
"""After full migration, needs_migration() should return False."""
run_migrations(fresh_engine)
assert needs_migration(fresh_engine) is False
class TestMigration0005Idempotency:
"""Tests that verify safe re-runs of migration."""
def test_run_migrations_twice_no_error(self, fresh_engine):
"""Calling run_migrations() twice should not raise."""
run_migrations(fresh_engine)
run_migrations(fresh_engine)
def test_column_unchanged_after_double_migration(self, fresh_engine):
"""Column should be identical after running migrations twice."""
run_migrations(fresh_engine)
columns_first = _get_columns(fresh_engine, "research_resources")
run_migrations(fresh_engine)
columns_second = _get_columns(fresh_engine, "research_resources")
assert ("document_id" in columns_first) == (
"document_id" in columns_second
)
def test_pre_existing_column_not_duplicated(self, tmp_path):
"""If document_id already exists (fresh DB), migration is a no-op."""
db_path = tmp_path / "pre_existing_col.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Full migration creates the column via 0001's create_all
run_migrations(engine)
columns_before = _get_columns(engine, "research_resources")
assert "document_id" in columns_before
# Downgrade to 0004 and re-upgrade to test the migration path
# when the column was already added by create_all
# (This tests the idempotency guard)
col_count_before = len(columns_before)
# Running migrations again should be a no-op
run_migrations(engine)
columns_after = _get_columns(engine, "research_resources")
assert len(columns_after) == col_count_before
finally:
engine.dispose()
class TestMigration0005Downgrade:
"""Tests for rollback behavior."""
def test_downgrade_to_0004_removes_column(self, fully_migrated_engine):
"""Downgrade from 0005 to 0004 should remove document_id."""
_run_downgrade_to(fully_migrated_engine, "0004")
columns = _get_columns(fully_migrated_engine, "research_resources")
assert "document_id" not in columns
def test_downgrade_to_0004_removes_index(self, fully_migrated_engine):
"""Downgrade should remove ix_research_resources_document_id."""
_run_downgrade_to(fully_migrated_engine, "0004")
indexes = _get_indexes_by_name(
fully_migrated_engine, "research_resources"
)
assert "ix_research_resources_document_id" not in indexes
def test_downgrade_preserves_table(self, fully_migrated_engine):
"""research_resources table should still exist after downgrade."""
_run_downgrade_to(fully_migrated_engine, "0004")
insp = inspect(fully_migrated_engine)
assert insp.has_table("research_resources")
def test_downgrade_preserves_other_columns(self, fully_migrated_engine):
"""Other columns (title, url, etc.) should survive downgrade."""
_run_downgrade_to(fully_migrated_engine, "0004")
columns = _get_columns(fully_migrated_engine, "research_resources")
for col in ("id", "research_id", "title", "url", "created_at"):
assert col in columns, f"Column {col} lost during downgrade"
def test_downgrade_then_upgrade_roundtrip(self, fully_migrated_engine):
"""Downgrade to 0004 then upgrade back to 0005 should restore column."""
engine = fully_migrated_engine
_run_downgrade_to(engine, "0004")
assert get_current_revision(engine) == "0004"
_run_upgrade_to(engine, "0005")
assert get_current_revision(engine) == "0005"
columns = _get_columns(engine, "research_resources")
assert "document_id" in columns
indexes = _get_indexes_by_name(engine, "research_resources")
assert "ix_research_resources_document_id" in indexes
class TestMigration0005DataPreservation:
"""Tests that verify migration is non-destructive."""
def test_existing_rows_preserved_after_upgrade(
self, migrated_to_0004_engine
):
"""Data in research_resources should survive the migration."""
engine = migrated_to_0004_engine
# Insert a research_history row first (FK constraint)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES ('rh-preserve', 'test query', 'quick', "
"'completed', '2026-01-01')"
)
)
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at) "
"VALUES ('rh-preserve', 'Preserved Resource', "
"'https://example.com', '2026-01-01')"
)
)
_run_upgrade_to(engine, "0005")
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT title, url, document_id "
"FROM research_resources "
"WHERE research_id = 'rh-preserve'"
)
).fetchone()
assert result is not None
assert result[0] == "Preserved Resource"
assert result[1] == "https://example.com"
assert result[2] is None # New column defaults to NULL
def test_data_preserved_after_downgrade(self, fully_migrated_engine):
"""Non-document_id data should survive downgrade."""
engine = fully_migrated_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES ('rh-down', 'downgrade query', 'detailed', "
"'completed', '2026-01-01')"
)
)
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at, document_id) "
"VALUES ('rh-down', 'Will Lose DocID', "
"'https://example.com', '2026-01-01', 'some-uuid')"
)
)
_run_downgrade_to(engine, "0004")
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT title, url FROM research_resources "
"WHERE research_id = 'rh-down'"
)
).fetchone()
assert result is not None
assert result[0] == "Will Lose DocID"
assert result[1] == "https://example.com"
class TestMigration0005EdgeCases:
"""Tests for edge cases and robustness."""
def test_missing_research_resources_table(self, tmp_path):
"""Migration should be a no-op when research_resources doesn't exist."""
db_path = tmp_path / "no_resources_table.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE alembic_version "
"(version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(
text("INSERT INTO alembic_version VALUES ('0004')")
)
_run_upgrade_to(engine, "0005")
assert get_current_revision(engine) == "0005"
insp = inspect(engine)
assert not insp.has_table("research_resources")
finally:
engine.dispose()
def test_in_memory_database(self):
"""Migration should work on in-memory SQLite database."""
engine = create_engine("sqlite:///:memory:")
try:
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
columns = _get_columns(engine, "research_resources")
assert "document_id" in columns
indexes = _get_indexes_by_name(engine, "research_resources")
assert "ix_research_resources_document_id" in indexes
finally:
engine.dispose()
def test_document_id_queryable_after_upgrade(self, migrated_to_0004_engine):
"""Queries filtering on document_id should work after upgrade."""
engine = migrated_to_0004_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES ('rh-query', 'query test', 'quick', "
"'completed', '2026-01-01')"
)
)
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at) "
"VALUES ('rh-query', 'Resource A', "
"'https://a.com', '2026-01-01')"
)
)
_run_upgrade_to(engine, "0005")
# Set document_id on the row
with engine.begin() as conn:
conn.execute(
text(
"UPDATE research_resources SET document_id = 'doc-uuid-1' "
"WHERE research_id = 'rh-query'"
)
)
# Query using the new column (mimics library_service.py join condition)
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT title FROM research_resources "
"WHERE document_id = 'doc-uuid-1'"
)
).fetchall()
assert len(result) == 1
assert result[0][0] == "Resource A"
# NULL document_id query
result = conn.execute(
text(
"SELECT COUNT(*) FROM research_resources "
"WHERE document_id IS NULL"
)
).scalar()
assert result == 0 # We updated the only row
+633
View File
@@ -0,0 +1,633 @@
"""Tests for migration 0006: Journal Quality System.
This migration consolidates what were originally five separate revisions
(0006-0010) into a single atomic change. It creates the ``papers`` and
``paper_appearances`` tables, adds the ``name_lower`` / ``score_source`` /
``quality_model`` columns + indexes to ``journals``, and adds the
``ix_research_resources_research_id`` FK index.
Tests cover:
- New table creation (``papers``, ``paper_appearances``) with the
correct columns, uniques, and FK cascade actions.
- New column creation on ``journals`` with ``name_lower`` backfill
(Python ``str.lower`` on existing rows).
- Named index creation and idempotency.
- Upgrade → downgrade → upgrade roundtrip (schema restored, no
leftover objects).
- Timestamp columns use ``UtcDateTime`` with ``utcnow()`` server
defaults (enforced by the pre-commit hook but worth a runtime
check in case the hook is bypassed).
"""
import pytest
from alembic import command
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
get_head_revision,
run_migrations,
)
# --------------------------------------------------------------------------- #
# Helpers #
# --------------------------------------------------------------------------- #
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
def _table_exists(engine, name):
return inspect(engine).has_table(name)
def _get_columns(engine, table_name):
insp = inspect(engine)
if not insp.has_table(table_name):
return {}
return {col["name"]: col for col in insp.get_columns(table_name)}
def _get_indexes_by_name(engine, table_name):
insp = inspect(engine)
if not insp.has_table(table_name):
return {}
return {
idx["name"]: idx["column_names"]
for idx in insp.get_indexes(table_name)
if idx["name"] is not None
}
def _get_unique_column_sets(engine, table_name):
insp = inspect(engine)
if not insp.has_table(table_name):
return []
return [
set(u["column_names"]) for u in insp.get_unique_constraints(table_name)
]
def _get_fk_ondelete(engine, table_name):
"""Return {column_name: ondelete_action} for all FKs in the table."""
insp = inspect(engine)
if not insp.has_table(table_name):
return {}
out = {}
for fk in insp.get_foreign_keys(table_name):
cols = fk.get("constrained_columns") or []
if not cols:
continue
ondelete = (fk.get("options") or {}).get("ondelete")
out[cols[0]] = ondelete
return out
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture
def fresh_engine(tmp_path):
"""Brand-new SQLite database — no migrations applied yet."""
db_path = tmp_path / "fresh_0006_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
@pytest.fixture
def migrated_to_0005_engine(tmp_path):
"""Database stamped at 0005 (just before the journal-quality schema)."""
db_path = tmp_path / "migrated_0005_test.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0005")
yield engine
engine.dispose()
@pytest.fixture
def fully_migrated_engine(tmp_path):
"""Database stamped at head (includes 0006)."""
db_path = tmp_path / "fully_migrated_0006_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
# --------------------------------------------------------------------------- #
# Tests — new tables #
# --------------------------------------------------------------------------- #
class TestMigration0006PapersTable:
"""Creation of the ``papers`` table with the right shape."""
def test_papers_table_exists(self, fully_migrated_engine):
assert _table_exists(fully_migrated_engine, "papers")
def test_papers_has_expected_columns(self, fully_migrated_engine):
cols = _get_columns(fully_migrated_engine, "papers")
expected = {
"id",
"doi",
"arxiv_id",
"pmid",
"journal_id",
"container_title",
"year",
"metadata",
"created_at",
"updated_at",
}
assert expected.issubset(set(cols))
def test_papers_has_no_journal_quality_column(self, fully_migrated_engine):
"""Quality is resolved live (journals.quality + bundled ref DB)
so there is no frozen per-Paper ``journal_quality`` column —
re-adding it would re-introduce the staleness footgun the
migration design explicitly avoids.
"""
cols = _get_columns(fully_migrated_engine, "papers")
assert "journal_quality" not in cols
def test_papers_year_is_indexed(self, fully_migrated_engine):
"""Year is promoted out of the metadata JSON blob to a
first-class integer column so the dashboard can filter/group
by year without paying for json_extract on every row.
"""
indexes = _get_indexes_by_name(fully_migrated_engine, "papers")
assert "idx_papers_year" in indexes
assert indexes["idx_papers_year"] == ["year"]
def test_papers_year_nullable(self, fully_migrated_engine):
"""Year is nullable — many sources lack a publication year."""
cols = _get_columns(fully_migrated_engine, "papers")
assert cols["year"]["nullable"] is True
def test_container_title_nullable(self, fully_migrated_engine):
"""container_title is nullable — populated by the write path
only when the filter scored the result, never required.
"""
cols = _get_columns(fully_migrated_engine, "papers")
assert cols["container_title"]["nullable"] is True
def test_identifier_columns_are_unique(self, fully_migrated_engine):
"""doi / arxiv_id / pmid each carry a single-column UNIQUE
constraint — that's the mechanism dedup relies on.
"""
unique_sets = _get_unique_column_sets(fully_migrated_engine, "papers")
assert {"doi"} in unique_sets
assert {"arxiv_id"} in unique_sets
assert {"pmid"} in unique_sets
def test_identifier_columns_are_nullable(self, fully_migrated_engine):
"""Papers without identifiers are still inserted — the UNIQUE
constraint must tolerate multiple NULLs (standard SQLite behavior).
"""
cols = _get_columns(fully_migrated_engine, "papers")
assert cols["doi"]["nullable"] is True
assert cols["arxiv_id"]["nullable"] is True
assert cols["pmid"]["nullable"] is True
def test_journal_id_has_fk_with_set_null(self, fully_migrated_engine):
"""journal_id → journals.id with ON DELETE SET NULL.
Deleting a journal leaves orphan Paper rows rather than cascading
the delete — paper provenance is worth preserving even if the
upstream journal is removed.
"""
fks = _get_fk_ondelete(fully_migrated_engine, "papers")
assert fks.get("journal_id", "").upper() == "SET NULL"
def test_idx_papers_journal_exists(self, fully_migrated_engine):
indexes = _get_indexes_by_name(fully_migrated_engine, "papers")
assert "idx_papers_journal" in indexes
assert indexes["idx_papers_journal"] == ["journal_id"]
def test_idx_papers_container_title_exists(self, fully_migrated_engine):
"""Dashboard GROUP BY container_title needs an index to be cheap."""
indexes = _get_indexes_by_name(fully_migrated_engine, "papers")
assert "idx_papers_container_title" in indexes
assert indexes["idx_papers_container_title"] == ["container_title"]
class TestMigration0006PaperAppearancesTable:
"""Creation of the ``paper_appearances`` join table."""
def test_paper_appearances_table_exists(self, fully_migrated_engine):
assert _table_exists(fully_migrated_engine, "paper_appearances")
def test_paper_appearances_has_expected_columns(
self, fully_migrated_engine
):
cols = _get_columns(fully_migrated_engine, "paper_appearances")
expected = {
"id",
"paper_id",
"resource_id",
"source_engine",
"created_at",
}
assert expected.issubset(set(cols))
def test_resource_id_is_unique(self, fully_migrated_engine):
"""One resource appears in exactly one paper_appearance row —
enforced at the schema level so dedup bugs can't double-count.
"""
unique_sets = _get_unique_column_sets(
fully_migrated_engine, "paper_appearances"
)
assert {"resource_id"} in unique_sets
def test_fks_use_cascade_ondelete(self, fully_migrated_engine):
"""Both FKs on paper_appearances cascade: deleting a paper or the
research resource must also drop the join row so we don't accrue
dangling rows pointing at missing parents.
"""
fks = _get_fk_ondelete(fully_migrated_engine, "paper_appearances")
assert fks.get("paper_id", "").upper() == "CASCADE"
assert fks.get("resource_id", "").upper() == "CASCADE"
def test_paper_id_is_indexed(self, fully_migrated_engine):
"""An index on paper_id must exist for the papers→appearances
join hot path. The exact index name depends on who wins the
race between alembic's named index and any ORM bootstrap that
also declares a backing index, so we check by column coverage
rather than by name.
"""
indexes = _get_indexes_by_name(
fully_migrated_engine, "paper_appearances"
)
assert any(cols == ["paper_id"] for cols in indexes.values()), (
f"no index covering paper_id found; got {indexes}"
)
def test_paper_appearances_paper_id_indexed_after_migration(
self, fully_migrated_engine
):
"""The migration path must produce the named index
``ix_paper_appearances_paper_id``. create_all() produces the
same name (from ``index=True`` on citation.py:159), so both
paths agree. A missing named index here means alembic-only
installs are running full scans on the paper→appearance join.
"""
indexes = _get_indexes_by_name(
fully_migrated_engine, "paper_appearances"
)
assert "ix_paper_appearances_paper_id" in indexes, (
f"named index ix_paper_appearances_paper_id missing from "
f"migration path; got {list(indexes.keys())}"
)
assert indexes["ix_paper_appearances_paper_id"] == ["paper_id"]
# --------------------------------------------------------------------------- #
# Tests — journals columns + name_lower backfill #
# --------------------------------------------------------------------------- #
class TestMigration0006JournalsColumns:
"""Column additions and the ``name_lower`` backfill."""
def test_new_columns_present(self, fully_migrated_engine):
cols = _get_columns(fully_migrated_engine, "journals")
assert "name_lower" in cols
assert "score_source" in cols
assert "quality_model" in cols
def test_indexes_present(self, fully_migrated_engine):
"""0006 creates ``ix_journals_quality_model``. It does NOT
create a non-unique index on ``name_lower``: the
``uq_journals_name_lower`` UNIQUE constraint already provides
the backing index and a second B-tree on the same column
would be pure write-amplification.
"""
indexes = _get_indexes_by_name(fully_migrated_engine, "journals")
assert "ix_journals_quality_model" in indexes
assert "ix_journals_name_lower" not in indexes, (
"ix_journals_name_lower is redundant with uq_journals_name_lower"
" — must not be created by 0006."
)
assert "ix_journals_name" not in indexes, (
"ix_journals_name is redundant with UNIQUE on Journal.name"
" — must not be created via model.create_all() either."
)
def test_name_lower_backfill_preserves_diacritics(
self, migrated_to_0005_engine
):
"""Python ``str.lower`` on an existing row should produce the
same normalized form the runtime insert path emits — diacritics
must survive unchanged.
"""
engine = migrated_to_0005_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO journals (name, quality, quality_analysis_time) "
"VALUES ('Café Scientifique', 5, 0)"
)
)
_run_upgrade_to(engine, "0006")
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT name_lower FROM journals "
"WHERE name = 'Café Scientifique'"
)
).fetchone()
assert row is not None
assert row[0] == "café scientifique"
def test_name_lower_unique_constraint_exists(self, fully_migrated_engine):
"""UNIQUE on ``name_lower`` is the defence against two rows with
different-cased ``name`` values splitting the journal cache —
e.g. ``"Nature Medicine"`` vs ``"NATURE MEDICINE"`` both passing
the ``name`` UNIQUE check while agreeing on ``name_lower``."""
unique_sets = _get_unique_column_sets(fully_migrated_engine, "journals")
assert {"name_lower"} in unique_sets
def test_migration_dedupes_name_lower_collisions_before_unique(
self, migrated_to_0005_engine
):
"""If pre-0006 rows collide on ``name_lower`` (possible because
0005's schema had no UNIQUE constraint), the migration must
dedupe before adding the new UNIQUE constraint, otherwise the
ALTER TABLE would fail. The surviving row is the HIGHEST-
quality one — the best LLM verdict wins — with ties broken by
lowest id.
"""
engine = migrated_to_0005_engine
with engine.begin() as conn:
# Pre-0006 schema has no name_lower column yet, so collisions
# emerge after backfill. Insert two rows with different-
# cased ``name`` values — both pass the ``name`` UNIQUE but
# will produce the same name_lower.
conn.execute(
text(
"INSERT INTO journals (name, quality, quality_analysis_time) "
"VALUES ('Nature Medicine', 8, 1000)"
)
)
conn.execute(
text(
"INSERT INTO journals (name, quality, quality_analysis_time) "
"VALUES ('NATURE MEDICINE', 9, 2000)"
)
)
# Run 0006 — backfill + dedupe + UNIQUE.
_run_upgrade_to(engine, "0006")
with engine.connect() as conn:
rows = conn.execute(
text(
"SELECT id, name, name_lower, quality FROM journals "
"WHERE name_lower = 'nature medicine' ORDER BY id"
)
).fetchall()
assert len(rows) == 1, (
f"Expected dedupe to leave exactly 1 row, got {len(rows)}: {rows}"
)
# Highest quality wins — that's the second insert (quality=9).
assert rows[0][1] == "NATURE MEDICINE"
assert rows[0][3] == 9
def test_dedupe_prefers_highest_quality_across_nfkc_variants(
self, migrated_to_0005_engine
):
"""Case-fold dedupe must ALSO collapse NFKC compatibility
variants (e.g. "Physics Letters™" vs "Physics Letters TM"),
and the surviving row must be the highest-quality one.
"""
engine = migrated_to_0005_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO journals (name, quality, quality_analysis_time) "
"VALUES ('Physics Letters\u2122', 5, 1000)"
)
)
conn.execute(
text(
"INSERT INTO journals (name, quality, quality_analysis_time) "
"VALUES ('physics lettersTM', 8, 2000)"
)
)
_run_upgrade_to(engine, "0006")
with engine.connect() as conn:
rows = conn.execute(
text(
"SELECT name, name_lower, quality FROM journals "
"WHERE name_lower = 'physics letterstm'"
)
).fetchall()
assert len(rows) == 1, f"Expected 1 row after NFKC dedupe, got {rows}"
assert rows[0][2] == 8
def test_backfill_nfkc_roundtrip(self, migrated_to_0005_engine):
"""Backfilled ``name_lower`` must match scoring.normalize_name
output. U+2122 (™) NFKC-decomposes to "TM"; bare .lower() would
leave it intact. This test locks NFKC semantics against silent
regression to bare lowercase.
"""
engine = migrated_to_0005_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO journals (name, quality, quality_analysis_time) "
"VALUES ('Physics Letters\u2122', 7, 1000)"
)
)
_run_upgrade_to(engine, "0006")
with engine.connect() as conn:
row = conn.execute(
text("SELECT name, name_lower FROM journals WHERE quality = 7")
).fetchone()
# Expected: NFKC(Physics Letters™) → "Physics LettersTM" → lower → "physics letterstm"
assert row[1] == "physics letterstm", (
f"Expected NFKC-normalized name_lower, got {row[1]!r}"
)
# NOTE: no test for name=NULL rows because the 0005 schema has
# NOT NULL on journals.name (Journal model, journal.py:37). The
# ``row.name is not None`` skip in the migration's dedupe is
# defensive against a scenario the schema disallows; we cannot
# seed it in a regression test.
# --------------------------------------------------------------------------- #
# Tests — research_resources index #
# --------------------------------------------------------------------------- #
class TestMigration0006ResearchResourcesIndex:
def test_research_id_index_exists(self, fully_migrated_engine):
indexes = _get_indexes_by_name(
fully_migrated_engine, "research_resources"
)
assert "ix_research_resources_research_id" in indexes
assert indexes["ix_research_resources_research_id"] == ["research_id"]
def test_research_resources_index_present_via_create_all(
self, fresh_engine
):
"""The model MUST declare the index (via __table_args__) so a
fresh ``Base.metadata.create_all()`` path produces the same
``ix_research_resources_research_id`` as the migration path.
Without this, create_all-style installs (dev setups, test
fixtures bypassing migrations) have no research_id index and
run full-table scans on 20+ call sites.
"""
from local_deep_research.database.models.base import Base
Base.metadata.create_all(fresh_engine)
indexes = _get_indexes_by_name(fresh_engine, "research_resources")
assert "ix_research_resources_research_id" in indexes, (
f"named index missing after create_all; got {list(indexes.keys())}"
)
assert indexes["ix_research_resources_research_id"] == ["research_id"]
# --------------------------------------------------------------------------- #
# Tests — idempotency + roundtrip #
# --------------------------------------------------------------------------- #
class TestMigration0006Idempotency:
"""Running the migration twice must be a no-op (no errors, same
schema). The migration uses ``_table_exists`` / ``_column_exists`` /
``_index_exists`` guards for exactly this reason.
"""
def test_double_migrate_no_error(self, fresh_engine):
run_migrations(fresh_engine)
# Second run should succeed silently.
run_migrations(fresh_engine)
head = get_head_revision()
assert head is not None and head.isdigit() and len(head) == 4
def test_rerun_recreates_dropped_papers_indexes(
self, migrated_to_0005_engine
):
"""Regression: if ``papers`` exists but one of its named indexes
was dropped (partial migration, manual intervention, etc.), a
rerun of 0006 must recreate the missing index rather than
silently skip it. Prior behaviour gated all three
``idx_papers_*`` creations on ``if not _table_exists('papers')``,
so the rerun path could never converge.
"""
engine = migrated_to_0005_engine
_run_upgrade_to(engine, "0006")
# Simulate drift: drop one of the named indexes.
with engine.begin() as conn:
conn.execute(text("DROP INDEX idx_papers_year"))
assert "idx_papers_year" not in _get_indexes_by_name(engine, "papers")
# Stamp back to 0005 without touching the schema, then re-upgrade.
# The table still exists, so the rerun must hit the index-guard path.
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.stamp(config, "0005")
_run_upgrade_to(engine, "0006")
indexes = _get_indexes_by_name(engine, "papers")
assert "idx_papers_year" in indexes
assert indexes["idx_papers_year"] == ["year"]
# The other two indexes must also still be present (not re-created
# twice, not dropped).
assert "idx_papers_journal" in indexes
assert "idx_papers_container_title" in indexes
class TestMigration0006Roundtrip:
"""Upgrade 0005 → 0006 → downgrade → upgrade again.
Downgrade must remove every object the upgrade created; the second
upgrade must recreate them without error. This is the cheapest way
to catch missing drops in ``downgrade()``.
"""
def test_downgrade_then_upgrade_restores_schema(
self, migrated_to_0005_engine
):
engine = migrated_to_0005_engine
_run_upgrade_to(engine, "0006")
assert _table_exists(engine, "papers")
assert _table_exists(engine, "paper_appearances")
assert "name_lower" in _get_columns(engine, "journals")
_run_downgrade_to(engine, "0005")
assert not _table_exists(engine, "papers")
assert not _table_exists(engine, "paper_appearances")
assert "name_lower" not in _get_columns(engine, "journals")
assert "score_source" not in _get_columns(engine, "journals")
assert "quality_model" not in _get_columns(engine, "journals")
# Index on research_resources is dropped too.
rr_indexes = _get_indexes_by_name(engine, "research_resources")
assert "ix_research_resources_research_id" not in rr_indexes
_run_upgrade_to(engine, "0006")
assert _table_exists(engine, "papers")
assert _table_exists(engine, "paper_appearances")
assert "name_lower" in _get_columns(engine, "journals")
def test_downgrade_preserves_journals_data(self, migrated_to_0005_engine):
"""The 0005 baseline columns (id, name, quality,
quality_analysis_time) must survive a downgrade. Only the three
columns added by 0006 (name_lower, score_source, quality_model)
are dropped. The downgrade docstring promises this; this test
enforces it.
"""
engine = migrated_to_0005_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO journals (name, quality, quality_analysis_time) "
"VALUES ('Nature', 10, 1234567890)"
)
)
_run_upgrade_to(engine, "0006")
_run_downgrade_to(engine, "0005")
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT name, quality, quality_analysis_time "
"FROM journals WHERE name = 'Nature'"
)
).fetchone()
assert row is not None, "journals row lost on downgrade"
assert row[0] == "Nature"
assert row[1] == 10
assert row[2] == 1234567890
@@ -0,0 +1,220 @@
"""Tests for migration 0009: default search.fetch.mode 'full''summary_focus_query'.
Pins the upgrade/downgrade semantics:
- Rows with the legacy default ``"full"`` get flipped.
- Rows users explicitly chose (``summary_focus``, ``summary_focus_query``,
``disabled``) are left untouched.
- Other settings keys with value ``"full"`` are not affected.
- Idempotency: a second upgrade is a no-op once values are migrated.
The on-disk encoding is JSON-text (``"full"`` with the surrounding
quotes), so the test inserts through SQLAlchemy's JSON column type to
match production storage exactly.
"""
import json
import pytest
from alembic import command
from sqlalchemy import create_engine, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
)
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
def _seed_setting(engine, key, value):
"""Insert a setting matching production's JSON-text storage.
SQLAlchemy's JSON column writes ``json.dumps(value)``; we mirror that
explicitly so raw SQL produces the same on-disk bytes the migration
expects to match in its WHERE clause.
"""
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO settings "
"(key, value, type, name, ui_element, visible, editable) "
"VALUES (:key, :value, 'search', :name, 'select', 1, 1)"
),
{"key": key, "value": json.dumps(value), "name": key},
)
def _read_setting(engine, key):
with engine.begin() as conn:
row = conn.execute(
text("SELECT value FROM settings WHERE key = :key"),
{"key": key},
).fetchone()
if row is None:
return None
raw = row[0]
# JSON column round-trips through json.loads on read, but raw text()
# bypasses the column type — so decode manually.
return json.loads(raw) if isinstance(raw, str) else raw
@pytest.fixture
def migrated_to_0008_engine(tmp_path):
"""Database fully migrated through 0008 (the revision before 0009)."""
db_path = tmp_path / "test_0009.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0008")
yield engine
engine.dispose()
class TestMigration0009Upgrade:
def test_full_value_is_migrated_to_summary_focus_query(
self, migrated_to_0008_engine
):
engine = migrated_to_0008_engine
with engine.begin() as conn:
conn.execute(
text("DELETE FROM settings WHERE key = 'search.fetch.mode'")
)
_seed_setting(engine, "search.fetch.mode", "full")
_run_upgrade_to(engine, "0009")
assert (
_read_setting(engine, "search.fetch.mode") == "summary_focus_query"
)
@pytest.mark.parametrize(
"explicit_value",
["summary_focus", "summary_focus_query", "disabled"],
)
def test_explicit_non_full_choices_are_preserved(
self, migrated_to_0008_engine, explicit_value
):
engine = migrated_to_0008_engine
with engine.begin() as conn:
conn.execute(
text("DELETE FROM settings WHERE key = 'search.fetch.mode'")
)
_seed_setting(engine, "search.fetch.mode", explicit_value)
_run_upgrade_to(engine, "0009")
assert _read_setting(engine, "search.fetch.mode") == explicit_value
def test_other_keys_with_full_value_are_not_touched(
self, migrated_to_0008_engine
):
engine = migrated_to_0008_engine
# An unrelated key that happens to hold 'full' must not be flipped.
_seed_setting(engine, "test.unrelated.mode", "full")
_run_upgrade_to(engine, "0009")
assert _read_setting(engine, "test.unrelated.mode") == "full"
def test_upgrade_is_idempotent(self, migrated_to_0008_engine):
"""Running the upgrade a second time is a no-op (already at head)."""
engine = migrated_to_0008_engine
with engine.begin() as conn:
conn.execute(
text("DELETE FROM settings WHERE key = 'search.fetch.mode'")
)
_seed_setting(engine, "search.fetch.mode", "full")
_run_upgrade_to(engine, "0009")
# Second invocation: nothing left to do; alembic short-circuits.
_run_upgrade_to(engine, "0009")
assert (
_read_setting(engine, "search.fetch.mode") == "summary_focus_query"
)
def test_no_settings_row_does_not_error(self, migrated_to_0008_engine):
"""If the row never existed, the migration is a clean no-op."""
engine = migrated_to_0008_engine
with engine.begin() as conn:
conn.execute(
text("DELETE FROM settings WHERE key = 'search.fetch.mode'")
)
_run_upgrade_to(engine, "0009")
assert _read_setting(engine, "search.fetch.mode") is None
class TestMigration0009Downgrade:
def test_downgrade_reverts_summary_focus_query_to_full(
self, migrated_to_0008_engine
):
engine = migrated_to_0008_engine
with engine.begin() as conn:
conn.execute(
text("DELETE FROM settings WHERE key = 'search.fetch.mode'")
)
_seed_setting(engine, "search.fetch.mode", "full")
_run_upgrade_to(engine, "0009")
_run_downgrade_to(engine, "0008")
assert _read_setting(engine, "search.fetch.mode") == "full"
@pytest.mark.parametrize("preserved_value", ["summary_focus", "disabled"])
def test_downgrade_preserves_other_explicit_choices(
self, migrated_to_0008_engine, preserved_value
):
engine = migrated_to_0008_engine
with engine.begin() as conn:
conn.execute(
text("DELETE FROM settings WHERE key = 'search.fetch.mode'")
)
_seed_setting(engine, "search.fetch.mode", preserved_value)
_run_upgrade_to(engine, "0009")
_run_downgrade_to(engine, "0008")
assert _read_setting(engine, "search.fetch.mode") == preserved_value
class TestMigration0009HeadAlignment:
def test_0009_chains_correctly_to_0008(self):
"""0009 (default_fetch_mode_summary) chains directly off 0008.
Originally this asserted ``get_head_revision() == "0009"``,
but a later migration added 0010 (chat tables, including the
partial unique chat-in-progress index) on top, so head moved
past 0009. The substantive invariant the original test was
protecting — that 0009 is correctly anchored in the chain —
survives by checking down_revision instead.
"""
from alembic.config import Config
from alembic.script import ScriptDirectory
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
config = Config()
config.set_main_option("script_location", str(get_migrations_dir()))
script = ScriptDirectory.from_config(config)
rev_0009 = script.get_revision("0009")
assert rev_0009.down_revision == "0008"
+399
View File
@@ -0,0 +1,399 @@
"""Tests for migration 0010: Add chat tables.
Migration 0010 introduces the chat schema in its final clean shape:
(originally numbered 0009; renumbered to 0010 when main's
0009_default_fetch_mode_summary landed first.)
- chat_sessions with status as Enum (ChatSessionStatus)
- chat_messages with content NOT NULL, no CHECK
- chat_progress_steps as a separate table for transient research
progress events (no longer mixed into chat_messages)
- research_history.chat_session_id (FK SET NULL) + step_count
The migration is fresh-install only; legacy 0007-shape dev DBs must
be recreated (or the chat tables dropped manually) before running.
Tests cover:
- Fresh-install path: chat tables exist, content is NOT NULL, no
CHECK constraint, status is Enum-typed.
- Idempotency: re-running migrations on a head DB is a no-op.
- Downgrade is NotImplementedError.
"""
import pytest
from alembic import command
from sqlalchemy import create_engine, event, inspect
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from local_deep_research.database.alembic_runner import (
get_alembic_config,
run_migrations,
stamp_database,
)
_PARTIAL_UNIQUE_INDEX_NAME = "ux_research_history_chat_session_in_progress"
def _run_downgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
@pytest.fixture
def fresh_engine(tmp_path):
db_path = tmp_path / "fresh_0010.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
@pytest.fixture
def fully_migrated_engine(tmp_path):
db_path = tmp_path / "fully_migrated_0010.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
class TestSchemaShape:
"""Chat schema invariants are in place after 0010 runs."""
def test_chat_tables_exist(self, fully_migrated_engine):
insp = inspect(fully_migrated_engine)
for table in ("chat_sessions", "chat_messages", "chat_progress_steps"):
assert insp.has_table(table), f"{table} missing"
def test_chat_messages_content_is_not_null(self, fully_migrated_engine):
insp = inspect(fully_migrated_engine)
cols = {c["name"]: c for c in insp.get_columns("chat_messages")}
assert "content" in cols
assert cols["content"]["nullable"] is False
def test_chat_messages_has_no_legacy_check(self, fully_migrated_engine):
insp = inspect(fully_migrated_engine)
checks = insp.get_check_constraints("chat_messages")
names = {c.get("name") for c in checks}
assert "ck_chat_message_has_content_source" not in names
def test_chat_session_status_typed(self, fully_migrated_engine):
"""status is typed VARCHAR with default 'active'.
Note: SQLAlchemy's Enum on SQLite stores as VARCHAR sized to
the longest enum value but does NOT emit a DB-level CHECK
unless `create_constraint=True` is explicitly set. The
codebase relies on ORM-layer validation (ChatSessionStatus(value))
for value enforcement — same pattern as ChatRole/ChatMessageType.
"""
insp = inspect(fully_migrated_engine)
cols = {c["name"]: c for c in insp.get_columns("chat_sessions")}
assert "status" in cols
# VARCHAR sized to the longest enum value ('archived' = 8 chars)
type_str = str(cols["status"]["type"]).upper()
assert "VARCHAR" in type_str
def test_research_history_chat_session_id_present(
self, fully_migrated_engine
):
insp = inspect(fully_migrated_engine)
cols = {c["name"] for c in insp.get_columns("research_history")}
assert "chat_session_id" in cols
assert "step_count" in cols
def test_chat_progress_steps_unique_per_research_seq(
self, fully_migrated_engine
):
insp = inspect(fully_migrated_engine)
uniques = insp.get_unique_constraints("chat_progress_steps")
names = {u.get("name") for u in uniques}
assert "uq_chat_progress_step_research_seq" in names
def test_composite_indexes_exist_after_upgrade(self, fully_migrated_engine):
"""Composite (session_id, created_at) indexes serve the load-older
pagination query in chat/service.py::get_session_messages. Without
them, SQLite uses the single-column session_id index and sorts in
memory — break-even at ~500 rows/session.
"""
insp = inspect(fully_migrated_engine)
msg_idx = {
i["name"]: i["column_names"]
for i in insp.get_indexes("chat_messages")
}
assert "ix_chat_messages_session_created" in msg_idx
assert msg_idx["ix_chat_messages_session_created"] == [
"session_id",
"created_at",
]
step_idx = {
i["name"]: i["column_names"]
for i in insp.get_indexes("chat_progress_steps")
}
assert "ix_chat_progress_steps_session_created" in step_idx
assert step_idx["ix_chat_progress_steps_session_created"] == [
"session_id",
"created_at",
]
class TestIdempotency:
"""Re-running migrations on a head DB is a no-op."""
def test_double_migrate_no_error(self, fresh_engine):
run_migrations(fresh_engine)
# Second run must not raise.
run_migrations(fresh_engine)
insp = inspect(fresh_engine)
assert insp.has_table("chat_progress_steps")
class TestDowngrade:
"""Downgrade is not supported and raises NotImplementedError.
Why: SQLite ALTER TABLE forbids dropping a column that is the
target of a FOREIGN KEY definition, and alembic's batch_alter_table
cannot rebuild research_history due to unnamed legacy constraints
on that table. The project is dev-stage; recreate the DB to roll
back. The parametrized stairway/down-leaves-no-residual tests in
test_alembic_migrations.py exempt 0010 via NON_REVERSIBLE_REVISIONS.
"""
def test_downgrade_raises_not_implemented(self, fully_migrated_engine):
with pytest.raises(NotImplementedError):
_run_downgrade_to(fully_migrated_engine, "0008")
class TestExistingDataBackfill:
"""Verify 0010 leaves pre-existing research_history rows in a sane state.
Note: we cannot use `run_migrations(target="0008")` to land at the
pre-0010 state because 0001 uses `Base.metadata.create_all` against the
live `Base`, which already includes `chat_session_id` and `step_count`.
Instead we hand-build a minimal pre-0010 `research_history` and stamp
the DB at 0009 so 0010 forward runs the actual ADD COLUMN path.
"""
def test_step_count_backfilled_for_existing_rows(self, tmp_path):
engine = create_engine(f"sqlite:///{tmp_path}/m.db")
# Hand-build pre-0010 research_history (subset of NOT NULL cols).
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE research_history ("
" id TEXT PRIMARY KEY, "
" query TEXT NOT NULL, "
" mode TEXT NOT NULL, "
" status TEXT NOT NULL, "
" created_at TEXT NOT NULL"
")"
)
)
conn.execute(
text(
"INSERT INTO research_history (id, query, mode, status, "
"created_at) VALUES ('r1', 'q', 'quick', 'completed', "
"'2026-01-01T00:00:00')"
)
)
# Stamp at 0009 (main's settings-only fetch_mode migration) so
# 0010 (chat tables) is the next forward step we exercise. 0010
# is the migration that actually ADD COLUMNs onto our hand-built
# research_history shape.
stamp_database(engine, "0009")
run_migrations(engine, target="head")
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT step_count, chat_session_id "
"FROM research_history WHERE id = 'r1'"
)
).first()
assert row is not None
# Relies on 0010 using server_default="0" (SQL-side, applied by
# SQLite ADD COLUMN at DDL time). If a future refactor switches
# to Python-side default=0, this assertion fails for the pre-
# existing 'r1' row — exactly the regression we want to catch.
assert row.step_count == 0
assert row.chat_session_id is None
# ---------------------------------------------------------------------------
# Partial unique index: at-most-one-in-progress per chat_session_id
#
# Originally lived in a separate migration 0011 with a separate test file;
# folded into 0010 to keep the chat schema landing in a single migration
# (chat is unreleased; one migration is easier to maintain than two).
# ---------------------------------------------------------------------------
@pytest.fixture
def fully_migrated_engine_with_fk(tmp_path):
"""Fully migrated SQLite engine with FK enforcement on every connection.
Required for the partial-unique-index tests because they depend on
SQLite enforcing the constraint at INSERT time, which only happens
when ``PRAGMA foreign_keys = ON`` is active.
"""
db_path = tmp_path / "0010_partial_unique_test.db"
engine = create_engine(f"sqlite:///{db_path}")
@event.listens_for(engine, "connect")
def _enable_fk(dbapi_connection, _):
dbapi_connection.execute("PRAGMA foreign_keys = ON")
run_migrations(engine, target="head")
yield engine
engine.dispose()
def _seed_chat_session(conn, sid):
conn.execute(
text(
"INSERT INTO chat_sessions "
"(id, status, message_count, created_at) "
"VALUES (:id, 'active', 0, '2026-01-01T00:00:00')"
),
{"id": sid},
)
def _insert_research(conn, *, rid, sid, status):
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at, chat_session_id) "
"VALUES (:rid, 'q', 'quick', :status, "
"'2026-01-01T00:00:00', :sid)"
),
{"rid": rid, "sid": sid, "status": status},
)
class TestPartialUniqueInProgressIndex:
"""The partial unique index closes a SELECT-then-INSERT race in
chat/routes.py. Verify the constraint actually fires at the DB."""
def test_partial_unique_index_exists_after_upgrade(
self, fully_migrated_engine_with_fk
):
inspector = inspect(fully_migrated_engine_with_fk)
indexes = {
idx["name"]: idx
for idx in inspector.get_indexes("research_history")
}
assert _PARTIAL_UNIQUE_INDEX_NAME in indexes
idx = indexes[_PARTIAL_UNIQUE_INDEX_NAME]
# SQLAlchemy's SQLite inspector returns 1 / 0 rather than True /
# False for the unique flag, so compare on truthiness.
assert bool(idx["unique"])
assert idx["column_names"] == ["chat_session_id"]
def test_second_in_progress_for_same_chat_session_blocked(
self, fully_migrated_engine_with_fk
):
engine = fully_migrated_engine_with_fk
with engine.begin() as conn:
_seed_chat_session(conn, "s1")
_insert_research(conn, rid="r1", sid="s1", status="in_progress")
with engine.connect() as conn:
with pytest.raises(IntegrityError):
with conn.begin():
_insert_research(
conn, rid="r2", sid="s1", status="in_progress"
)
def test_completed_runs_for_same_chat_session_allowed(
self, fully_migrated_engine_with_fk
):
"""Partial: only in_progress rows are unique; completed history
of arbitrarily many runs per chat session must remain allowed."""
engine = fully_migrated_engine_with_fk
with engine.begin() as conn:
_seed_chat_session(conn, "s1")
_insert_research(conn, rid="r1", sid="s1", status="completed")
_insert_research(conn, rid="r2", sid="s1", status="completed")
_insert_research(conn, rid="r3", sid="s1", status="failed")
with engine.connect() as conn:
count = conn.execute(
text(
"SELECT COUNT(*) FROM research_history "
"WHERE chat_session_id='s1'"
)
).scalar()
assert count == 3
def test_in_progress_with_null_chat_session_id_unconstrained(
self, fully_migrated_engine_with_fk
):
"""Partial: NULL chat_session_id rows must be unconstrained so
non-chat research (news, scheduler, direct API) is unaffected."""
engine = fully_migrated_engine_with_fk
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES (:rid, 'q', 'quick', 'in_progress', "
"'2026-01-01T00:00:00')"
),
{"rid": "r1"},
)
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES (:rid, 'q', 'quick', 'in_progress', "
"'2026-01-01T00:00:00')"
),
{"rid": "r2"},
)
with engine.connect() as conn:
count = conn.execute(
text(
"SELECT COUNT(*) FROM research_history "
"WHERE chat_session_id IS NULL"
)
).scalar()
assert count == 2
def test_completing_a_run_releases_the_in_progress_slot(
self, fully_migrated_engine_with_fk
):
"""After r1 transitions away from in_progress, r2 must be able
to claim the slot."""
engine = fully_migrated_engine_with_fk
with engine.begin() as conn:
_seed_chat_session(conn, "s1")
_insert_research(conn, rid="r1", sid="s1", status="in_progress")
with engine.begin() as conn:
conn.execute(
text(
"UPDATE research_history "
"SET status='completed' WHERE id='r1'"
)
)
with engine.begin() as conn:
_insert_research(conn, rid="r2", sid="s1", status="in_progress")
with engine.connect() as conn:
count = conn.execute(
text(
"SELECT COUNT(*) FROM research_history "
"WHERE chat_session_id='s1' AND status='in_progress'"
)
).scalar()
assert count == 1
@@ -0,0 +1,303 @@
"""Tests for migration 0013: remove the auto/parallel meta search engines.
Pins the upgrade semantics:
- ``search.tool`` rows naming a removed engine (auto / meta / parallel /
parallel_scientific) are rewritten to ``searxng``; concrete engine
choices are preserved.
- Orphaned ``search.engine.auto.*`` / ``search.engine.web.parallel.*``
setting rows are deleted; sibling keys (e.g. ``use_in_auto_search``
flags under other engines) survive.
- ``news_subscriptions.search_engine`` is NULLed for removed engines
(falsy means "use the user's default search tool" in the scheduler).
- ``queued_researches.settings_snapshot`` JSON has its
``submission.search_engine`` (and embedded ``search.tool``) rewritten,
for both the new nested and the legacy flat snapshot structure.
- ``benchmark_runs`` / ``benchmark_configs`` ``search_config.search_tool``
is rewritten.
- Idempotency: re-running the upgrade is a no-op.
The settings ``value`` column stores JSON text (``"auto"`` with quotes);
seeding mirrors that encoding so the WHERE clauses match production.
"""
import json
import pytest
from alembic import command
from sqlalchemy import create_engine, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
)
REMOVED = ["auto", "meta", "parallel", "parallel_scientific"]
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _seed_setting(engine, key, value):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO settings "
"(key, value, type, name, ui_element, visible, editable) "
"VALUES (:key, :value, 'search', :name, 'select', 1, 1)"
),
{"key": key, "value": json.dumps(value), "name": key},
)
def _read_setting(engine, key):
with engine.begin() as conn:
row = conn.execute(
text("SELECT value FROM settings WHERE key = :key"),
{"key": key},
).fetchone()
if row is None:
return None
raw = row[0]
return json.loads(raw) if isinstance(raw, str) else raw
@pytest.fixture
def migrated_to_0012_engine(tmp_path):
"""Database fully migrated through 0012 (the revision before 0013)."""
db_path = tmp_path / "test_0013.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0012")
yield engine
engine.dispose()
class TestMigration0013Settings:
@pytest.mark.parametrize("removed_value", REMOVED)
def test_search_tool_rewritten_to_searxng(
self, migrated_to_0012_engine, removed_value
):
engine = migrated_to_0012_engine
_seed_setting(engine, "search.tool", removed_value)
_run_upgrade_to(engine, "0013")
assert _read_setting(engine, "search.tool") == "searxng"
@pytest.mark.parametrize("concrete", ["searxng", "wikipedia", "arxiv"])
def test_concrete_engine_choice_preserved(
self, migrated_to_0012_engine, concrete
):
engine = migrated_to_0012_engine
_seed_setting(engine, "search.tool", concrete)
_run_upgrade_to(engine, "0013")
assert _read_setting(engine, "search.tool") == concrete
def test_orphan_engine_settings_deleted(self, migrated_to_0012_engine):
engine = migrated_to_0012_engine
_seed_setting(engine, "search.engine.auto.display_name", "Auto")
_seed_setting(engine, "search.engine.web.parallel.reliability", 0.5)
# Sibling keys under other engines must survive — including the
# use_in_auto_search flags whose names merely contain "auto".
_seed_setting(
engine, "search.engine.web.searxng.use_in_auto_search", True
)
_run_upgrade_to(engine, "0013")
assert _read_setting(engine, "search.engine.auto.display_name") is None
assert (
_read_setting(engine, "search.engine.web.parallel.reliability")
is None
)
assert (
_read_setting(
engine, "search.engine.web.searxng.use_in_auto_search"
)
is True
)
def test_upgrade_is_idempotent(self, migrated_to_0012_engine):
engine = migrated_to_0012_engine
_seed_setting(engine, "search.tool", "auto")
_run_upgrade_to(engine, "0013")
_run_upgrade_to(engine, "0013")
assert _read_setting(engine, "search.tool") == "searxng"
def test_missing_rows_are_a_clean_noop(self, migrated_to_0012_engine):
engine = migrated_to_0012_engine
_run_upgrade_to(engine, "0013")
assert _read_setting(engine, "search.tool") is None
class TestMigration0013NewsSubscriptions:
def _seed_subscription(self, engine, sub_id, search_engine):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO news_subscriptions "
"(id, subscription_type, query_or_topic, search_engine) "
"VALUES (:id, 'search', 'test query', :se)"
),
{"id": sub_id, "se": search_engine},
)
def _read_subscription_engine(self, engine, sub_id):
with engine.begin() as conn:
return conn.execute(
text(
"SELECT search_engine FROM news_subscriptions "
"WHERE id = :id"
),
{"id": sub_id},
).fetchone()[0]
def test_removed_engine_nulled_concrete_preserved(
self, migrated_to_0012_engine
):
engine = migrated_to_0012_engine
self._seed_subscription(engine, "sub-auto", "auto")
self._seed_subscription(engine, "sub-parallel", "parallel")
self._seed_subscription(engine, "sub-wiki", "wikipedia")
self._seed_subscription(engine, "sub-null", None)
_run_upgrade_to(engine, "0013")
assert self._read_subscription_engine(engine, "sub-auto") is None
assert self._read_subscription_engine(engine, "sub-parallel") is None
assert self._read_subscription_engine(engine, "sub-wiki") == "wikipedia"
assert self._read_subscription_engine(engine, "sub-null") is None
class TestMigration0013QueuedResearches:
def _seed_queued(self, engine, research_id, snapshot):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO queued_researches "
"(username, research_id, query, mode, settings_snapshot, "
"position) "
"VALUES ('tester', :rid, 'q', 'quick', :snap, 1)"
),
{"rid": research_id, "snap": json.dumps(snapshot)},
)
def _read_snapshot(self, engine, research_id):
with engine.begin() as conn:
raw = conn.execute(
text(
"SELECT settings_snapshot FROM queued_researches "
"WHERE research_id = :rid"
),
{"rid": research_id},
).fetchone()[0]
return json.loads(raw) if isinstance(raw, str) else raw
def test_nested_submission_engine_rewritten(self, migrated_to_0012_engine):
engine = migrated_to_0012_engine
self._seed_queued(
engine,
"rid-nested",
{
"submission": {"search_engine": "auto", "model": "m"},
"settings_snapshot": {
"search.tool": {"value": "auto", "type": "SEARCH"}
},
},
)
_run_upgrade_to(engine, "0013")
snap = self._read_snapshot(engine, "rid-nested")
assert snap["submission"]["search_engine"] == "searxng"
assert snap["settings_snapshot"]["search.tool"]["value"] == "searxng"
def test_legacy_flat_snapshot_rewritten(self, migrated_to_0012_engine):
engine = migrated_to_0012_engine
self._seed_queued(
engine, "rid-flat", {"search_engine": "parallel", "model": "m"}
)
_run_upgrade_to(engine, "0013")
snap = self._read_snapshot(engine, "rid-flat")
assert snap["search_engine"] == "searxng"
def test_concrete_engine_snapshot_untouched(self, migrated_to_0012_engine):
engine = migrated_to_0012_engine
original = {
"submission": {"search_engine": "wikipedia"},
"settings_snapshot": {"search.tool": "wikipedia"},
}
self._seed_queued(engine, "rid-wiki", original)
_run_upgrade_to(engine, "0013")
assert self._read_snapshot(engine, "rid-wiki") == original
class TestMigration0013Benchmarks:
def _seed_config(self, engine, name, search_tool):
search_config = json.dumps(
{"search_tool": search_tool, "iterations": 2}
)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO benchmark_configs "
"(name, config_hash, search_config, evaluation_config, "
"datasets_config, created_at, updated_at, is_default, "
"is_public, usage_count) "
"VALUES (:name, 'abcd1234', :sc, '{}', '{}', "
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0, 0, 0)"
),
{"name": name, "sc": search_config},
)
def _read_config_tool(self, engine, name):
with engine.begin() as conn:
raw = conn.execute(
text(
"SELECT search_config FROM benchmark_configs "
"WHERE name = :name"
),
{"name": name},
).fetchone()[0]
config = json.loads(raw) if isinstance(raw, str) else raw
return config["search_tool"]
def test_benchmark_config_search_tool_rewritten(
self, migrated_to_0012_engine
):
engine = migrated_to_0012_engine
self._seed_config(engine, "cfg-auto", "auto")
self._seed_config(engine, "cfg-searxng", "searxng")
_run_upgrade_to(engine, "0013")
assert self._read_config_tool(engine, "cfg-auto") == "searxng"
assert self._read_config_tool(engine, "cfg-searxng") == "searxng"
class TestMigration0013HeadAlignment:
def test_0013_chains_correctly_to_0012(self):
from alembic.config import Config
from alembic.script import ScriptDirectory
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
config = Config()
config.set_main_option("script_location", str(get_migrations_dir()))
script = ScriptDirectory.from_config(config)
rev_0013 = script.get_revision("0013")
assert rev_0013.down_revision == "0012"
@@ -0,0 +1,209 @@
"""Tests for migration 0014: add ldr_version + settings_snapshot to benchmark_runs.
Pins the upgrade/downgrade semantics:
- Upgrade adds both columns as nullable on existing benchmark_runs tables.
- Existing rows survive; their new columns are NULL.
- Downgrade drops both columns cleanly.
- Idempotent: re-running upgrade after partial application is a no-op.
- Missing-table guard: doesn't crash if benchmark_runs doesn't exist yet.
Note on test setup
==================
Migration 0001 calls ``Base.metadata.create_all()``, which creates tables
based on the CURRENT model class — so on a freshly initialised DB, the new
columns are present even before 0014 runs. To pin "0014 is the migration
that adds these columns" we use the same pattern as
``test_migration_0005_resource_document_id.py``: hand-build a "legacy"
``benchmark_runs`` table without the new columns, stamp the DB at
``down_revision``, and run the upgrade against that.
"""
import pytest
from alembic import command
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
stamp_database,
)
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
def _columns(engine, table):
insp = inspect(engine)
if not insp.has_table(table):
return set()
return {c["name"] for c in insp.get_columns(table)}
def _column_info(engine, table):
insp = inspect(engine)
if not insp.has_table(table):
return {}
return {c["name"]: c for c in insp.get_columns(table)}
@pytest.fixture
def legacy_engine(tmp_path):
"""A DB with a ``benchmark_runs`` table missing the new 0014 columns.
Simulates an installation that's been running on revisions 00010013
and never had the new ``ldr_version`` or ``settings_snapshot`` columns.
Stamped at 0013 so 0014's upgrade is a clean delta.
"""
db_path = tmp_path / "legacy_pre_0014.db"
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
# Build the pre-0014 benchmark_runs schema by hand. We only need
# the NOT-NULL columns the migration will inspect; full schema
# fidelity isn't required for column-add tests.
conn.execute(
text(
"CREATE TABLE benchmark_runs ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"run_name VARCHAR(255), "
"config_hash VARCHAR(16) NOT NULL, "
"query_hash_list TEXT NOT NULL, "
"search_config TEXT NOT NULL, "
"evaluation_config TEXT NOT NULL, "
"datasets_config TEXT NOT NULL, "
"status VARCHAR(20) NOT NULL, "
"created_at TEXT NOT NULL, "
"updated_at TEXT NOT NULL, "
"start_time TEXT, "
"end_time TEXT, "
"total_examples INTEGER NOT NULL DEFAULT 0, "
"completed_examples INTEGER NOT NULL DEFAULT 0, "
"failed_examples INTEGER NOT NULL DEFAULT 0, "
"overall_accuracy REAL, "
"processing_rate REAL, "
"error_message TEXT"
")"
)
)
# Stamp at the predecessor so alembic's view of state is consistent.
stamp_database(engine, "0013")
yield engine
engine.dispose()
class TestMigration0014Upgrade:
def test_adds_ldr_version_column(self, legacy_engine):
engine = legacy_engine
assert "ldr_version" not in _columns(engine, "benchmark_runs")
_run_upgrade_to(engine, "0014")
assert "ldr_version" in _columns(engine, "benchmark_runs")
def test_adds_settings_snapshot_column(self, legacy_engine):
engine = legacy_engine
assert "settings_snapshot" not in _columns(engine, "benchmark_runs")
_run_upgrade_to(engine, "0014")
assert "settings_snapshot" in _columns(engine, "benchmark_runs")
def test_columns_are_nullable(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0014")
cols = _column_info(engine, "benchmark_runs")
assert cols["ldr_version"]["nullable"] is True
assert cols["settings_snapshot"]["nullable"] is True
def test_existing_row_survives_with_null_new_columns(self, legacy_engine):
engine = legacy_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO benchmark_runs "
"(run_name, config_hash, query_hash_list, search_config, "
"evaluation_config, datasets_config, status, created_at, "
"updated_at, total_examples, completed_examples, "
"failed_examples) "
"VALUES ('pre-migration', 'h0', '[]', '{}', '{}', '{}', "
"'PENDING', '2026-01-01 00:00:00', '2026-01-01 00:00:00', "
"0, 0, 0)"
)
)
_run_upgrade_to(engine, "0014")
with engine.begin() as conn:
row = conn.execute(
text(
"SELECT run_name, ldr_version, settings_snapshot "
"FROM benchmark_runs WHERE run_name = 'pre-migration'"
)
).fetchone()
assert row is not None
assert row[0] == "pre-migration"
assert row[1] is None
assert row[2] is None
def test_can_write_new_columns_after_upgrade(self, legacy_engine):
"""Sanity check — the columns are usable, not just present."""
engine = legacy_engine
_run_upgrade_to(engine, "0014")
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO benchmark_runs "
"(run_name, config_hash, query_hash_list, search_config, "
"evaluation_config, datasets_config, status, created_at, "
"updated_at, total_examples, completed_examples, "
"failed_examples, ldr_version, settings_snapshot) "
"VALUES ('post-migration', 'h1', '[]', '{}', '{}', '{}', "
"'COMPLETED', '2026-05-03 00:00:00', "
"'2026-05-03 00:00:00', 1, 1, 0, '1.6.10', "
"""'{"llm.model": {"value": "qwen3.6:27b"}}')"""
)
)
row = conn.execute(
text(
"SELECT ldr_version, settings_snapshot "
"FROM benchmark_runs WHERE run_name = 'post-migration'"
)
).fetchone()
assert row[0] == "1.6.10"
# SQLite stores JSON as TEXT — values come back as strings here.
assert "qwen3.6:27b" in row[1]
def test_upgrade_is_idempotent(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0014")
_run_upgrade_to(engine, "0014")
cols = _columns(engine, "benchmark_runs")
assert "ldr_version" in cols
assert "settings_snapshot" in cols
def test_missing_table_does_not_crash(self, tmp_path):
"""If benchmark_runs doesn't exist, migration is a clean no-op."""
db_path = tmp_path / "no_benchmark_table.db"
engine = create_engine(f"sqlite:///{db_path}")
stamp_database(engine, "0013")
# Should not raise.
_run_upgrade_to(engine, "0014")
engine.dispose()
class TestMigration0014Downgrade:
def test_downgrade_removes_columns(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0014")
assert "ldr_version" in _columns(engine, "benchmark_runs")
_run_downgrade_to(engine, "0013")
cols = _columns(engine, "benchmark_runs")
assert "ldr_version" not in cols
assert "settings_snapshot" not in cols
@@ -0,0 +1,190 @@
"""Tests for migration 0015: drop the dead ``documents.notes`` legacy column.
Covers:
- The drop removes ``notes`` from ``documents``.
- Other columns, indexes, and row data on the rebuilt table survive.
- Idempotency (re-run, and tables that never had the column).
- Missing-table guard.
- Downgrade re-adds ``notes`` as nullable Text, and a round-trip is stable.
(The head-alignment guard moved to the newer 0016 migration's test file, per
the convention that it lives in the newest migration's tests.)
Note on test setup
==================
Migration 0001 runs ``Base.metadata.create_all()`` against the CURRENT model,
which (after this PR) no longer has ``notes`` — so a freshly initialised DB
never has the column. To pin "0015 is the migration that drops it" we use the
same pattern as ``test_migration_0005_resource_document_id.py`` /
``test_migration_0014_*``: hand-build a "legacy" ``documents`` table that still
has ``notes``, stamp at ``0014``, and run the upgrade against that.
"""
import pytest
from alembic import command
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
stamp_database,
)
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
def _columns(engine, table):
insp = inspect(engine)
if not insp.has_table(table):
return set()
return {c["name"] for c in insp.get_columns(table)}
def _column_info(engine, table):
insp = inspect(engine)
if not insp.has_table(table):
return {}
return {c["name"]: c for c in insp.get_columns(table)}
def _index_names(engine, table):
insp = inspect(engine)
if not insp.has_table(table):
return set()
return {idx["name"] for idx in insp.get_indexes(table) if idx["name"]}
@pytest.fixture
def legacy_engine(tmp_path):
"""A DB whose ``documents`` table still has the ``notes`` column.
Simulates an installation created before 0015. Stamped at 0014 so 0015's
upgrade is a clean delta. Includes a named index and a seeded row so the
batch-mode table rebuild can be checked for column/index/data preservation.
"""
db_path = tmp_path / "legacy_pre_0015.db"
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE documents ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"document_hash VARCHAR(64) NOT NULL, "
"title VARCHAR(500), "
"favorite BOOLEAN NOT NULL DEFAULT 0, "
"tags TEXT, "
"notes TEXT, "
"created_at TEXT NOT NULL"
")"
)
)
conn.execute(
text("CREATE INDEX idx_document_hash ON documents (document_hash)")
)
conn.execute(
text(
"INSERT INTO documents "
"(document_hash, title, favorite, tags, notes, created_at) "
"VALUES ('hash-1', 'Doc One', 1, '[\"a\"]', "
"'a stray note', '2026-01-01 00:00:00')"
)
)
stamp_database(engine, "0014")
yield engine
engine.dispose()
class TestMigration0015Upgrade:
def test_drops_notes_column(self, legacy_engine):
engine = legacy_engine
assert "notes" in _columns(engine, "documents")
_run_upgrade_to(engine, "0015")
assert "notes" not in _columns(engine, "documents")
def test_preserves_other_columns(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0015")
cols = _columns(engine, "documents")
for expected in {
"id",
"document_hash",
"title",
"favorite",
"tags",
"created_at",
}:
assert expected in cols
def test_preserves_index(self, legacy_engine):
engine = legacy_engine
assert "idx_document_hash" in _index_names(engine, "documents")
_run_upgrade_to(engine, "0015")
assert "idx_document_hash" in _index_names(engine, "documents")
def test_preserves_row_data(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0015")
with engine.begin() as conn:
row = conn.execute(
text(
"SELECT document_hash, title, favorite, tags "
"FROM documents WHERE document_hash = 'hash-1'"
)
).fetchone()
assert row is not None
assert row[0] == "hash-1"
assert row[1] == "Doc One"
assert row[2] == 1
assert row[3] == '["a"]'
def test_upgrade_is_idempotent(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0015")
_run_upgrade_to(engine, "0015") # second run is a no-op, no error
assert "notes" not in _columns(engine, "documents")
def test_missing_table_does_not_crash(self, tmp_path):
"""Upgrade is a no-op when ``documents`` doesn't exist yet."""
engine = create_engine(f"sqlite:///{tmp_path}/no_documents.db")
stamp_database(engine, "0014")
_run_upgrade_to(engine, "0015") # must not raise
assert not inspect(engine).has_table("documents")
engine.dispose()
class TestMigration0015Downgrade:
def test_downgrade_readds_notes_nullable(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0015")
assert "notes" not in _columns(engine, "documents")
_run_downgrade_to(engine, "0014")
cols = _column_info(engine, "documents")
assert "notes" in cols
assert cols["notes"]["nullable"] is True
assert "TEXT" in str(cols["notes"]["type"]).upper()
def test_downgrade_then_upgrade_roundtrip(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0015")
_run_downgrade_to(engine, "0014")
_run_upgrade_to(engine, "0015")
assert "notes" not in _columns(engine, "documents")
# Seeded row still present after the round-trip.
with engine.begin() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM documents")
).scalar()
assert count == 1
@@ -0,0 +1,223 @@
"""Dedicated tests for migration 0016 (drop orphaned ``cache`` + ``search_cache``).
Mirrors the 0013/0014 convention: seed the pre-0016 state, run the upgrade,
and assert the destructive effect.
This file exists because the ``Cache``/``SearchCache`` models were removed in
the same PR, so no other test (and no migration) ever creates these tables.
Without this file, migration 0016 always reaches ``inspector.has_table()`` as
False in the test suite and its ``op.drop_table`` branch — the only code that
runs against real user databases — is never exercised. Here we recreate the
legacy tables at the revision before 0016 to simulate an existing user DB that
still carries them, then prove the upgrade drops them.
(0016 was renumbered from 0015 after a separate 0015_drop_document_notes
landed on main; it now chains after that revision.)
"""
import pytest
from alembic import command
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
stamp_database,
)
from local_deep_research.database.encrypted_db import DatabaseManager
# DDL for the two tables as they existed before this migration (created via
# Base.metadata from the now-removed ORM models). Recreated here so the test
# can simulate a pre-existing user database that still carries them, including
# every index the models declared.
_SEARCH_CACHE_DDL = [
"""
CREATE TABLE search_cache (
query_hash VARCHAR NOT NULL PRIMARY KEY,
query_text TEXT NOT NULL,
results JSON NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
access_count INTEGER,
last_accessed INTEGER NOT NULL
)
""",
"CREATE INDEX idx_expires_at ON search_cache (expires_at)",
"CREATE INDEX idx_last_accessed ON search_cache (last_accessed)",
]
_CACHE_DDL = [
"""
CREATE TABLE cache (
id INTEGER NOT NULL PRIMARY KEY,
cache_key VARCHAR(255) NOT NULL,
cache_value JSON,
cache_text TEXT,
cache_type VARCHAR(50),
source VARCHAR(100),
size_bytes INTEGER,
ttl_seconds INTEGER,
expires_at DATETIME,
hit_count INTEGER,
created_at DATETIME,
accessed_at DATETIME
)
""",
"CREATE UNIQUE INDEX ix_cache_cache_key ON cache (cache_key)",
"CREATE INDEX ix_cache_expires_at ON cache (expires_at)",
"CREATE INDEX idx_type_expires ON cache (cache_type, expires_at)",
"CREATE INDEX idx_source_key ON cache (source, cache_key)",
]
_CACHE_INDEXES = {
"idx_expires_at",
"idx_last_accessed",
"ix_cache_cache_key",
"ix_cache_expires_at",
"idx_type_expires",
"idx_source_key",
}
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _create_legacy_cache_tables(engine, *, populate):
with engine.begin() as conn:
for stmt in _SEARCH_CACHE_DDL + _CACHE_DDL:
conn.execute(text(stmt))
if populate:
conn.execute(
text(
"INSERT INTO search_cache "
"(query_hash, query_text, results, created_at, "
" expires_at, access_count, last_accessed) "
"VALUES ('h1', 'q', '[]', 1, 2, 1, 1)"
)
)
conn.execute(
text(
"INSERT INTO cache (cache_key, cache_text, hit_count) "
"VALUES ('k1', 'v', 0)"
)
)
def _tables(engine):
return set(inspect(engine).get_table_names())
def _index_names(engine):
with engine.begin() as conn:
rows = conn.execute(
text("SELECT name FROM sqlite_master WHERE type = 'index'")
).fetchall()
return {r[0] for r in rows}
@pytest.fixture
def migrated_to_0015_engine(tmp_path):
"""A database upgraded through 0015 (the revision before 0016).
At 0015 the cache/search_cache tables do NOT exist (their models were
removed, so 0001's metadata-based create never produces them); tests that
need the legacy tables add them explicitly.
"""
engine = create_engine(f"sqlite:///{tmp_path / 'test_0016.db'}")
_run_upgrade_to(engine, "0015")
yield engine
engine.dispose()
class TestMigration0016:
def test_drops_both_tables_even_when_populated(
self, migrated_to_0015_engine
):
"""Destructive path: tables present (with rows) at 0015 -> gone at 0016."""
engine = migrated_to_0015_engine
_create_legacy_cache_tables(engine, populate=True)
assert {"cache", "search_cache"} <= _tables(engine)
_run_upgrade_to(engine, "0016")
remaining = _tables(engine)
assert "cache" not in remaining
assert "search_cache" not in remaining
def test_indexes_dropped_with_tables(self, migrated_to_0015_engine):
"""Dropping the tables removes their indexes too (SQLite behavior)."""
engine = migrated_to_0015_engine
_create_legacy_cache_tables(engine, populate=False)
assert _CACHE_INDEXES <= _index_names(engine)
_run_upgrade_to(engine, "0016")
assert not (_CACHE_INDEXES & _index_names(engine))
def test_clean_noop_when_tables_absent(self, migrated_to_0015_engine):
"""Fresh-install / idempotent path: tables never existed -> clean no-op.
This is exactly the state every fresh DB reaches (models removed, so
0001 never creates the tables). The has_table guard must skip the drop
without raising.
"""
engine = migrated_to_0015_engine
assert "cache" not in _tables(engine)
assert "search_cache" not in _tables(engine)
_run_upgrade_to(engine, "0016") # must not raise
remaining = _tables(engine)
assert "cache" not in remaining
assert "search_cache" not in remaining
def test_0016_chains_to_0015(self, migrated_to_0015_engine):
# Head-alignment (which revision is the tip) is asserted in the newest
# migration's test file; here we only guard 0016's own chain link.
from alembic.script import ScriptDirectory
cfg = get_alembic_config(migrated_to_0015_engine)
script = ScriptDirectory.from_config(cfg)
rev = script.get_revision("0016")
assert rev.down_revision == "0015"
class TestMigration0016Encrypted:
"""0016 must drop the tables on a real SQLCipher-encrypted user DB.
Per-user encrypted databases are the production database type. The DDL is
dialect-agnostic, but exercising the drop through the actual SQLCipher
engine and the real DatabaseManager creation path guards against any
encryption/connection-layer surprise. Skips only if a functional SQLCipher
backend is genuinely unavailable.
"""
def test_drops_tables_on_encrypted_user_db(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: tmp_path,
)
manager = DatabaseManager()
if not manager.has_encryption:
pytest.skip("Functional SQLCipher backend not available")
username, password = "encuser", "TestPassword123!"
engine = manager.create_user_database(username, password)
try:
# create_user_database migrated to head; roll the recorded version
# back to 0015 and add the legacy tables to simulate a pre-0016
# encrypted user DB that still carries them.
stamp_database(engine, "0015")
_create_legacy_cache_tables(engine, populate=True)
assert {"cache", "search_cache"} <= _tables(engine)
_run_upgrade_to(engine, "0016")
remaining = _tables(engine)
assert "cache" not in remaining
assert "search_cache" not in remaining
finally:
manager.close_user_database(username)
@@ -0,0 +1,146 @@
"""Tests for migration 0017: add (research_id, status) composite index to download_queue.
Covers:
- The upgrade adds the composite index to download_queue.
- Idempotency: re-running upgrade on a DB that already has the index is a no-op.
- Missing-table guard: upgrade is a no-op when download_queue doesn't exist.
- Downgrade removes the index; table data is preserved.
- Head-alignment: 0017 is the latest revision (the guard lives in the newest
migration's test file; 0016 took it from 0015, 0017 takes it from 0016).
"""
import pytest
from alembic import command
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
stamp_database,
)
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
def _index_names(engine, table):
insp = inspect(engine)
if not insp.has_table(table):
return set()
return {idx["name"] for idx in insp.get_indexes(table) if idx["name"]}
@pytest.fixture
def legacy_engine(tmp_path):
"""A DB whose download_queue table lacks the composite index.
Simulates an installation created before 0017. Stamped at 0016 so 0017's
upgrade is a clean delta.
"""
db_path = tmp_path / "legacy_pre_0017.db"
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE download_queue ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"research_id INTEGER NOT NULL, "
"status VARCHAR(50) NOT NULL DEFAULT 'pending', "
"url TEXT NOT NULL, "
"created_at TEXT NOT NULL"
")"
)
)
conn.execute(
text(
"CREATE INDEX idx_download_queue_research_id "
"ON download_queue (research_id)"
)
)
conn.execute(
text(
"INSERT INTO download_queue "
"(research_id, status, url, created_at) "
"VALUES (1, 'pending', 'https://example.com', '2026-01-01 00:00:00')"
)
)
stamp_database(engine, "0016")
yield engine
engine.dispose()
class TestMigration0017Upgrade:
def test_adds_composite_index(self, legacy_engine):
engine = legacy_engine
assert "idx_download_queue_research_status" not in _index_names(
engine, "download_queue"
)
_run_upgrade_to(engine, "0017")
assert "idx_download_queue_research_status" in _index_names(
engine, "download_queue"
)
def test_preserves_row_data(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0017")
with engine.begin() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM download_queue")
).scalar()
assert count == 1
def test_upgrade_is_idempotent(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0017")
_run_upgrade_to(engine, "0017") # second run is a no-op, no error
assert "idx_download_queue_research_status" in _index_names(
engine, "download_queue"
)
def test_missing_table_does_not_crash(self, tmp_path):
"""Upgrade is a no-op when download_queue doesn't exist yet."""
engine = create_engine(f"sqlite:///{tmp_path}/no_queue.db")
stamp_database(engine, "0016")
_run_upgrade_to(engine, "0017") # must not raise
assert not inspect(engine).has_table("download_queue")
engine.dispose()
class TestMigration0017Downgrade:
def test_downgrade_removes_index(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0017")
assert "idx_download_queue_research_status" in _index_names(
engine, "download_queue"
)
_run_downgrade_to(engine, "0016")
assert "idx_download_queue_research_status" not in _index_names(
engine, "download_queue"
)
def test_downgrade_preserves_row_data(self, legacy_engine):
engine = legacy_engine
_run_upgrade_to(engine, "0017")
_run_downgrade_to(engine, "0016")
with engine.begin() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM download_queue")
).scalar()
assert count == 1
# Head-alignment guard moved to test_migration_0018_remove_mcp_strategy.py:
# 0017 is no longer the latest revision (0018 chains after it). The guard
# always lives in the newest migration's test file.
@@ -0,0 +1,374 @@
"""Tests for migration 0018: remove the 'mcp'/'agentic' search strategy.
Pins the upgrade semantics:
- ``search.search_strategy`` rows naming a removed strategy (mcp / agentic)
are rewritten to ``langgraph-agent``; concrete choices are preserved.
- The orphaned ``mcp.servers`` setting row is deleted.
- ``news_subscriptions.search_strategy`` is NULLed for removed strategies
(falsy means "use the user's default strategy" in the scheduler).
- ``queued_researches.settings_snapshot`` JSON has both the top-level
``submission.strategy`` and the embedded ``search.search_strategy``
rewritten, for the nested and the legacy flat snapshot structure.
- ``benchmark_runs`` / ``benchmark_configs`` ``search_config.search_strategy``
is rewritten.
- The settings ``value`` column stores JSON text (``"mcp"`` with quotes); the
rewritten value must be stored as ``"langgraph-agent"`` (single JSON
encoding) — a raw-bytes assertion guards against double-encoding.
- Idempotency: re-running the upgrade is a no-op.
"""
import json
import pytest
from alembic import command
from sqlalchemy import create_engine, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
)
REMOVED = ["mcp", "agentic"]
REPLACEMENT = "langgraph-agent"
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _seed_setting(engine, key, value):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO settings "
"(key, value, type, name, ui_element, visible, editable) "
"VALUES (:key, :value, 'search', :name, 'select', 1, 1)"
),
{"key": key, "value": json.dumps(value), "name": key},
)
def _read_setting(engine, key):
with engine.begin() as conn:
row = conn.execute(
text("SELECT value FROM settings WHERE key = :key"),
{"key": key},
).fetchone()
if row is None:
return None
raw = row[0]
return json.loads(raw) if isinstance(raw, str) else raw
def _read_setting_raw(engine, key):
"""Return the stored bytes of ``settings.value`` without decoding."""
with engine.begin() as conn:
row = conn.execute(
text("SELECT value FROM settings WHERE key = :key"),
{"key": key},
).fetchone()
return None if row is None else row[0]
@pytest.fixture
def migrated_to_0017_engine(tmp_path):
"""Database fully migrated through 0017 (the revision before 0018)."""
db_path = tmp_path / "test_0018.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0017")
yield engine
engine.dispose()
class TestMigration0018Settings:
@pytest.mark.parametrize("removed_value", REMOVED)
def test_strategy_rewritten_to_langgraph_agent(
self, migrated_to_0017_engine, removed_value
):
engine = migrated_to_0017_engine
_seed_setting(engine, "search.search_strategy", removed_value)
_run_upgrade_to(engine, "0018")
assert _read_setting(engine, "search.search_strategy") == REPLACEMENT
@pytest.mark.parametrize("removed_value", REMOVED)
def test_rewritten_value_stored_single_encoded(
self, migrated_to_0017_engine, removed_value
):
"""The stored bytes must be ``"langgraph-agent"`` (single JSON
encoding), not a double-encoded ``"\\"langgraph-agent\\""``."""
engine = migrated_to_0017_engine
_seed_setting(engine, "search.search_strategy", removed_value)
_run_upgrade_to(engine, "0018")
assert _read_setting_raw(
engine, "search.search_strategy"
) == json.dumps(REPLACEMENT)
@pytest.mark.parametrize(
"concrete", ["source-based", "focused-iteration", "topic-organization"]
)
def test_concrete_strategy_preserved(
self, migrated_to_0017_engine, concrete
):
engine = migrated_to_0017_engine
_seed_setting(engine, "search.search_strategy", concrete)
_run_upgrade_to(engine, "0018")
assert _read_setting(engine, "search.search_strategy") == concrete
def test_mcp_servers_setting_deleted(self, migrated_to_0017_engine):
engine = migrated_to_0017_engine
_seed_setting(engine, "mcp.servers", [{"command": "npx"}])
_run_upgrade_to(engine, "0018")
assert _read_setting(engine, "mcp.servers") is None
def test_upgrade_is_idempotent(self, migrated_to_0017_engine):
engine = migrated_to_0017_engine
_seed_setting(engine, "search.search_strategy", "mcp")
_run_upgrade_to(engine, "0018")
_run_upgrade_to(engine, "0018")
assert _read_setting(engine, "search.search_strategy") == REPLACEMENT
def test_missing_rows_are_a_clean_noop(self, migrated_to_0017_engine):
engine = migrated_to_0017_engine
_run_upgrade_to(engine, "0018")
assert _read_setting(engine, "search.search_strategy") is None
class TestMigration0018NewsSubscriptions:
def _seed_subscription(self, engine, sub_id, search_strategy):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO news_subscriptions "
"(id, subscription_type, query_or_topic, search_strategy) "
"VALUES (:id, 'search', 'test query', :ss)"
),
{"id": sub_id, "ss": search_strategy},
)
def _read_subscription_strategy(self, engine, sub_id):
with engine.begin() as conn:
return conn.execute(
text(
"SELECT search_strategy FROM news_subscriptions "
"WHERE id = :id"
),
{"id": sub_id},
).fetchone()[0]
def test_removed_strategy_nulled_concrete_preserved(
self, migrated_to_0017_engine
):
engine = migrated_to_0017_engine
self._seed_subscription(engine, "sub-mcp", "mcp")
self._seed_subscription(engine, "sub-agentic", "agentic")
self._seed_subscription(engine, "sub-lg", "langgraph-agent")
self._seed_subscription(engine, "sub-null", None)
_run_upgrade_to(engine, "0018")
assert self._read_subscription_strategy(engine, "sub-mcp") is None
assert self._read_subscription_strategy(engine, "sub-agentic") is None
assert (
self._read_subscription_strategy(engine, "sub-lg")
== "langgraph-agent"
)
assert self._read_subscription_strategy(engine, "sub-null") is None
class TestMigration0018QueuedResearches:
def _seed_queued(self, engine, research_id, snapshot):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO queued_researches "
"(username, research_id, query, mode, settings_snapshot, "
"position) "
"VALUES ('tester', :rid, 'q', 'quick', :snap, 1)"
),
{"rid": research_id, "snap": json.dumps(snapshot)},
)
def _read_snapshot(self, engine, research_id):
with engine.begin() as conn:
raw = conn.execute(
text(
"SELECT settings_snapshot FROM queued_researches "
"WHERE research_id = :rid"
),
{"rid": research_id},
).fetchone()[0]
return json.loads(raw) if isinstance(raw, str) else raw
def test_nested_submission_strategy_rewritten(
self, migrated_to_0017_engine
):
engine = migrated_to_0017_engine
self._seed_queued(
engine,
"rid-nested",
{
"submission": {"strategy": "mcp", "model": "m"},
"settings_snapshot": {
"search.search_strategy": {
"value": "agentic",
"type": "SEARCH",
}
},
},
)
_run_upgrade_to(engine, "0018")
snap = self._read_snapshot(engine, "rid-nested")
assert snap["submission"]["strategy"] == "langgraph-agent"
assert (
snap["settings_snapshot"]["search.search_strategy"]["value"]
== "langgraph-agent"
)
def test_legacy_flat_snapshot_rewritten(self, migrated_to_0017_engine):
engine = migrated_to_0017_engine
self._seed_queued(
engine, "rid-flat", {"strategy": "agentic", "model": "m"}
)
_run_upgrade_to(engine, "0018")
snap = self._read_snapshot(engine, "rid-flat")
assert snap["strategy"] == "langgraph-agent"
def test_concrete_strategy_snapshot_untouched(
self, migrated_to_0017_engine
):
engine = migrated_to_0017_engine
original = {
"submission": {"strategy": "source-based"},
"settings_snapshot": {"search.search_strategy": "source-based"},
}
self._seed_queued(engine, "rid-src", original)
_run_upgrade_to(engine, "0018")
assert self._read_snapshot(engine, "rid-src") == original
class TestMigration0018Benchmarks:
def _seed_config(self, engine, name, search_strategy):
search_config = json.dumps(
{"search_strategy": search_strategy, "iterations": 2}
)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO benchmark_configs "
"(name, config_hash, search_config, evaluation_config, "
"datasets_config, created_at, updated_at, is_default, "
"is_public, usage_count) "
"VALUES (:name, 'abcd1234', :sc, '{}', '{}', "
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0, 0, 0)"
),
{"name": name, "sc": search_config},
)
def _read_config_strategy(self, engine, name):
with engine.begin() as conn:
raw = conn.execute(
text(
"SELECT search_config FROM benchmark_configs "
"WHERE name = :name"
),
{"name": name},
).fetchone()[0]
config = json.loads(raw) if isinstance(raw, str) else raw
return config["search_strategy"]
def test_benchmark_config_strategy_rewritten(self, migrated_to_0017_engine):
engine = migrated_to_0017_engine
self._seed_config(engine, "cfg-mcp", "mcp")
self._seed_config(engine, "cfg-src", "source-based")
_run_upgrade_to(engine, "0018")
assert (
self._read_config_strategy(engine, "cfg-mcp") == "langgraph-agent"
)
assert self._read_config_strategy(engine, "cfg-src") == "source-based"
def _seed_run(self, engine, run_name, search_strategy):
search_config = json.dumps(
{"search_strategy": search_strategy, "iterations": 2}
)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO benchmark_runs "
"(run_name, config_hash, query_hash_list, search_config, "
"evaluation_config, datasets_config, status, created_at, "
"updated_at, total_examples, completed_examples, "
"failed_examples) "
"VALUES (:name, 'h0', '[]', :sc, '{}', '{}', 'PENDING', "
"'2026-01-01 00:00:00', '2026-01-01 00:00:00', 0, 0, 0)"
),
{"name": run_name, "sc": search_config},
)
def _read_run_strategy(self, engine, run_name):
with engine.begin() as conn:
raw = conn.execute(
text(
"SELECT search_config FROM benchmark_runs "
"WHERE run_name = :name"
),
{"name": run_name},
).fetchone()[0]
config = json.loads(raw) if isinstance(raw, str) else raw
return config["search_strategy"]
def test_benchmark_run_strategy_rewritten(self, migrated_to_0017_engine):
# upgrade() rewrites search_config in BOTH benchmark_runs and
# benchmark_configs; this pins the benchmark_runs branch (the
# benchmark_configs path is covered above), guarding against a
# regression that breaks only one table binding.
engine = migrated_to_0017_engine
self._seed_run(engine, "run-mcp", "mcp")
self._seed_run(engine, "run-src", "source-based")
_run_upgrade_to(engine, "0018")
assert self._read_run_strategy(engine, "run-mcp") == "langgraph-agent"
assert self._read_run_strategy(engine, "run-src") == "source-based"
class TestMigration0018HeadAlignment:
def test_0018_chains_correctly_to_0017(self):
from alembic.config import Config
from alembic.script import ScriptDirectory
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
config = Config()
config.set_main_option("script_location", str(get_migrations_dir()))
script = ScriptDirectory.from_config(config)
rev_0018 = script.get_revision("0018")
assert rev_0018.down_revision == "0017"
# NOTE: the head-alignment guard (assert head == latest) always lives in
# the newest migration's test file — currently
# test_migration_0020_add_zotero_tables.py. 0018 is no longer the head,
# so asserting it here would break every future migration.
@@ -0,0 +1,150 @@
"""Tests for migration 0019: retire the 'both' egress scope.
Pins the upgrade semantics:
- ``policy.egress_scope`` rows holding ``both`` are rewritten to ``adaptive``;
every other (concrete) scope is preserved untouched.
- The settings ``value`` column stores JSON text (``"both"`` with quotes); the
rewritten value must be stored as ``"adaptive"`` (single JSON encoding) — a
raw-bytes assertion guards against double-encoding (see migration 0018).
- Idempotency: re-running the upgrade is a no-op.
- A DB with no matching row upgrades cleanly (rowcount 0, no error).
"""
import json
import pytest
from alembic import command
from sqlalchemy import create_engine, text
from local_deep_research.database.alembic_runner import get_alembic_config
EGRESS_SCOPE_KEY = "policy.egress_scope"
def _run_upgrade_to(engine, revision):
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _seed_setting(engine, key, value):
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO settings "
"(key, value, type, name, ui_element, visible, editable) "
"VALUES (:key, :value, 'app', :name, 'select', 1, 1)"
),
{"key": key, "value": json.dumps(value), "name": key},
)
def _read_setting(engine, key):
with engine.begin() as conn:
row = conn.execute(
text("SELECT value FROM settings WHERE key = :key"),
{"key": key},
).fetchone()
if row is None:
return None
raw = row[0]
return json.loads(raw) if isinstance(raw, str) else raw
def _read_setting_raw(engine, key):
"""Return the stored bytes of ``settings.value`` without decoding."""
with engine.begin() as conn:
row = conn.execute(
text("SELECT value FROM settings WHERE key = :key"),
{"key": key},
).fetchone()
return None if row is None else row[0]
@pytest.fixture
def migrated_to_0018_engine(tmp_path):
"""Database fully migrated through 0018 (the revision before 0019)."""
db_path = tmp_path / "test_0019.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0018")
yield engine
engine.dispose()
class TestMigration0019RetireBoth:
def test_both_rewritten_to_adaptive(self, migrated_to_0018_engine):
engine = migrated_to_0018_engine
_seed_setting(engine, EGRESS_SCOPE_KEY, "both")
_run_upgrade_to(engine, "0019")
assert _read_setting(engine, EGRESS_SCOPE_KEY) == "adaptive"
def test_rewritten_value_stored_single_encoded(
self, migrated_to_0018_engine
):
"""The stored bytes must be ``"adaptive"`` (single JSON encoding), not a
double-encoded ``"\\"adaptive\\""``. This is what catches a WHERE-clause
desync from the on-disk JSON form."""
engine = migrated_to_0018_engine
_seed_setting(engine, EGRESS_SCOPE_KEY, "both")
_run_upgrade_to(engine, "0019")
assert _read_setting_raw(engine, EGRESS_SCOPE_KEY) == json.dumps(
"adaptive"
)
@pytest.mark.parametrize(
"concrete",
["adaptive", "public_only", "private_only", "strict", "unprotected"],
)
def test_concrete_scope_preserved(self, migrated_to_0018_engine, concrete):
engine = migrated_to_0018_engine
_seed_setting(engine, EGRESS_SCOPE_KEY, concrete)
_run_upgrade_to(engine, "0019")
assert _read_setting(engine, EGRESS_SCOPE_KEY) == concrete
def test_upgrade_is_idempotent(self, migrated_to_0018_engine):
engine = migrated_to_0018_engine
_seed_setting(engine, EGRESS_SCOPE_KEY, "both")
_run_upgrade_to(engine, "0019")
_run_upgrade_to(engine, "0019")
assert _read_setting(engine, EGRESS_SCOPE_KEY) == "adaptive"
def test_missing_row_is_a_clean_noop(self, migrated_to_0018_engine):
engine = migrated_to_0018_engine
_run_upgrade_to(engine, "0019")
assert _read_setting(engine, EGRESS_SCOPE_KEY) is None
class TestMigration0019HeadAlignment:
"""Head-alignment guard. By convention this lives in the newest
migration's test file (moved here from the 0018 test file when 0019 was
added). Catches a new migration landing without the chain being wired up."""
def test_0019_chains_correctly_to_0018(self):
from alembic.config import Config
from alembic.script import ScriptDirectory
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
config = Config()
config.set_main_option("script_location", str(get_migrations_dir()))
script = ScriptDirectory.from_config(config)
rev_0019 = script.get_revision("0019")
assert rev_0019.down_revision == "0018"
# NOTE: the head-alignment guard (assert head == latest) moved to the
# newest migration's test file — test_migration_0020_add_zotero_tables.py.
# 0019 is no longer the head, so asserting it here would break every
# future migration.
@@ -0,0 +1,163 @@
"""Tests for migration 0020: add the Zotero integration tables.
Verifies the migration chains correctly after 0019 and is the current head,
and that the upgrade creates the two Zotero tables. Also holds the
head-alignment guard (0020 is the newest migration); when a later migration
is added, move this guard to that migration's test file.
"""
import pytest
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.exc import IntegrityError
from local_deep_research.database.alembic_runner import (
get_alembic_config,
get_head_revision,
get_migrations_dir,
run_migrations,
)
def test_0020_chains_after_0019():
config = Config()
config.set_main_option("script_location", str(get_migrations_dir()))
script = ScriptDirectory.from_config(config)
rev = script.get_revision("0020")
assert rev.down_revision == "0019"
def test_head_revision_is_0020():
# 0020 (add zotero tables) is the newest migration. If you add a later
# migration, move this guard to its test file.
assert get_head_revision() == "0020"
def test_upgrade_creates_zotero_tables(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'test_0020.db'}")
run_migrations(engine) # up to head (includes 0020)
tables = set(inspect(engine).get_table_names())
assert "zotero_sync_state" in tables
assert "zotero_item_map" in tables
engine.dispose()
def _migrated_engine(tmp_path, name="test_0020.db"):
engine = create_engine(f"sqlite:///{tmp_path / name}")
run_migrations(engine)
return engine
def _run_alembic(engine, direction, target):
"""Run an alembic upgrade/downgrade, wiring the connection into the config
the way alembic_runner.run_migrations does (this project's env.py requires
config.attributes['connection'])."""
config = get_alembic_config(engine)
with engine.connect() as conn:
with conn.begin():
config.attributes["connection"] = conn
getattr(command, direction)(config, target)
def test_upgrade_creates_unique_constraints(tmp_path):
# Production per-user DBs are built PURELY from the Alembic chain (not
# create_all), and the sync service relies on these UNIQUE constraints as
# its race backstop. Assert the migration actually shipped them.
engine = _migrated_engine(tmp_path)
insp = inspect(engine)
item_uqs = insp.get_unique_constraints("zotero_item_map")
assert any(
set(uq["column_names"]) == {"ldr_collection_id", "zotero_item_key"}
for uq in item_uqs
), item_uqs
state_uqs = insp.get_unique_constraints("zotero_sync_state")
assert any(
set(uq["column_names"])
== {"library_type", "library_id", "collection_key"}
for uq in state_uqs
), state_uqs
engine.dispose()
def test_upgrade_creates_expected_indexes(tmp_path):
engine = _migrated_engine(tmp_path)
insp = inspect(engine)
item_indexes = {ix["name"] for ix in insp.get_indexes("zotero_item_map")}
state_indexes = {ix["name"] for ix in insp.get_indexes("zotero_sync_state")}
assert "ix_zotero_item_map_ldr_collection_id" in item_indexes
assert "ix_zotero_item_map_zotero_item_key" in item_indexes
assert "ix_zotero_item_map_document_id" in item_indexes
assert "ix_zotero_sync_state_ldr_collection_id" in state_indexes
engine.dispose()
def test_upgrade_foreign_keys(tmp_path):
engine = _migrated_engine(tmp_path)
insp = inspect(engine)
fks = {
fk["constrained_columns"][0]: fk
for fk in insp.get_foreign_keys("zotero_item_map")
}
assert fks["ldr_collection_id"]["referred_table"] == "collections"
assert fks["document_id"]["referred_table"] == "documents"
# ondelete: CASCADE for the owning collection, SET NULL for the document
# (the sync service treats a null document as "needs re-import").
assert (
fks["ldr_collection_id"].get("options", {}).get("ondelete", "").upper()
== "CASCADE"
)
assert (
fks["document_id"].get("options", {}).get("ondelete", "").upper()
== "SET NULL"
)
engine.dispose()
def test_item_map_unique_constraint_is_enforced(tmp_path):
# Behavioral proof (against the migration-built schema) that a duplicate
# (ldr_collection_id, zotero_item_key) is rejected.
engine = _migrated_engine(tmp_path)
ts = "2020-01-01 00:00:00"
insert = text(
"INSERT INTO zotero_item_map "
"(ldr_collection_id, zotero_item_key, zotero_version, has_pdf, "
"created_at, updated_at) "
"VALUES ('c1', 'K1', :v, 0, :ts, :ts)"
)
with engine.begin() as conn:
conn.execute(insert, {"v": 1, "ts": ts})
with pytest.raises(IntegrityError):
with engine.begin() as conn:
conn.execute(insert, {"v": 2, "ts": ts})
engine.dispose()
def test_downgrade_then_upgrade_roundtrip(tmp_path):
engine = _migrated_engine(tmp_path)
# Downgrade removes both tables...
_run_alembic(engine, "downgrade", "0019")
tables = set(inspect(engine).get_table_names())
assert "zotero_sync_state" not in tables
assert "zotero_item_map" not in tables
# ...and re-upgrading recreates them (exercises the has_table guards).
_run_alembic(engine, "upgrade", "0020")
tables = set(inspect(engine).get_table_names())
assert "zotero_sync_state" in tables
assert "zotero_item_map" in tables
engine.dispose()
def test_upgrade_is_idempotent(tmp_path):
# Running the migration runner a second time must not error.
engine = _migrated_engine(tmp_path)
run_migrations(engine)
tables = set(inspect(engine).get_table_names())
assert "zotero_sync_state" in tables
assert "zotero_item_map" in tables
engine.dispose()
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Test that all models are properly consolidated in the database.models package."""
import sys
from pathlib import Path
sys.path.insert(
0,
str(Path(__file__).parent.parent.parent.resolve()),
)
def test_all_models_importable():
"""Test that all models can be imported from the consolidated location."""
# This should not raise any ImportError
from local_deep_research.database.models import (
Base,
BenchmarkRun,
# Benchmark
ResearchHistory,
User,
)
# If we get here, all imports worked
assert Base is not None
assert User is not None
assert BenchmarkRun is not None
assert ResearchHistory is not None
print("✓ All models successfully imported from consolidated location")
def test_benchmark_models_relationships():
"""Test that benchmark model relationships are properly defined."""
from local_deep_research.database.models import (
BenchmarkProgress,
BenchmarkResult,
BenchmarkRun,
)
# Check that relationships are defined
assert hasattr(BenchmarkRun, "results")
assert hasattr(BenchmarkRun, "progress_updates")
assert hasattr(BenchmarkResult, "benchmark_run")
assert hasattr(BenchmarkProgress, "benchmark_run")
print("✓ Benchmark model relationships properly defined")
def test_research_models_have_correct_columns():
"""Test that research models have the expected columns after consolidation."""
from local_deep_research.database.models import (
ResearchHistory,
ResearchResource,
)
# Check ResearchHistory has renamed metadata column
assert hasattr(ResearchHistory, "research_meta")
assert hasattr(ResearchHistory, "query")
assert hasattr(ResearchHistory, "status")
# Check ResearchResource has renamed metadata column
assert hasattr(ResearchResource, "resource_metadata")
assert hasattr(ResearchResource, "title")
assert hasattr(ResearchResource, "url")
print("✓ Research models have correct column names")
if __name__ == "__main__":
test_all_models_importable()
test_benchmark_models_relationships()
test_research_models_have_correct_columns()
print("\n✅ All model consolidation tests passed!")
@@ -0,0 +1,442 @@
"""
Extended tests for model consolidation - Comprehensive model architecture validation.
Tests cover:
- All model imports from consolidated location
- Model relationships and foreign keys
- Column definitions and types
- Model constraints and indexes
- Enum definitions
- Cross-model consistency
"""
from sqlalchemy import inspect
class TestModelImports:
"""Tests for model imports from consolidated location."""
def test_benchmark_models_importable(self):
"""Benchmark models should be importable."""
from local_deep_research.database.models import (
BenchmarkRun,
BenchmarkResult,
BenchmarkProgress,
)
assert BenchmarkRun is not None
assert BenchmarkResult is not None
assert BenchmarkProgress is not None
def test_library_models_importable(self):
"""Library models should be importable."""
from local_deep_research.database.models import (
Document,
Collection,
DocumentChunk,
)
assert Document is not None
assert Collection is not None
assert DocumentChunk is not None
class TestModelRelationships:
"""Tests for model relationships."""
def test_benchmark_run_has_results_relationship(self):
"""BenchmarkRun should have results relationship."""
from local_deep_research.database.models import BenchmarkRun
assert hasattr(BenchmarkRun, "results")
def test_benchmark_run_has_progress_relationship(self):
"""BenchmarkRun should have progress_updates relationship."""
from local_deep_research.database.models import BenchmarkRun
assert hasattr(BenchmarkRun, "progress_updates")
def test_benchmark_result_has_run_relationship(self):
"""BenchmarkResult should have benchmark_run relationship."""
from local_deep_research.database.models import BenchmarkResult
assert hasattr(BenchmarkResult, "benchmark_run")
def test_document_has_collections_relationship(self):
"""Document should have collections relationship."""
from local_deep_research.database.models import Document
assert hasattr(Document, "collections")
def test_collection_has_documents_relationship(self):
"""Collection should have document_links relationship."""
from local_deep_research.database.models import Collection
assert hasattr(Collection, "document_links")
class TestColumnDefinitions:
"""Tests for model column definitions."""
def test_research_history_has_query_column(self):
"""ResearchHistory should have query column."""
from local_deep_research.database.models import ResearchHistory
assert hasattr(ResearchHistory, "query")
def test_research_history_has_status_column(self):
"""ResearchHistory should have status column."""
from local_deep_research.database.models import ResearchHistory
assert hasattr(ResearchHistory, "status")
def test_research_history_has_research_meta_column(self):
"""ResearchHistory should have research_meta (renamed from metadata)."""
from local_deep_research.database.models import ResearchHistory
assert hasattr(ResearchHistory, "research_meta")
def test_research_resource_has_title_column(self):
"""ResearchResource should have title column."""
from local_deep_research.database.models import ResearchResource
assert hasattr(ResearchResource, "title")
def test_research_resource_has_url_column(self):
"""ResearchResource should have url column."""
from local_deep_research.database.models import ResearchResource
assert hasattr(ResearchResource, "url")
def test_research_resource_has_resource_metadata_column(self):
"""ResearchResource should have resource_metadata (renamed from metadata)."""
from local_deep_research.database.models import ResearchResource
assert hasattr(ResearchResource, "resource_metadata")
def test_document_has_required_columns(self):
"""Document should have all required columns."""
from local_deep_research.database.models import Document
required_columns = [
"id",
"document_hash",
"file_size",
"file_type",
"status",
"created_at",
]
for col in required_columns:
assert hasattr(Document, col), f"Document missing column: {col}"
def test_collection_has_required_columns(self):
"""Collection should have all required columns."""
from local_deep_research.database.models import Collection
required_columns = ["id", "name", "is_default", "created_at"]
for col in required_columns:
assert hasattr(Collection, col), f"Collection missing column: {col}"
class TestEnumDefinitions:
"""Tests for enum definitions."""
def test_document_status_has_expected_values(self):
"""DocumentStatus should have expected values."""
from local_deep_research.database.models.library import DocumentStatus
assert DocumentStatus.PENDING.value == "pending"
assert DocumentStatus.PROCESSING.value == "processing"
assert DocumentStatus.COMPLETED.value == "completed"
assert DocumentStatus.FAILED.value == "failed"
def test_embedding_provider_enum_exists(self):
"""EmbeddingProvider enum should exist."""
from local_deep_research.database.models.library import (
EmbeddingProvider,
)
assert EmbeddingProvider is not None
assert (
EmbeddingProvider.SENTENCE_TRANSFORMERS.value
== "sentence_transformers"
)
assert EmbeddingProvider.OLLAMA.value == "ollama"
# Issue #3883 — OPENAI covers the cloud API and any
# OpenAI-compatible endpoint (LM Studio, vLLM, llama.cpp).
assert EmbeddingProvider.OPENAI.value == "openai"
def test_embedding_provider_openai_constructible_from_string(self):
"""EmbeddingProvider("openai") should resolve to the OPENAI member.
The library RAG service stores the provider as a string and
later persists it via ``EmbeddingProvider(value)``; the
round-trip is the column-write path that issue #3883's
acceptance criteria #1 calls out.
"""
from local_deep_research.database.models.library import (
EmbeddingProvider,
)
assert EmbeddingProvider("openai") is EmbeddingProvider.OPENAI
class TestTableNames:
"""Tests for correct table names."""
def test_document_table_name(self):
"""Document should have correct table name."""
from local_deep_research.database.models import Document
assert Document.__tablename__ == "documents"
def test_collection_table_name(self):
"""Collection should have correct table name."""
from local_deep_research.database.models import Collection
assert Collection.__tablename__ == "collections"
def test_document_chunk_table_name(self):
"""DocumentChunk should have correct table name."""
from local_deep_research.database.models import DocumentChunk
assert DocumentChunk.__tablename__ == "document_chunks"
def test_rag_index_table_name(self):
"""RAGIndex should have correct table name."""
from local_deep_research.database.models import RAGIndex
assert RAGIndex.__tablename__ == "rag_indices"
class TestModelConstraints:
"""Tests for model constraints."""
def test_document_has_unique_hash_constraint(self):
"""Document should have unique document_hash constraint."""
from local_deep_research.database.models import Document
mapper = inspect(Document)
columns = {c.name: c for c in mapper.columns}
assert columns["document_hash"].unique is True
def test_collection_document_has_unique_constraint(self):
"""DocumentCollection should have unique document-collection pair."""
from local_deep_research.database.models import DocumentCollection
# Check table args for unique constraint
table_args = DocumentCollection.__table_args__
has_unique = any(
hasattr(arg, "name") and "uix_document_collection" in str(arg.name)
for arg in table_args
if hasattr(arg, "name")
)
assert has_unique
class TestIndexDefinitions:
"""Tests for index definitions."""
def test_document_has_source_type_index(self):
"""Document should have source_type index."""
from local_deep_research.database.models import Document
table_args = Document.__table_args__
has_index = any(
hasattr(arg, "name") and "idx_source_type" in str(arg.name)
for arg in table_args
if hasattr(arg, "name")
)
assert has_index
def test_document_chunk_has_collection_index(self):
"""DocumentChunk should have collection index."""
from local_deep_research.database.models import DocumentChunk
table_args = DocumentChunk.__table_args__
has_index = any(
hasattr(arg, "name") and "idx_chunk_collection" in str(arg.name)
for arg in table_args
if hasattr(arg, "name")
)
assert has_index
class TestCrossModelConsistency:
"""Tests for cross-model consistency."""
def test_document_references_source_type(self):
"""Document.source_type_id should reference source_types."""
from local_deep_research.database.models import Document
mapper = inspect(Document)
columns = {c.name: c for c in mapper.columns}
fk = list(columns["source_type_id"].foreign_keys)[0]
assert "source_types" in str(fk.target_fullname)
def test_document_collection_references_both(self):
"""DocumentCollection should reference both Document and Collection."""
from local_deep_research.database.models import DocumentCollection
mapper = inspect(DocumentCollection)
columns = {c.name: c for c in mapper.columns}
doc_fk = list(columns["document_id"].foreign_keys)[0]
coll_fk = list(columns["collection_id"].foreign_keys)[0]
assert "documents" in str(doc_fk.target_fullname)
assert "collections" in str(coll_fk.target_fullname)
class TestModelRepr:
"""Tests for model __repr__ methods."""
def test_document_repr_not_error(self):
"""Document __repr__ should not raise errors."""
from local_deep_research.database.models import Document
doc = Document()
doc.id = "test-id"
doc.title = "Test Document"
doc.file_type = "pdf"
doc.file_size = 1024
# Should not raise
repr_str = repr(doc)
assert "Document" in repr_str
def test_collection_repr_not_error(self):
"""Collection __repr__ should not raise errors."""
from local_deep_research.database.models import Collection
coll = Collection()
coll.id = "test-id"
coll.name = "Test Collection"
coll.collection_type = "user_collection"
repr_str = repr(coll)
assert "Collection" in repr_str
class TestModelDefaults:
"""Tests for model default values."""
def test_document_status_default(self):
"""Document status should default to COMPLETED."""
from local_deep_research.database.models import Document
mapper = inspect(Document)
columns = {c.name: c for c in mapper.columns}
default = columns["status"].default
assert default is not None
def test_collection_is_default_defaults_to_false(self):
"""Collection.is_default should default to False."""
from local_deep_research.database.models import Collection
mapper = inspect(Collection)
columns = {c.name: c for c in mapper.columns}
default = columns["is_default"].default
assert default is not None
assert default.arg is False
class TestNullableColumns:
"""Tests for nullable column settings."""
def test_document_id_not_nullable(self):
"""Document.id should not be nullable."""
from local_deep_research.database.models import Document
mapper = inspect(Document)
columns = {c.name: c for c in mapper.columns}
assert columns["id"].nullable is False
def test_document_hash_not_nullable(self):
"""Document.document_hash should not be nullable."""
from local_deep_research.database.models import Document
mapper = inspect(Document)
columns = {c.name: c for c in mapper.columns}
assert columns["document_hash"].nullable is False
def test_document_original_url_nullable(self):
"""Document.original_url should be nullable (for uploads)."""
from local_deep_research.database.models import Document
mapper = inspect(Document)
columns = {c.name: c for c in mapper.columns}
assert columns["original_url"].nullable is True
class TestExtractionEnums:
"""Tests for extraction-related enums."""
def test_extraction_method_enum(self):
"""ExtractionMethod enum should have expected values."""
from local_deep_research.database.models.library import ExtractionMethod
assert ExtractionMethod.PDF_EXTRACTION.value == "pdf_extraction"
assert ExtractionMethod.NATIVE_API.value == "native_api"
assert ExtractionMethod.UNKNOWN.value == "unknown"
def test_extraction_source_enum(self):
"""ExtractionSource enum should have expected values."""
from local_deep_research.database.models.library import ExtractionSource
assert ExtractionSource.ARXIV_API.value == "arxiv_api"
assert ExtractionSource.PUBMED_API.value == "pubmed_api"
assert ExtractionSource.PDFPLUMBER.value == "pdfplumber"
def test_extraction_quality_enum(self):
"""ExtractionQuality enum should have expected values."""
from local_deep_research.database.models.library import (
ExtractionQuality,
)
assert ExtractionQuality.HIGH.value == "high"
assert ExtractionQuality.MEDIUM.value == "medium"
assert ExtractionQuality.LOW.value == "low"
class TestRAGEnums:
"""Tests for RAG-related enums."""
def test_distance_metric_enum(self):
"""DistanceMetric enum should have expected values."""
from local_deep_research.database.models.library import DistanceMetric
assert DistanceMetric.COSINE.value == "cosine"
assert DistanceMetric.L2.value == "l2"
assert DistanceMetric.DOT_PRODUCT.value == "dot_product"
def test_index_type_enum(self):
"""IndexType enum should have expected values."""
from local_deep_research.database.models.library import IndexType
assert IndexType.FLAT.value == "flat"
assert IndexType.HNSW.value == "hnsw"
assert IndexType.IVF.value == "ivf"
def test_splitter_type_enum(self):
"""SplitterType enum should have expected values."""
from local_deep_research.database.models.library import SplitterType
assert SplitterType.RECURSIVE.value == "recursive"
assert SplitterType.SEMANTIC.value == "semantic"
assert SplitterType.TOKEN.value == "token"
assert SplitterType.SENTENCE.value == "sentence"
class TestPDFStorageMode:
"""Tests for PDF storage mode enum."""
def test_pdf_storage_mode_enum(self):
"""PDFStorageMode enum should have expected values."""
from local_deep_research.database.models.library import PDFStorageMode
assert PDFStorageMode.NONE.value == "none"
assert PDFStorageMode.FILESYSTEM.value == "filesystem"
assert PDFStorageMode.DATABASE.value == "database"
+168
View File
@@ -0,0 +1,168 @@
"""Test multi-user encrypted database functionality."""
import shutil
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.database.encrypted_db import DatabaseManager
from local_deep_research.database.models import (
ResearchHistory,
)
from local_deep_research.database.models.auth import User
class TestMultiUserDatabase:
"""Test suite for multi-user encrypted database functionality."""
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for test databases."""
temp_dir = tempfile.mkdtemp()
yield temp_dir
shutil.rmtree(temp_dir)
@pytest.fixture
def db_manager(self, temp_dir):
"""Create a database manager with a custom data directory."""
# Mock the data directory to use our temp directory
with patch(
"local_deep_research.database.encrypted_db.get_data_directory"
) as mock_get_dir:
mock_get_dir.return_value = Path(temp_dir)
manager = DatabaseManager()
manager.data_dir = Path(temp_dir) / "encrypted_databases"
manager.data_dir.mkdir(parents=True, exist_ok=True)
yield manager
@pytest.fixture
def mock_auth_db(self, monkeypatch):
"""Mock the auth database functions."""
mock_session = MagicMock()
mock_user = MagicMock(spec=User)
mock_user.username = "testuser"
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_user
mock_session.close = MagicMock()
def mock_get_auth_db_session():
return mock_session
monkeypatch.setattr(
"local_deep_research.database.auth_db.get_auth_db_session",
mock_get_auth_db_session,
)
return mock_session
def test_database_isolation_without_sqlcipher(
self, db_manager, mock_auth_db
):
"""Test that the system handles missing SQLCipher gracefully."""
# This test verifies the system's behavior when SQLCipher is not available
# In a real deployment, SQLCipher would be required
# Mock SQLAlchemy to simulate SQLCipher not being available
with patch(
"local_deep_research.database.encrypted_db.create_engine"
) as mock_engine:
mock_engine.side_effect = ImportError(
"No module named 'pysqlcipher3'"
)
# Attempt to create a user database
with pytest.raises(ImportError):
db_manager.create_user_database("testuser", "password123")
def test_user_exists_check(self, db_manager, mock_auth_db):
"""Test checking if a user exists."""
# Test existing user
assert db_manager.user_exists("testuser") is True
# Test non-existing user
mock_auth_db.query.return_value.filter_by.return_value.first.return_value = None
assert db_manager.user_exists("nonexistent") is False
def test_database_path_generation(self, db_manager):
"""Test that database paths are generated correctly."""
# Test path generation for different usernames
path1 = db_manager._get_user_db_path("user1")
path2 = db_manager._get_user_db_path("user2")
# Paths should be different
assert path1 != path2
# Paths should be in the correct directory
assert path1.parent == db_manager.data_dir
assert path2.parent == db_manager.data_dir
# Paths should use hashed usernames
assert "user1" not in str(path1)
assert "user2" not in str(path2)
def test_memory_usage_tracking(self, db_manager):
"""Test memory usage statistics."""
stats = db_manager.get_memory_usage()
assert stats["active_connections"] == 0
assert stats["active_sessions"] == 0
assert stats["estimated_memory_mb"] == 0
def test_session_management_without_sqlcipher(self, db_manager):
"""Test session management when SQLCipher is not available."""
# Without an open database, get_session should return None
session = db_manager.get_session("testuser")
assert session is None
@pytest.mark.skipif(
True, # Always skip for now since SQLCipher is not installed
reason="SQLCipher not available in test environment",
)
def test_full_multiuser_flow(self, db_manager, mock_auth_db):
"""Test complete multi-user flow with SQLCipher (skipped if not available)."""
# This test would run if SQLCipher were installed
# Create databases for two users
# Get sessions for each user
session1 = db_manager.get_session("user1")
session2 = db_manager.get_session("user2")
# Add research to user1's database
research1 = ResearchHistory(
query="User 1 research", mode="quick", status="completed"
)
session1.add(research1)
session1.commit()
# Add research to user2's database
research2 = ResearchHistory(
query="User 2 research", mode="deep", status="completed"
)
session2.add(research2)
session2.commit()
# Verify isolation - each user only sees their own data
user1_research = session1.query(ResearchHistory).all()
user2_research = session2.query(ResearchHistory).all()
assert len(user1_research) == 1
assert len(user2_research) == 1
assert user1_research[0].query == "User 1 research"
assert user2_research[0].query == "User 2 research"
# Test password change
success = db_manager.change_password(
"user1", "password1", "newpassword1"
)
assert success is True
# Close databases
db_manager.close_user_database("user1")
db_manager.close_user_database("user2")
# Verify databases are closed
assert "user1" not in db_manager.connections
assert "user2" not in db_manager.connections
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
# allow: no-sut-import — guardian; scans the codebase for raw SQL outside allowed locations
"""Test to verify no raw SQL is used in the codebase (except in allowed locations)."""
import sys
from pathlib import Path
sys.path.insert(
0,
str(Path(__file__).parent.parent.parent.resolve()),
)
import re
def check_file_for_raw_sql(filepath):
"""Check a single file for raw SQL usage."""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
# Skip if it's a test file, migration file, or database-specific files
filepath_str = str(filepath).lower()
if any(
skip in filepath_str
for skip in [
"test",
"migration",
"encrypted_db.py",
"sqlcipher_utils.py",
"thread_local_session.py",
"queue/processor.py",
"database/initialize.py", # Schema migrations using DDL
"alembic_runner.py", # Migration runner: drops orphan _alembic_tmp_* tables (#3817), toggles foreign_keys (#3990)
"auth_db.py", # SQLAlchemy DDL (CreateTable/CreateIndex), not raw SQL
"backup_service.py", # SQLCipher ATTACH/DETACH/export operations
"journal_quality/db.py", # Read-only SQLite DB build + PRAGMA user_version
]
):
return []
violations = []
lines = content.split("\n")
# Patterns that indicate raw SQL
sql_patterns = [
r"cursor\.execute\s*\(",
r"conn\.execute\s*\(",
r'session\.execute\s*\(\s*["\']',
r'["\']SELECT\s+.*FROM\s+',
r'["\']INSERT\s+INTO\s+',
r'["\']UPDATE\s+.*SET\s+',
r'["\']DELETE\s+FROM\s+',
r'["\']CREATE\s+TABLE\s+',
r'["\']DROP\s+TABLE\s+',
r'["\']ALTER\s+TABLE\s+',
]
# Allowed patterns (SQLCipher PRAGMAs and simple connection tests)
allowed_patterns = [
r"PRAGMA\s+(cipher_|quick_check|rekey)", # SQLCipher pragmas
r"SELECT\s+1(?:\s|$|;)", # Simple connection test
r'text\s*\(\s*["\'](?:SELECT\s+1|PRAGMA)', # SQLAlchemy text() with allowed queries
]
for line_num, line in enumerate(lines, 1):
for pattern in sql_patterns:
if re.search(pattern, line, re.IGNORECASE):
# Check if it's in a comment or docstring
stripped = line.strip()
if (
stripped.startswith("#")
or stripped.startswith('"""')
or stripped.startswith("'''")
):
continue
# Check if it's an allowed pattern
is_allowed = False
for allowed in allowed_patterns:
if re.search(allowed, line, re.IGNORECASE):
is_allowed = True
break
if not is_allowed:
violations.append((line_num, line.strip()))
return violations
def test_no_raw_sql_in_src():
"""Test that no raw SQL is used in src directory (except allowed patterns).
Allowed exceptions:
- encrypted_db.py file (SQLCipher-specific operations)
- PRAGMA commands for SQLCipher (cipher_*, quick_check, rekey)
- Simple connection tests (SELECT 1)
"""
src_path = Path(__file__).parent.parent.parent / "src"
violations = {}
# Walk through all Python files
for filepath in src_path.rglob("*.py"):
file_violations = check_file_for_raw_sql(filepath)
if file_violations:
violations[str(filepath)] = file_violations
# Report violations
if violations:
print("\n❌ Found raw SQL in the following files:")
for filepath, file_violations in violations.items():
print(f"\n{filepath}:")
for line_num, line in file_violations:
print(f" Line {line_num}: {line[:80]}...")
# This should fail the test
assert False, f"Found raw SQL in {len(violations)} files"
else:
print(
"✓ No raw SQL found in src directory (excluding allowed patterns)"
)
def test_orm_imports_used():
"""Files that touch the DB should reach for the ORM, not raw SQL.
Walk the src tree, collect every file that performs a DB operation
(``get_db_session``, references to ``ResearchHistory`` or
``ResearchResource``), and require that the vast majority of them
also import SQLAlchemy or use ORM-style query helpers.
The ratio guard is intentionally loose (≥80%) because a handful of
helper modules legitimately reference these names without
constructing queries themselves (e.g. type-only imports, fixtures,
docstring examples).
"""
src_path = Path(__file__).parent.parent.parent / "src"
files_with_db_operations = []
files_with_orm_imports = []
for filepath in src_path.rglob("*.py"):
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
# Check if file has database operations
if any(
pattern in content
for pattern in [
"get_db_session",
"ResearchHistory",
"ResearchResource",
]
):
files_with_db_operations.append(filepath)
# Check if it has ORM imports
if any(
pattern in content
for pattern in [
"from sqlalchemy",
"import.*session",
".query(",
".filter",
]
):
files_with_orm_imports.append(filepath)
# Sanity: at least some files matched the DB-operation heuristic, or
# the test has silently stopped finding anything (e.g., a refactor
# renamed the symbols above and the test would otherwise pass with 0
# files inspected).
assert files_with_db_operations, (
"No files with DB operations detected — patterns may be stale"
)
orm_ratio = len(files_with_orm_imports) / len(files_with_db_operations)
assert orm_ratio >= 0.8, (
f"Only {len(files_with_orm_imports)}/{len(files_with_db_operations)} "
f"files with DB operations use ORM imports (ratio {orm_ratio:.2f} < 0.80)"
)
def test_models_imported_from_correct_location():
"""Test that models are imported from the consolidated location."""
src_path = Path(__file__).parent.parent.parent / "src"
incorrect_imports = {}
# Old import patterns that should not be used
old_patterns = [
r"from.*web\.models\.database import.*(?:Research|ResearchHistory)",
r"from.*web\.database\.models import",
r"from.*benchmarks\.models\.benchmark_models import",
]
for filepath in src_path.rglob("*.py"):
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
violations = []
lines = content.split("\n")
for line_num, line in enumerate(lines, 1):
for pattern in old_patterns:
if re.search(pattern, line):
violations.append((line_num, line.strip()))
if violations:
incorrect_imports[str(filepath)] = violations
if incorrect_imports:
print("\n❌ Found imports from old model locations:")
for filepath, violations in incorrect_imports.items():
print(f"\n{filepath}:")
for line_num, line in violations:
print(f" Line {line_num}: {line}")
# This should fail the test
assert False, (
f"Found {len(incorrect_imports)} files with incorrect model imports"
)
else:
print("✓ All models imported from correct location (database.models)")
if __name__ == "__main__":
test_no_raw_sql_in_src()
test_orm_imports_used()
test_models_imported_from_correct_location()
print("\n✅ All SQL/ORM compliance tests passed!")
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Test that ORM conversions work correctly (no more raw SQL)."""
import sys
from pathlib import Path
sys.path.insert(
0,
str(Path(__file__).parent.parent.parent.resolve()),
)
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import (
Base,
ResearchHistory,
ResearchLog,
ResearchResource,
)
@pytest.fixture
def test_db():
"""Create a test database in memory."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
engine.dispose()
def test_research_history_orm_queries(test_db):
"""Test ResearchHistory ORM queries work correctly."""
import uuid
# Create test data with UUID
research = ResearchHistory(
id=str(uuid.uuid4()),
query="Test quantum computing",
mode="deep",
status="completed",
created_at="2024-01-01T00:00:00",
progress=100,
research_meta={"model": "gpt-4", "iterations": 5},
)
test_db.add(research)
test_db.commit()
# Test querying by ID
found = test_db.query(ResearchHistory).filter_by(id=research.id).first()
assert found is not None
assert found.query == "Test quantum computing"
# Test querying by status
completed = (
test_db.query(ResearchHistory).filter_by(status="completed").all()
)
assert len(completed) == 1
assert completed[0].id == research.id
# Test ordering
ordered = (
test_db.query(ResearchHistory)
.order_by(ResearchHistory.created_at.desc())
.all()
)
assert len(ordered) == 1
print("✓ ResearchHistory ORM queries work correctly")
def test_research_resource_orm_operations(test_db):
"""Test ResearchResource ORM operations."""
import uuid
# Create a research entry first with UUID
research = ResearchHistory(
id=str(uuid.uuid4()),
query="Test",
mode="quick",
status="completed",
created_at="2024-01-01T00:00:00",
)
test_db.add(research)
test_db.commit()
# Add resources
resource1 = ResearchResource(
research_id=research.id,
title="Resource 1",
url="https://example.com/1",
content_preview="Preview 1",
source_type="web",
resource_metadata={"relevance": 0.9},
created_at="2024-01-01T12:00:00",
)
resource2 = ResearchResource(
research_id=research.id,
title="Resource 2",
url="https://example.com/2",
content_preview="Preview 2",
source_type="pdf",
created_at="2024-01-01T12:05:00",
)
test_db.add_all([resource1, resource2])
test_db.commit()
# Query resources for research
resources = (
test_db.query(ResearchResource)
.filter_by(research_id=research.id)
.order_by(ResearchResource.id.asc())
.all()
)
assert len(resources) == 2
assert resources[0].title == "Resource 1"
assert resources[1].title == "Resource 2"
assert resources[0].resource_metadata == {"relevance": 0.9}
# Test deletion
test_db.delete(resource1)
test_db.commit()
remaining = (
test_db.query(ResearchResource)
.filter_by(research_id=research.id)
.count()
)
assert remaining == 1
print("✓ ResearchResource ORM operations work correctly")
def test_research_log_orm_queries(test_db):
"""Test ResearchLog ORM queries."""
from datetime import datetime, timezone
# First create a Research entry (not ResearchHistory)
from local_deep_research.database.models import (
Research,
ResearchMode,
ResearchStatus,
)
research = Research(
query="Test research",
status=ResearchStatus.IN_PROGRESS,
mode=ResearchMode.QUICK,
)
test_db.add(research)
test_db.commit()
# Add logs with all required fields
log1 = ResearchLog(
research_id=research.id,
timestamp=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
message="Starting research",
module="research_service",
function="start_research",
line_no=100,
level="INFO",
)
log2 = ResearchLog(
research_id=research.id,
timestamp=datetime(2024, 1, 1, 0, 1, 0, tzinfo=timezone.utc),
message="Search completed",
module="search_engine",
function="search",
line_no=250,
level="INFO",
)
test_db.add_all([log1, log2])
test_db.commit()
# Query logs
logs = (
test_db.query(ResearchLog)
.filter_by(research_id=research.id)
.order_by(ResearchLog.timestamp.asc())
.all()
)
assert len(logs) == 2
assert logs[0].message == "Starting research"
assert logs[1].level == "INFO"
# Query by module
search_logs = (
test_db.query(ResearchLog)
.filter_by(research_id=research.id, module="search_engine")
.all()
)
assert len(search_logs) == 1
assert search_logs[0].message == "Search completed"
print("✓ ResearchLog ORM queries work correctly")
if __name__ == "__main__":
# Create in-memory database for testing
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
try:
test_research_history_orm_queries(session)
test_research_resource_orm_operations(session)
test_research_log_orm_queries(session)
print("\n✅ All ORM conversion tests passed!")
finally:
session.close()
engine.dispose()
@@ -0,0 +1,229 @@
"""Regression test for issue #3544.
When a research session is deleted, ``ResearchResource`` rows
cascade-delete and their ``PaperAppearance`` rows cascade-delete with
them. ``Paper`` rows have no FK back to research and remain in place.
Before the fix, ``/api/journals/user-research`` queried ``Paper``
directly and so kept showing journals whose only Papers belonged to
deleted research sessions. The fix adds ``.filter(Paper.appearances.any())``
to both the top-200 GROUP BY query and the predatory_blocked DISTINCT
query so orphan Papers are excluded from the dashboard view.
This test asserts the filter behavior at the SQL semantics level:
- one Paper still has appearances → it is included
- one Paper is orphaned (its only appearance was cascade-deleted) → it is excluded
"""
import uuid
import pytest
from sqlalchemy import create_engine, event, func
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import (
Base,
Paper,
PaperAppearance,
ResearchHistory,
ResearchResource,
)
@pytest.fixture
def session():
"""In-memory SQLite engine with FK enforcement enabled.
FK enforcement is required because the cascade we are testing
(``research_resources.research_id`` → ``research_history.id``
with ``ondelete=CASCADE``) is enforced by the database, not by
SQLAlchemy ORM. Bare SQLite has FK enforcement off by default.
"""
engine = create_engine("sqlite:///:memory:")
@event.listens_for(engine, "connect")
def _enable_fk(dbapi_connection, _):
dbapi_connection.execute("PRAGMA foreign_keys = ON")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
s = Session()
yield s
s.close()
engine.dispose()
def _seed_research_with_paper(session, journal_name, doi):
"""Create a (ResearchHistory, ResearchResource, Paper, PaperAppearance) chain."""
rid = str(uuid.uuid4())
research = ResearchHistory(
id=rid,
query=f"q for {journal_name}",
mode="detailed",
status="completed",
created_at="2026-05-07T00:00:00",
)
session.add(research)
session.flush()
resource = ResearchResource(
research_id=rid,
title=f"paper in {journal_name}",
url=f"https://example.com/{doi}",
source_type="article",
created_at="2026-05-07T00:00:00",
)
session.add(resource)
session.flush()
paper = Paper(
doi=doi,
container_title=journal_name,
year=2026,
)
session.add(paper)
session.flush()
appearance = PaperAppearance(
paper_id=paper.id,
resource_id=resource.id,
source_engine="arxiv",
)
session.add(appearance)
session.commit()
return research, paper
def _user_research_journals_query(session):
"""Mirror the production query in api_user_research_journals."""
return (
session.query(
Paper.container_title,
func.count(Paper.id).label("paper_count"),
)
.filter(Paper.container_title.isnot(None))
.filter(Paper.appearances.any())
.group_by(Paper.container_title)
.all()
)
def _distinct_titles_query(session):
"""Mirror the production predatory_blocked DISTINCT query."""
return [
name
for (name,) in session.query(Paper.container_title)
.filter(Paper.container_title.isnot(None))
.filter(Paper.appearances.any())
.distinct()
.all()
]
def test_orphan_paper_excluded_from_user_research_dashboard(session):
"""After deleting one research session, its journal must disappear
from the dashboard while the surviving session's journal remains."""
_, kept_paper = _seed_research_with_paper(session, "Journal A", "10.1/jA")
deleted_research, orphan_paper = _seed_research_with_paper(
session, "Journal B", "10.1/jB"
)
# Sanity: both journals visible before deletion.
rows_before = _user_research_journals_query(session)
titles_before = {r.container_title for r in rows_before}
assert titles_before == {"Journal A", "Journal B"}
# Delete one research session — its ResearchResource cascade-deletes,
# which cascade-deletes its PaperAppearance row. The Paper row stays.
session.delete(deleted_research)
session.commit()
# Paper is still in the DB — fix is not deletion-based.
assert session.query(Paper).filter_by(id=orphan_paper.id).count() == 1, (
"Fix must not delete orphan Papers — only filter them at query time"
)
# The other Paper still has its appearance.
assert (
session.query(PaperAppearance).filter_by(paper_id=kept_paper.id).count()
== 1
)
# The orphan Paper's appearance is gone (cascade through ResearchResource).
assert (
session.query(PaperAppearance)
.filter_by(paper_id=orphan_paper.id)
.count()
== 0
)
# Dashboard query: orphan journal must be excluded.
rows_after = _user_research_journals_query(session)
titles_after = {r.container_title for r in rows_after}
assert titles_after == {"Journal A"}, (
f"Orphan Paper's journal must be excluded from dashboard; "
f"got {titles_after}"
)
# Predatory_blocked DISTINCT query: same behavior.
distinct_after = _distinct_titles_query(session)
assert set(distinct_after) == {"Journal A"}
def test_paper_with_multiple_appearances_kept_when_one_deleted(session):
"""A paper that appears in two research sessions must remain visible
after one of those sessions is deleted (only orphans are excluded)."""
research_a_id = str(uuid.uuid4())
research_b_id = str(uuid.uuid4())
for rid in (research_a_id, research_b_id):
session.add(
ResearchHistory(
id=rid,
query="q",
mode="detailed",
status="completed",
created_at="2026-05-07T00:00:00",
)
)
session.flush()
# One Paper, two ResearchResources (one per session), two PaperAppearances.
paper = Paper(
doi="10.1/shared",
container_title="Shared Journal",
year=2026,
)
session.add(paper)
session.flush()
for rid in (research_a_id, research_b_id):
resource = ResearchResource(
research_id=rid,
title="shared paper",
url=f"https://example.com/{rid}",
source_type="article",
created_at="2026-05-07T00:00:00",
)
session.add(resource)
session.flush()
session.add(
PaperAppearance(
paper_id=paper.id,
resource_id=resource.id,
source_engine="openalex",
)
)
session.commit()
# Delete research A — its appearance goes, B's stays.
session.delete(
session.query(ResearchHistory).filter_by(id=research_a_id).one()
)
session.commit()
assert (
session.query(PaperAppearance).filter_by(paper_id=paper.id).count() == 1
)
rows = _user_research_journals_query(session)
titles = {r.container_title for r in rows}
assert titles == {"Shared Journal"}, (
f"Paper with surviving appearance must stay visible; got {titles}"
)
@@ -0,0 +1,484 @@
"""Integration tests for Paper dedup write path in ResearchSourcesService.
Exercises the full write path with a real (encrypted) test database:
- ResearchResource creation
- Paper dedup via DOI/arxiv_id/pmid waterfall
- PaperAppearance linking
- paper_metadata JSON blob round-trip
- Savepoint isolation on per-source failure
"""
import sys
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
import pytest
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 (
Paper,
PaperAppearance,
ResearchHistory,
ResearchResource,
)
class TestPaperDedupIntegration:
"""End-to-end tests for Paper dedup using a real test database."""
@pytest.fixture
def temp_data_dir(self):
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def db_manager(self, temp_data_dir, monkeypatch):
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: temp_data_dir,
)
manager = DatabaseManager()
yield manager
for username in list(manager.connections.keys()):
manager.close_user_database(username)
@pytest.fixture
def test_session(self, db_manager):
username = "testuser"
password = "TestPassword123!"
db_manager.create_user_database(username, password)
session = db_manager.get_session(username)
yield session, username
session.close()
db_manager.close_user_database(username)
@pytest.fixture
def research_id(self, test_session):
"""Create a ResearchHistory row and return its ID."""
session, _ = test_session
rid = str(uuid.uuid4())
research = ResearchHistory(
id=rid,
query="test dedup query",
mode="detailed",
status="in_progress",
created_at=datetime.now(timezone.utc).isoformat(),
progress=50,
)
session.add(research)
session.commit()
return rid
def test_paper_created_with_indexed_columns_and_metadata_blob(
self, test_session, research_id, monkeypatch
):
"""A single academic source creates one Paper + one PaperAppearance."""
session, username = test_session
# Patch get_user_db_session to return our test session
from contextlib import contextmanager
@contextmanager
def fake_session(*args, **kwargs):
yield session
monkeypatch.setattr(
"local_deep_research.web.services.research_sources_service.get_user_db_session",
fake_session,
)
from local_deep_research.web.services.research_sources_service import (
ResearchSourcesService,
)
sources = [
{
"url": "https://arxiv.org/abs/2301.12345",
"title": "Machine Learning Fundamentals",
"snippet": "Overview of ML concepts",
"doi": "10.1234/example.2023.001",
"authors": ["Alice Smith", "Bob Jones"],
"year": 2023,
"journal_ref": "Journal of ML Research",
"source_engine": "arxiv",
}
]
saved_count = ResearchSourcesService.save_research_sources(
research_id=research_id,
sources=sources,
username=username,
)
assert saved_count == 1
papers = session.query(Paper).all()
assert len(papers) == 1
paper = papers[0]
# Indexed columns
assert paper.doi == "10.1234/example.2023.001"
# Metadata JSON blob — contains bibliographic fields
assert paper.paper_metadata is not None
assert isinstance(paper.paper_metadata, dict)
# PaperAppearance links the paper to the resource
appearances = session.query(PaperAppearance).all()
assert len(appearances) == 1
assert appearances[0].paper_id == paper.id
def test_same_doi_deduped_across_two_sources(
self, test_session, research_id, monkeypatch
):
"""Two sources with the same DOI → 1 Paper + 2 PaperAppearances."""
session, username = test_session
from contextlib import contextmanager
@contextmanager
def fake_session(*args, **kwargs):
yield session
monkeypatch.setattr(
"local_deep_research.web.services.research_sources_service.get_user_db_session",
fake_session,
)
from local_deep_research.web.services.research_sources_service import (
ResearchSourcesService,
)
same_doi = "10.1234/example.2023.001"
sources = [
{
"url": "https://arxiv.org/abs/2301.12345",
"title": "ML Fundamentals",
"doi": same_doi,
"authors": ["Alice Smith"],
"year": 2023,
"journal_ref": "Journal of ML Research",
"source_engine": "arxiv",
},
{
"url": "https://openalex.org/W2023001",
"title": "ML Fundamentals (OpenAlex version)",
"doi": same_doi, # SAME DOI → dedup
"authors": ["Alice Smith", "Bob Jones"],
"year": 2023,
"journal_ref": "Journal of ML Research",
"source_engine": "openalex",
},
]
saved_count = ResearchSourcesService.save_research_sources(
research_id=research_id,
sources=sources,
username=username,
)
assert saved_count == 2
# Dedup: only one Paper row
papers = session.query(Paper).filter_by(doi=same_doi).all()
assert len(papers) == 1, f"Expected 1 Paper (dedup), got {len(papers)}"
# Two ResearchResource rows (one per source)
resources = (
session.query(ResearchResource)
.filter_by(research_id=research_id)
.all()
)
assert len(resources) == 2
# Two PaperAppearance rows, both pointing at the same Paper
appearances = (
session.query(PaperAppearance)
.filter_by(paper_id=papers[0].id)
.all()
)
assert len(appearances) == 2
# Appearances reference different resources
appearance_resource_ids = {a.resource_id for a in appearances}
resource_ids = {r.id for r in resources}
assert appearance_resource_ids == resource_ids
def test_batch_with_failing_source_savepoint_isolation(
self, test_session, research_id, monkeypatch
):
"""One failing source should not lose earlier successful sources."""
session, username = test_session
from contextlib import contextmanager
@contextmanager
def fake_session(*args, **kwargs):
yield session
monkeypatch.setattr(
"local_deep_research.web.services.research_sources_service.get_user_db_session",
fake_session,
)
from local_deep_research.web.services.research_sources_service import (
ResearchSourcesService,
)
# Three sources: first and third are valid, middle one has a
# pathologically bad structure that will crash normalize_citation
# via the metadata-is-not-a-dict path if unguarded. Actually
# since we fixed that, craft a different failure: a URL that
# will pass but then fail on some downstream step. The cleanest
# way to force a failure is to make the second source trigger
# a constraint violation we can't catch gracefully.
#
# Simpler: use three valid sources and verify all 3 succeed.
# The savepoint path is already exercised by test 2 indirectly
# (through the retry logic); here we just assert three-source
# batches commit cleanly.
sources = [
{
"url": "https://arxiv.org/abs/2401.00001",
"title": "Paper A",
"doi": "10.1000/a",
"journal_ref": "Journal A",
"source_engine": "arxiv",
},
{
"url": "https://arxiv.org/abs/2401.00002",
"title": "Paper B",
"doi": "10.1000/b",
"journal_ref": "Journal B",
"source_engine": "arxiv",
},
{
"url": "https://arxiv.org/abs/2401.00003",
"title": "Paper C",
"doi": "10.1000/c",
"journal_ref": "Journal C",
"source_engine": "arxiv",
},
]
saved_count = ResearchSourcesService.save_research_sources(
research_id=research_id,
sources=sources,
username=username,
)
assert saved_count == 3
# All 3 papers persisted
papers = session.query(Paper).all()
assert len(papers) == 3
dois = {p.doi for p in papers}
assert dois == {"10.1000/a", "10.1000/b", "10.1000/c"}
def test_json_safe_rejects_non_serializable_source(
self, test_session, research_id, monkeypatch
):
"""Raw source with non-JSON types should still save via _json_safe."""
session, username = test_session
from contextlib import contextmanager
@contextmanager
def fake_session(*args, **kwargs):
yield session
monkeypatch.setattr(
"local_deep_research.web.services.research_sources_service.get_user_db_session",
fake_session,
)
from local_deep_research.web.services.research_sources_service import (
ResearchSourcesService,
)
# This source has a datetime object (non-JSON-safe) in a
# nested dict. Without _json_safe it would crash the flush.
sources = [
{
"url": "https://arxiv.org/abs/2401.99998",
"title": "Test Paper",
"doi": "10.9998/test",
"journal_ref": "Test Journal",
"source_engine": "arxiv",
# Deliberately non-JSON-serializable: a datetime
# nested inside the source dict.
"raw_timestamp": datetime.now(timezone.utc),
}
]
saved_count = ResearchSourcesService.save_research_sources(
research_id=research_id,
sources=sources,
username=username,
)
# Should succeed because _json_safe coerces the datetime to str
assert saved_count == 1
papers = session.query(Paper).filter_by(doi="10.9998/test").all()
assert len(papers) == 1
def test_metadata_blob_survives_roundtrip(
self, test_session, research_id, monkeypatch
):
"""paper_metadata is a proper dict after write + read-back."""
session, username = test_session
from contextlib import contextmanager
@contextmanager
def fake_session(*args, **kwargs):
yield session
monkeypatch.setattr(
"local_deep_research.web.services.research_sources_service.get_user_db_session",
fake_session,
)
from local_deep_research.web.services.research_sources_service import (
ResearchSourcesService,
)
sources = [
{
"url": "https://arxiv.org/abs/2301.99999",
"title": "Test Paper",
"doi": "10.9999/test.2023",
"authors": [
{"family": "Smith", "given": "J."},
{"family": "Jones", "given": "A."},
],
"year": 2023,
"publication_date": "2023-06-15",
"volume": "42",
"issue": "3",
"pages": "123-145",
"journal_ref": "Test Journal",
"source_engine": "arxiv",
}
]
ResearchSourcesService.save_research_sources(
research_id=research_id,
sources=sources,
username=username,
)
# Clear session cache to force read from DB
session.expire_all()
paper = session.query(Paper).filter_by(doi="10.9999/test.2023").first()
assert paper is not None
assert paper.paper_metadata is not None
# Should be a real dict (JSON-deserialized)
assert isinstance(paper.paper_metadata, dict)
def test_partial_identifier_dedup_arxiv_only_existing(
self, test_session, research_id, monkeypatch
):
"""Regression: a Paper with only arxiv_id must dedup when the
incoming record has BOTH doi + arxiv_id. The previous waterfall
short-circuited on DOI miss and never tried arxiv, creating
duplicate rows."""
session, username = test_session
from contextlib import contextmanager
@contextmanager
def fake_session(*args, **kwargs):
yield session
monkeypatch.setattr(
"local_deep_research.web.services.research_sources_service.get_user_db_session",
fake_session,
)
from local_deep_research.web.services.research_sources_service import (
ResearchSourcesService,
)
# First source: arxiv-only (no DOI extracted)
first = [
{
"url": "https://arxiv.org/abs/2401.12345",
"title": "Partial ID test",
"authors": ["A. Author"],
"year": 2024,
"source_engine": "arxiv",
}
]
ResearchSourcesService.save_research_sources(
research_id=research_id, sources=first, username=username
)
assert session.query(Paper).count() == 1
paper1 = session.query(Paper).first()
assert paper1.arxiv_id == "2401.12345"
assert paper1.doi is None
# Second source: SAME paper, but now with both DOI and arxiv_id.
# OR-query must match on arxiv_id even though DOI lookup misses.
second_research_id = str(uuid.uuid4())
research2 = ResearchHistory(
id=second_research_id,
query="second research",
mode="detailed",
status="in_progress",
created_at=datetime.now(timezone.utc).isoformat(),
progress=50,
)
session.add(research2)
session.commit()
# Second source: arXiv URL (so _extract_arxiv_id fires) but with
# an added DOI field as well. This is the realistic case —
# the same paper found by a second engine that now knows both
# identifiers. The waterfall bug would try DOI first, miss,
# and create a duplicate; the OR query must match via arxiv_id.
second = [
{
"url": "https://arxiv.org/abs/2401.12345",
"title": "Partial ID test (with DOI)",
"doi": "10.9999/partial.test",
"authors": ["A. Author"],
"year": 2024,
"source_engine": "arxiv",
}
]
ResearchSourcesService.save_research_sources(
research_id=second_research_id,
sources=second,
username=username,
)
# Must still be exactly ONE Paper row, not two.
all_papers = session.query(Paper).all()
assert len(all_papers) == 1, (
f"Expected dedup via OR query on arxiv_id; got "
f"{len(all_papers)} Paper rows"
)
# Two appearances (one per source), both pointing at the same paper.
appearances = session.query(PaperAppearance).all()
assert len(appearances) == 2
assert {a.paper_id for a in appearances} == {all_papers[0].id}
# NOTE: no unit test for the IntegrityError retry branch at
# research_sources_service.py:228-268. That branch is a race-
# mitigation path for concurrent writers competing on UNIQUE(doi)
# and requires two real sessions flushing simultaneously to
# exercise deterministically. A mock-based approach (stub
# _find_existing_paper to miss once, then pre-seed a colliding
# DOI) triggers SQLAlchemy's PendingRollbackError before the retry
# runs, because a savepoint rollback does not fully reset the
# session state after a constraint failure. A real concurrency
# test would need threading + a shared engine; that infrastructure
# does not currently exist in this test suite. The happy-path
# dedup coverage above (test_same_doi_deduped_across_two_sources,
# test_partial_identifier_dedup_arxiv_only_existing) exercises the
# SELECT-before-INSERT path; the retry-after-race path is covered
# only by production observation today.
+30
View File
@@ -0,0 +1,30 @@
"""
Test that pool configuration constants have expected values.
These tests ensure consistency across database engines.
If values need to change, update pool_config.py and these tests together.
"""
class TestPoolConfigConstants:
"""Verify shared pool configuration constants."""
def test_pool_pre_ping_is_true(self):
from local_deep_research.database.pool_config import POOL_PRE_PING
assert POOL_PRE_PING is True
def test_pool_recycle_seconds_value(self):
from local_deep_research.database.pool_config import (
POOL_RECYCLE_SECONDS,
)
assert POOL_RECYCLE_SECONDS == 3600
def test_pool_recycle_is_positive_integer(self):
from local_deep_research.database.pool_config import (
POOL_RECYCLE_SECONDS,
)
assert isinstance(POOL_RECYCLE_SECONDS, int)
assert POOL_RECYCLE_SECONDS > 0
@@ -0,0 +1,347 @@
"""
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"
+415
View File
@@ -0,0 +1,415 @@
"""
Tests for database/queue_service.py
Tests cover:
- UserQueueService initialization
- Queue status management
- Task metadata operations
- Task status updates
- Pending task retrieval
- Task cleanup
"""
from unittest.mock import Mock
from datetime import datetime, UTC
class TestUserQueueServiceInit:
"""Tests for UserQueueService initialization."""
def test_init_with_session(self):
"""Test initialization with a session."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
service = UserQueueService(mock_session)
assert service.session == mock_session
class TestUpdateQueueStatus:
"""Tests for update_queue_status method."""
def test_updates_existing_status(self):
"""Test updating existing queue status."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service.update_queue_status(5, 10, "task-123")
assert mock_status.active_tasks == 5
assert mock_status.queued_tasks == 10
assert mock_status.last_task_id == "task-123"
mock_session.commit.assert_called_once()
def test_creates_new_status_when_none_exists(self):
"""Test creating status when none exists."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.query.return_value.first.return_value = None
service = UserQueueService(mock_session)
service.update_queue_status(2, 5)
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
class TestGetQueueStatus:
"""Tests for get_queue_status method."""
def test_returns_status_dict(self):
"""Test returns status as dictionary."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.active_tasks = 3
mock_status.queued_tasks = 7
mock_status.last_checked = datetime.now(UTC)
mock_status.last_task_id = "task-456"
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
result = service.get_queue_status()
assert result["active_tasks"] == 3
assert result["queued_tasks"] == 7
assert result["last_task_id"] == "task-456"
def test_returns_none_when_no_status(self):
"""Test returns None when no status exists."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.query.return_value.first.return_value = None
service = UserQueueService(mock_session)
result = service.get_queue_status()
assert result is None
class TestAddTaskMetadata:
"""Tests for add_task_metadata method."""
def test_adds_task_metadata(self):
"""Test adding task metadata."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
# Mock for _increment_queue_count
mock_status = Mock()
mock_status.queued_tasks = 0
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service.add_task_metadata("task-1", "research", priority=5)
mock_session.add.assert_called()
mock_session.commit.assert_called()
def test_increments_queue_count(self):
"""Test that queue count is incremented."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 2
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service.add_task_metadata("task-1", "research")
assert mock_status.queued_tasks == 3
class TestUpdateTaskStatus:
"""Tests for update_task_status method."""
def test_updates_task_to_processing(self):
"""Test updating task to processing status."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_task = Mock()
mock_task.status = "queued"
mock_status = Mock()
mock_status.queued_tasks = 5
mock_status.active_tasks = 2
# Set up query chain
mock_filter = Mock()
mock_filter.first.return_value = mock_task
mock_query = Mock()
mock_query.filter_by.return_value = mock_filter
mock_query.first.return_value = mock_status
def query_side_effect(model):
if hasattr(model, "task_id"): # TaskMetadata
return mock_query
return Mock(first=Mock(return_value=mock_status))
mock_session.query.side_effect = query_side_effect
service = UserQueueService(mock_session)
service.update_task_status("task-1", "processing")
assert mock_task.status == "processing"
def test_updates_task_to_completed(self):
"""Test updating task to completed status."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_task = Mock()
mock_task.status = "processing"
mock_status = Mock()
mock_status.queued_tasks = 3
mock_status.active_tasks = 2
mock_filter = Mock()
mock_filter.first.return_value = mock_task
mock_query = Mock()
mock_query.filter_by.return_value = mock_filter
mock_query.first.return_value = mock_status
def query_side_effect(model):
if hasattr(model, "task_id"):
return mock_query
return Mock(first=Mock(return_value=mock_status))
mock_session.query.side_effect = query_side_effect
service = UserQueueService(mock_session)
service.update_task_status("task-1", "completed")
assert mock_task.status == "completed"
def test_updates_task_with_error(self):
"""Test updating task with error message."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_task = Mock()
mock_task.status = "processing"
mock_status = Mock()
mock_status.queued_tasks = 0
mock_status.active_tasks = 1
mock_filter = Mock()
mock_filter.first.return_value = mock_task
mock_query = Mock()
mock_query.filter_by.return_value = mock_filter
mock_query.first.return_value = mock_status
def query_side_effect(model):
if hasattr(model, "task_id"):
return mock_query
return Mock(first=Mock(return_value=mock_status))
mock_session.query.side_effect = query_side_effect
service = UserQueueService(mock_session)
service.update_task_status("task-1", "failed", "Something went wrong")
assert mock_task.status == "failed"
assert mock_task.error_message == "Something went wrong"
def test_handles_nonexistent_task(self):
"""Test handling when task doesn't exist."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_filter = Mock()
mock_filter.first.return_value = None
mock_query = Mock()
mock_query.filter_by.return_value = mock_filter
mock_session.query.return_value = mock_query
service = UserQueueService(mock_session)
# Should not raise
service.update_task_status("nonexistent", "completed")
class TestGetPendingTasks:
"""Tests for get_pending_tasks method."""
def test_returns_pending_tasks(self):
"""Test getting pending tasks."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_task1 = Mock()
mock_task1.task_id = "task-1"
mock_task1.task_type = "research"
mock_task1.created_at = datetime.now(UTC)
mock_task1.priority = 5
mock_task2 = Mock()
mock_task2.task_id = "task-2"
mock_task2.task_type = "analysis"
mock_task2.created_at = datetime.now(UTC)
mock_task2.priority = 3
mock_query = mock_session.query.return_value
mock_query.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = [
mock_task1,
mock_task2,
]
service = UserQueueService(mock_session)
result = service.get_pending_tasks(limit=5)
assert len(result) == 2
assert result[0]["task_id"] == "task-1"
assert result[0]["task_type"] == "research"
assert result[1]["task_id"] == "task-2"
def test_returns_empty_list_when_no_tasks(self):
"""Test returns empty list when no pending tasks."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_query = mock_session.query.return_value
mock_query.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = []
service = UserQueueService(mock_session)
result = service.get_pending_tasks()
assert result == []
class TestCleanupOldTasks:
"""Tests for cleanup_old_tasks method."""
def test_deletes_old_tasks(self):
"""Test deleting old completed tasks."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_query = mock_session.query.return_value
mock_query.filter.return_value.delete.return_value = 5
service = UserQueueService(mock_session)
result = service.cleanup_old_tasks(days=7)
assert result == 5
mock_session.commit.assert_called_once()
class TestQueueCountHelpers:
"""Tests for queue count helper methods."""
def test_increment_queue_count_existing(self):
"""Test incrementing queue count with existing status."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 5
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service._increment_queue_count()
assert mock_status.queued_tasks == 6
def test_increment_queue_count_new_status(self):
"""Test incrementing queue count creates new status."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.query.return_value.first.return_value = None
service = UserQueueService(mock_session)
service._increment_queue_count()
mock_session.add.assert_called_once()
def test_update_queue_counts_clamps_to_zero(self):
"""Test that queue counts don't go below zero."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 1
mock_status.active_tasks = 1
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service._update_queue_counts(-5, -5)
assert mock_status.queued_tasks == 0
assert mock_status.active_tasks == 0
class TestProcessingToQueuedRevert:
"""update_task_status('queued') after 'processing' reverts the counter.
Regression: the queued-dispatch loop's SystemAtCapacityError branch
re-queues a research without reverting the queued->processing counter
claim, so each capacity-rejected retry leaked a slot into active_tasks
and decremented queued_tasks. Under sustained capacity pressure
queued_tasks drifts to 0, at which point _process_user_queue treats the
queue as empty and stops dispatching the still-present rows. These use
a real in-memory DB so the counter arithmetic is actually exercised.
"""
@staticmethod
def _service():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models.queue import (
QueueStatus,
TaskMetadata,
)
from local_deep_research.database.queue_service import (
UserQueueService,
)
engine = create_engine("sqlite:///:memory:")
QueueStatus.__table__.create(engine)
TaskMetadata.__table__.create(engine)
session = sessionmaker(bind=engine)()
return UserQueueService(session)
def test_processing_then_queued_round_trips_counts(self):
svc = self._service()
svc.add_task_metadata("r1", "research") # queued 1 / active 0
assert svc.get_queue_status()["queued_tasks"] == 1
assert svc.get_queue_status()["active_tasks"] == 0
svc.update_task_status("r1", "processing") # queued 0 / active 1
s = svc.get_queue_status()
assert s["queued_tasks"] == 0
assert s["active_tasks"] == 1
svc.update_task_status("r1", "queued") # reverted: queued 1 / active 0
s = svc.get_queue_status()
assert s["queued_tasks"] == 1, (
"queued->processing claim must be reverted on re-queue"
)
assert s["active_tasks"] == 0, (
"active slot must not leak after a capacity re-queue"
)
def test_repeated_capacity_rejects_do_not_drift(self):
"""Three processing->queued cycles (simulating capacity retries)
must leave the counter exactly where it started."""
svc = self._service()
svc.add_task_metadata("r1", "research")
for _ in range(3):
svc.update_task_status("r1", "processing")
svc.update_task_status("r1", "queued")
s = svc.get_queue_status()
assert s["queued_tasks"] == 1
assert s["active_tasks"] == 0
@@ -0,0 +1,723 @@
"""
Extended tests for database/queue_service.py - UserQueueService class.
Tests cover:
- _safe_commit rollback behavior on exceptions
- update_queue_status creating vs updating QueueStatus
- get_queue_status returning None when no status exists
- add_task_metadata creating TaskMetadata and incrementing queue count
- update_task_status transitions (queued->processing, processing->completed,
processing->failed) and their side effects on timestamps and queue counts
- update_task_status handling of non-existent tasks
- get_pending_tasks returning ordered list of task dicts
- cleanup_old_tasks deleting old completed/failed tasks
- _update_queue_counts preventing negative values via max(0, ...)
"""
from datetime import UTC, datetime
from unittest.mock import Mock, patch
import pytest
class TestSafeCommit:
"""Tests for _safe_commit rollback behavior."""
def test_safe_commit_calls_session_commit(self):
"""Successful commit should just call session.commit()."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
service = UserQueueService(mock_session)
service._safe_commit()
mock_session.commit.assert_called_once()
mock_session.rollback.assert_not_called()
def test_safe_commit_rolls_back_on_exception(self):
"""On commit failure, should rollback and re-raise."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.commit.side_effect = RuntimeError("DB write failed")
service = UserQueueService(mock_session)
with pytest.raises(RuntimeError, match="DB write failed"):
service._safe_commit()
mock_session.rollback.assert_called_once()
def test_safe_commit_logs_exception_on_failure(self):
"""On commit failure, should log the exception via loguru."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.commit.side_effect = Exception("some error")
service = UserQueueService(mock_session)
with patch(
"local_deep_research.database.queue_service.logger"
) as mock_logger:
with pytest.raises(Exception):
service._safe_commit()
mock_logger.exception.assert_called_once()
class TestUpdateQueueStatusExtended:
"""Extended tests for update_queue_status method."""
def test_creates_new_queue_status_when_none_exists(self):
"""When no QueueStatus row exists, should create one and add it."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.query.return_value.first.return_value = None
service = UserQueueService(mock_session)
with patch(
"local_deep_research.database.queue_service.QueueStatus"
) as MockQueueStatus:
mock_new_status = Mock()
MockQueueStatus.return_value = mock_new_status
service.update_queue_status(3, 7, "task-abc")
MockQueueStatus.assert_called_once_with(
active_tasks=3,
queued_tasks=7,
last_task_id="task-abc",
)
mock_session.add.assert_called_once_with(mock_new_status)
mock_session.commit.assert_called_once()
def test_updates_existing_queue_status(self):
"""When QueueStatus exists, should update its fields in place."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.active_tasks = 1
mock_status.queued_tasks = 2
mock_status.last_task_id = "old-task"
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service.update_queue_status(10, 20, "new-task")
assert mock_status.active_tasks == 10
assert mock_status.queued_tasks == 20
assert mock_status.last_task_id == "new-task"
assert mock_status.last_checked is not None
mock_session.commit.assert_called_once()
# Should NOT call session.add since status already exists
mock_session.add.assert_not_called()
def test_does_not_update_last_task_id_when_none(self):
"""When last_task_id is None, should not overwrite existing value."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.active_tasks = 0
mock_status.queued_tasks = 0
mock_status.last_task_id = "keep-this"
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service.update_queue_status(1, 1, last_task_id=None)
# last_task_id should remain unchanged because the if-branch is falsy
assert mock_status.last_task_id == "keep-this"
def test_update_queue_status_sets_last_checked_timestamp(self):
"""Updating existing status should set last_checked to current time."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.last_checked = None
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service.update_queue_status(0, 0)
# last_checked should be set to a datetime
assert mock_status.last_checked is not None
class TestGetQueueStatusExtended:
"""Extended tests for get_queue_status method."""
def test_returns_none_when_no_status(self):
"""Should return None when no QueueStatus exists."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.query.return_value.first.return_value = None
service = UserQueueService(mock_session)
result = service.get_queue_status()
assert result is None
def test_returns_complete_dict_with_all_fields(self):
"""Should return dict with active_tasks, queued_tasks, last_checked, last_task_id."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
now = datetime.now(UTC)
mock_status = Mock()
mock_status.active_tasks = 5
mock_status.queued_tasks = 12
mock_status.last_checked = now
mock_status.last_task_id = "task-xyz"
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
result = service.get_queue_status()
assert result == {
"active_tasks": 5,
"queued_tasks": 12,
"last_checked": now,
"last_task_id": "task-xyz",
}
def test_returns_dict_with_null_last_task_id(self):
"""Should handle None last_task_id correctly."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.active_tasks = 0
mock_status.queued_tasks = 0
mock_status.last_checked = datetime.now(UTC)
mock_status.last_task_id = None
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
result = service.get_queue_status()
assert result is not None
assert result["last_task_id"] is None
class TestAddTaskMetadataExtended:
"""Extended tests for add_task_metadata method."""
def test_creates_task_metadata_and_increments_queue(self):
"""Should create TaskMetadata with correct fields and increment queue count."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 3
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
with patch(
"local_deep_research.database.queue_service.TaskMetadata"
) as MockTaskMetadata:
mock_task = Mock()
MockTaskMetadata.return_value = mock_task
service.add_task_metadata("task-99", "research", priority=10)
MockTaskMetadata.assert_called_once_with(
task_id="task-99",
status="queued",
task_type="research",
priority=10,
)
# session.add should be called for the task (and possibly for status)
mock_session.add.assert_any_call(mock_task)
# queued_tasks should have been incremented
assert mock_status.queued_tasks == 4
mock_session.commit.assert_called_once()
def test_default_priority_is_zero(self):
"""Should use priority=0 when not specified."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 0
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
with patch(
"local_deep_research.database.queue_service.TaskMetadata"
) as MockTaskMetadata:
MockTaskMetadata.return_value = Mock()
service.add_task_metadata("task-1", "benchmark")
MockTaskMetadata.assert_called_once_with(
task_id="task-1",
status="queued",
task_type="benchmark",
priority=0,
)
def test_creates_queue_status_if_none_exists(self):
"""When no QueueStatus exists, _increment_queue_count creates one."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.query.return_value.first.return_value = None
service = UserQueueService(mock_session)
with patch(
"local_deep_research.database.queue_service.TaskMetadata"
) as MockTaskMetadata:
MockTaskMetadata.return_value = Mock()
with patch(
"local_deep_research.database.queue_service.QueueStatus"
) as MockQueueStatus:
mock_new_status = Mock()
mock_new_status.queued_tasks = 0
MockQueueStatus.return_value = mock_new_status
service.add_task_metadata("task-new", "research")
# QueueStatus should have been created
MockQueueStatus.assert_called_once_with(
queued_tasks=0, active_tasks=0
)
# And queued_tasks incremented from 0 to 1
assert mock_new_status.queued_tasks == 1
class TestUpdateTaskStatusExtended:
"""Extended tests for update_task_status transitions."""
def _make_service_with_task(self, task_mock, status_mock):
"""Helper to set up a service with a findable task and status."""
mock_session = Mock()
mock_filter = Mock()
mock_filter.first.return_value = task_mock
mock_task_query = Mock()
mock_task_query.filter_by.return_value = mock_filter
def query_side_effect(model):
if hasattr(model, "task_id"): # TaskMetadata
return mock_task_query
# QueueStatus query for _get_or_create_status
return Mock(first=Mock(return_value=status_mock))
mock_session.query.side_effect = query_side_effect
return mock_session
def test_queued_to_processing_sets_started_at_and_adjusts_counts(self):
"""Transition queued->processing should set started_at and adjust counts."""
from local_deep_research.database.queue_service import UserQueueService
mock_task = Mock()
mock_task.status = "queued"
mock_task.started_at = None
mock_status = Mock()
mock_status.queued_tasks = 5
mock_status.active_tasks = 2
mock_session = self._make_service_with_task(mock_task, mock_status)
service = UserQueueService(mock_session)
service.update_task_status("task-1", "processing")
assert mock_task.status == "processing"
assert mock_task.started_at is not None
# queued: max(0, 5 + (-1)) = 4, active: max(0, 2 + 1) = 3
assert mock_status.queued_tasks == 4
assert mock_status.active_tasks == 3
mock_session.commit.assert_called_once()
def test_processing_to_completed_sets_completed_at_and_adjusts_counts(self):
"""Transition processing->completed should set completed_at and decrement active."""
from local_deep_research.database.queue_service import UserQueueService
mock_task = Mock()
mock_task.status = "processing"
mock_task.completed_at = None
mock_status = Mock()
mock_status.queued_tasks = 3
mock_status.active_tasks = 2
mock_session = self._make_service_with_task(mock_task, mock_status)
service = UserQueueService(mock_session)
service.update_task_status("task-1", "completed")
assert mock_task.status == "completed"
assert mock_task.completed_at is not None
assert mock_task.error_message is None
# queued: max(0, 3 + 0) = 3, active: max(0, 2 + (-1)) = 1
assert mock_status.queued_tasks == 3
assert mock_status.active_tasks == 1
mock_session.commit.assert_called_once()
def test_processing_to_failed_sets_completed_at_and_error_message(self):
"""Transition processing->failed should set completed_at, error_message, and decrement active."""
from local_deep_research.database.queue_service import UserQueueService
mock_task = Mock()
mock_task.status = "processing"
mock_task.completed_at = None
mock_task.error_message = None
mock_status = Mock()
mock_status.queued_tasks = 0
mock_status.active_tasks = 1
mock_session = self._make_service_with_task(mock_task, mock_status)
service = UserQueueService(mock_session)
service.update_task_status("task-1", "failed", "Timeout exceeded")
assert mock_task.status == "failed"
assert mock_task.error_message == "Timeout exceeded"
assert mock_task.completed_at is not None
# active: max(0, 1 + (-1)) = 0
assert mock_status.active_tasks == 0
mock_session.commit.assert_called_once()
def test_task_not_found_does_nothing(self):
"""When task_id does not match any task, should do nothing."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_filter = Mock()
mock_filter.first.return_value = None
mock_query = Mock()
mock_query.filter_by.return_value = mock_filter
mock_session.query.return_value = mock_query
service = UserQueueService(mock_session)
service.update_task_status("nonexistent-task", "completed")
# Should not call commit since task was not found
mock_session.commit.assert_not_called()
def test_processing_to_completed_with_no_error_message(self):
"""Completing a task without error should set error_message to None."""
from local_deep_research.database.queue_service import UserQueueService
mock_task = Mock()
mock_task.status = "processing"
mock_task.error_message = "old error"
mock_status = Mock()
mock_status.queued_tasks = 0
mock_status.active_tasks = 1
mock_session = self._make_service_with_task(mock_task, mock_status)
service = UserQueueService(mock_session)
service.update_task_status("task-1", "completed")
# error_message should be set to None (the default)
assert mock_task.error_message is None
def test_non_transition_status_update(self):
"""Setting a status that doesn't match queued->processing or completed/failed
should still update the status but not touch timestamps or counts."""
from local_deep_research.database.queue_service import UserQueueService
mock_task = Mock()
mock_task.status = "processing"
mock_status = Mock()
mock_status.queued_tasks = 2
mock_status.active_tasks = 3
mock_session = self._make_service_with_task(mock_task, mock_status)
service = UserQueueService(mock_session)
# A custom status that doesn't trigger any branch
service.update_task_status("task-1", "cancelled")
assert mock_task.status == "cancelled"
# Counts should be unchanged since "cancelled" doesn't match any branch
assert mock_status.queued_tasks == 2
assert mock_status.active_tasks == 3
class TestGetPendingTasksExtended:
"""Extended tests for get_pending_tasks method."""
def test_returns_ordered_list_of_task_dicts(self):
"""Should return list of dicts with task_id, task_type, created_at, priority."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
now = datetime.now(UTC)
mock_task1 = Mock()
mock_task1.task_id = "high-priority"
mock_task1.task_type = "research"
mock_task1.created_at = now
mock_task1.priority = 10
mock_task2 = Mock()
mock_task2.task_id = "low-priority"
mock_task2.task_type = "benchmark"
mock_task2.created_at = now
mock_task2.priority = 1
mock_query = mock_session.query.return_value
chain = mock_query.filter_by.return_value.order_by.return_value.limit.return_value
chain.all.return_value = [mock_task1, mock_task2]
service = UserQueueService(mock_session)
result = service.get_pending_tasks(limit=10)
assert len(result) == 2
assert result[0] == {
"task_id": "high-priority",
"task_type": "research",
"created_at": now,
"priority": 10,
}
assert result[1] == {
"task_id": "low-priority",
"task_type": "benchmark",
"created_at": now,
"priority": 1,
}
def test_returns_empty_list_when_no_pending_tasks(self):
"""Should return empty list when there are no queued tasks."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_query = mock_session.query.return_value
chain = mock_query.filter_by.return_value.order_by.return_value.limit.return_value
chain.all.return_value = []
service = UserQueueService(mock_session)
result = service.get_pending_tasks()
assert result == []
def test_respects_limit_parameter(self):
"""Should pass the limit to the query chain."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_query = mock_session.query.return_value
mock_order = mock_query.filter_by.return_value.order_by.return_value
mock_order.limit.return_value.all.return_value = []
service = UserQueueService(mock_session)
service.get_pending_tasks(limit=5)
mock_order.limit.assert_called_once_with(5)
class TestCleanupOldTasksExtended:
"""Extended tests for cleanup_old_tasks method."""
def test_deletes_old_completed_and_failed_tasks(self):
"""Should delete tasks that are completed/failed and older than cutoff."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_query = mock_session.query.return_value
mock_query.filter.return_value.delete.return_value = 3
service = UserQueueService(mock_session)
result = service.cleanup_old_tasks(days=14)
assert result == 3
mock_session.commit.assert_called_once()
def test_returns_zero_when_nothing_to_delete(self):
"""Should return 0 when no tasks match the criteria."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_query = mock_session.query.return_value
mock_query.filter.return_value.delete.return_value = 0
service = UserQueueService(mock_session)
result = service.cleanup_old_tasks(days=7)
assert result == 0
mock_session.commit.assert_called_once()
def test_uses_correct_default_days(self):
"""Default days parameter should be 7 -- just verify it works without args."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_query = mock_session.query.return_value
mock_query.filter.return_value.delete.return_value = 0
service = UserQueueService(mock_session)
# Call with no arguments to exercise the default days=7
result = service.cleanup_old_tasks()
assert result == 0
mock_session.commit.assert_called_once()
class TestUpdateQueueCountsExtended:
"""Extended tests for _update_queue_counts negative value protection."""
def test_prevents_negative_queued_tasks(self):
"""max(0, ...) should prevent queued_tasks from going negative."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 2
mock_status.active_tasks = 5
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service._update_queue_counts(-10, 0)
assert mock_status.queued_tasks == 0
assert mock_status.active_tasks == 5
def test_prevents_negative_active_tasks(self):
"""max(0, ...) should prevent active_tasks from going negative."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 3
mock_status.active_tasks = 1
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service._update_queue_counts(0, -100)
assert mock_status.queued_tasks == 3
assert mock_status.active_tasks == 0
def test_prevents_both_negative(self):
"""Both counts should be clamped to zero simultaneously."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 1
mock_status.active_tasks = 1
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service._update_queue_counts(-999, -999)
assert mock_status.queued_tasks == 0
assert mock_status.active_tasks == 0
def test_positive_deltas_work_normally(self):
"""Positive deltas should increase counts normally."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 5
mock_status.active_tasks = 3
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service._update_queue_counts(2, 4)
assert mock_status.queued_tasks == 7
assert mock_status.active_tasks == 7
def test_sets_last_checked_timestamp(self):
"""Should update last_checked when updating counts."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_status.queued_tasks = 0
mock_status.active_tasks = 0
mock_status.last_checked = None
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
service._update_queue_counts(1, 1)
assert mock_status.last_checked is not None
def test_creates_status_if_none_exists(self):
"""When no QueueStatus exists, should create one before updating."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.query.return_value.first.return_value = None
service = UserQueueService(mock_session)
with patch(
"local_deep_research.database.queue_service.QueueStatus"
) as MockQueueStatus:
mock_new_status = Mock()
mock_new_status.queued_tasks = 0
mock_new_status.active_tasks = 0
MockQueueStatus.return_value = mock_new_status
service._update_queue_counts(3, 2)
MockQueueStatus.assert_called_once_with(
queued_tasks=0, active_tasks=0
)
mock_session.add.assert_called_once_with(mock_new_status)
assert mock_new_status.queued_tasks == 3
assert mock_new_status.active_tasks == 2
class TestGetOrCreateStatus:
"""Tests for _get_or_create_status method."""
def test_returns_existing_status(self):
"""Should return existing QueueStatus when one exists."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_status = Mock()
mock_session.query.return_value.first.return_value = mock_status
service = UserQueueService(mock_session)
result = service._get_or_create_status()
assert result is mock_status
mock_session.add.assert_not_called()
def test_creates_new_status_with_zero_counts(self):
"""When no status exists, should create one with zero counts."""
from local_deep_research.database.queue_service import UserQueueService
mock_session = Mock()
mock_session.query.return_value.first.return_value = None
service = UserQueueService(mock_session)
with patch(
"local_deep_research.database.queue_service.QueueStatus"
) as MockQueueStatus:
mock_new_status = Mock()
MockQueueStatus.return_value = mock_new_status
result = service._get_or_create_status()
MockQueueStatus.assert_called_once_with(
queued_tasks=0, active_tasks=0
)
mock_session.add.assert_called_once_with(mock_new_status)
assert result is mock_new_status
+438
View File
@@ -0,0 +1,438 @@
"""Tests for rate limiting database models."""
import time
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import (
Base,
RateLimitAttempt,
RateLimitEstimate,
)
class TestRateLimitingModels:
"""Test suite for rate limiting models."""
@pytest.fixture
def engine(self):
"""Create an in-memory SQLite database for testing."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(self, engine):
"""Create a database session for testing."""
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_rate_limit_attempt_creation(self, session):
"""Test creating rate limit attempt records."""
attempt = RateLimitAttempt(
engine_type="google",
timestamp=time.time(),
wait_time=2.5,
retry_count=1,
success=True,
error_type=None,
)
session.add(attempt)
session.commit()
# Verify attempt
saved = session.query(RateLimitAttempt).first()
assert saved is not None
assert saved.engine_type == "google"
assert saved.wait_time == 2.5
assert saved.retry_count == 1
assert saved.success is True
assert saved.error_type is None
def test_failed_rate_limit_attempts(self, session):
"""Test tracking failed rate limit attempts."""
current_time = time.time()
# Successful attempt
success = RateLimitAttempt(
engine_type="bing",
timestamp=current_time,
wait_time=1.0,
retry_count=0,
success=True,
)
# Failed attempt - too fast
too_fast = RateLimitAttempt(
engine_type="bing",
timestamp=current_time + 1,
wait_time=0.5,
retry_count=1,
success=False,
error_type="rate_limit",
)
# Failed attempt - other error
other_error = RateLimitAttempt(
engine_type="bing",
timestamp=current_time + 2,
wait_time=2.0,
retry_count=2,
success=False,
error_type="connection",
)
session.add_all([success, too_fast, other_error])
session.commit()
# Analyze attempts
all_attempts = (
session.query(RateLimitAttempt).filter_by(engine_type="bing").all()
)
assert len(all_attempts) == 3
failed = session.query(RateLimitAttempt).filter_by(success=False).all()
assert len(failed) == 2
# Check error types
rate_limit_errors = (
session.query(RateLimitAttempt)
.filter_by(error_type="rate_limit")
.count()
)
assert rate_limit_errors == 1
def test_rate_limit_estimate(self, session):
"""Test rate limit estimate storage and updates."""
estimate = RateLimitEstimate(
engine_type="duckduckgo",
base_wait_seconds=1.5,
min_wait_seconds=0.5,
max_wait_seconds=10.0,
last_updated=time.time(),
total_attempts=100,
success_rate=0.85,
)
session.add(estimate)
session.commit()
# Verify estimate
saved = (
session.query(RateLimitEstimate)
.filter_by(engine_type="duckduckgo")
.first()
)
assert saved is not None
assert saved.base_wait_seconds == 1.5
assert saved.success_rate == 0.85
assert saved.total_attempts == 100
def test_multiple_engine_tracking(self, session):
"""Test tracking rate limits for multiple search engines."""
engines = [
("google", 1.0, 0.5, 5.0, 0.9),
("bing", 0.8, 0.3, 3.0, 0.92),
("duckduckgo", 0.5, 0.1, 2.0, 0.95),
("searx", 2.0, 1.0, 10.0, 0.8),
]
current_time = time.time()
for engine, base, min_val, max_val, success_rate in engines:
estimate = RateLimitEstimate(
engine_type=engine,
base_wait_seconds=base,
min_wait_seconds=min_val,
max_wait_seconds=max_val,
last_updated=current_time,
total_attempts=50,
success_rate=success_rate,
)
session.add(estimate)
session.commit()
# Verify all engines
all_estimates = session.query(RateLimitEstimate).all()
assert len(all_estimates) == 4
# Find most reliable engine
most_reliable = (
session.query(RateLimitEstimate)
.order_by(RateLimitEstimate.success_rate.desc())
.first()
)
assert most_reliable.engine_type == "duckduckgo"
def test_adaptive_rate_learning(self, session):
"""Test updating rate limit estimates based on attempts."""
# Initial estimate
estimate = RateLimitEstimate(
engine_type="adaptive_test",
base_wait_seconds=1.0,
min_wait_seconds=0.5,
max_wait_seconds=5.0,
last_updated=time.time(),
total_attempts=10,
success_rate=0.8,
)
session.add(estimate)
session.commit()
# Simulate attempts
current_time = time.time()
attempts = [
(0.5, False), # Too fast
(0.8, False), # Still too fast
(1.2, True), # Success
(1.1, True), # Success
(1.0, True), # Success
(0.9, False), # Too fast again
(1.1, True), # Success
]
for i, (wait_time, success) in enumerate(attempts):
attempt = RateLimitAttempt(
engine_type="adaptive_test",
timestamp=current_time + i,
wait_time=wait_time,
retry_count=0 if success else 1,
success=success,
error_type=None if success else "rate_limit",
)
session.add(attempt)
session.commit()
# Update estimate based on attempts
successful_waits = (
session.query(RateLimitAttempt.wait_time)
.filter_by(engine_type="adaptive_test", success=True)
.all()
)
if successful_waits:
avg_successful_wait = sum(w[0] for w in successful_waits) / len(
successful_waits
)
estimate.base_wait_seconds = avg_successful_wait
estimate.total_attempts += len(attempts)
estimate.success_rate = len(successful_waits) / len(attempts)
estimate.last_updated = time.time()
session.commit()
# Verify updated estimate
updated = (
session.query(RateLimitEstimate)
.filter_by(engine_type="adaptive_test")
.first()
)
assert (
updated.base_wait_seconds > 1.0
) # Should increase based on attempts
assert updated.total_attempts == 17 # 10 + 7
assert updated.success_rate == 4 / 7 # 4 successes out of 7 attempts
def test_time_based_patterns(self, session):
"""Test identifying time-based rate limit patterns."""
# Simulate different success rates at different times
base_time = time.time()
# Morning hours - higher success rate
for i in range(10):
attempt = RateLimitAttempt(
engine_type="time_pattern",
timestamp=base_time + i * 60, # Every minute
wait_time=1.0,
retry_count=0,
success=i % 3 != 0, # 66% success
error_type=None if i % 3 != 0 else "rate_limit",
)
session.add(attempt)
# Afternoon - lower success rate
for i in range(10):
attempt = RateLimitAttempt(
engine_type="time_pattern",
timestamp=base_time + 3600 + i * 60, # 1 hour later
wait_time=1.0,
retry_count=0,
success=i % 2 == 0, # 50% success
error_type=None if i % 2 == 0 else "rate_limit",
)
session.add(attempt)
session.commit()
# Analyze patterns
morning_attempts = (
session.query(RateLimitAttempt)
.filter(
RateLimitAttempt.engine_type == "time_pattern",
RateLimitAttempt.timestamp < base_time + 3600,
)
.all()
)
afternoon_attempts = (
session.query(RateLimitAttempt)
.filter(
RateLimitAttempt.engine_type == "time_pattern",
RateLimitAttempt.timestamp >= base_time + 3600,
)
.all()
)
morning_success_rate = sum(
1 for a in morning_attempts if a.success
) / len(morning_attempts)
afternoon_success_rate = sum(
1 for a in afternoon_attempts if a.success
) / len(afternoon_attempts)
assert morning_success_rate > afternoon_success_rate
def test_estimate_updates(self, session):
"""Test updating rate limit estimates."""
# Create initial estimate
estimate = RateLimitEstimate(
engine_type="update_test",
base_wait_seconds=1.0,
min_wait_seconds=0.5,
max_wait_seconds=5.0,
last_updated=time.time() - 3600, # 1 hour ago
total_attempts=50,
success_rate=0.8,
)
session.add(estimate)
session.commit()
# Update estimate
estimate.base_wait_seconds = 1.5
estimate.success_rate = 0.85
estimate.total_attempts = 75
estimate.last_updated = time.time()
session.commit()
# Verify updates
updated = (
session.query(RateLimitEstimate)
.filter_by(engine_type="update_test")
.first()
)
assert updated.base_wait_seconds == 1.5
assert updated.success_rate == 0.85
assert updated.total_attempts == 75
assert updated.last_updated > time.time() - 60 # Updated recently
def test_cleanup_old_attempts(self, session):
"""Test cleaning up old rate limit attempts."""
current_time = time.time()
# Create attempts at different ages
for days_ago in range(10):
attempt = RateLimitAttempt(
engine_type="cleanup_test",
timestamp=current_time - (days_ago * 86400), # Days in seconds
wait_time=1.0,
retry_count=0,
success=True,
)
session.add(attempt)
session.commit()
# Count old attempts (older than 7 days)
seven_days_ago = current_time - (7 * 86400)
old_attempts = (
session.query(RateLimitAttempt)
.filter(RateLimitAttempt.timestamp < seven_days_ago)
.count()
)
# For days_ago in [7, 8, 9], which are all > 7 days ago
# But since timestamp is current_time - (days_ago * 86400)
# Only days 8 and 9 are actually older than 7 days
assert old_attempts == 2 # Days 8, 9
# Delete old attempts
session.query(RateLimitAttempt).filter(
RateLimitAttempt.timestamp < seven_days_ago
).delete()
session.commit()
# Verify cleanup
remaining = session.query(RateLimitAttempt).count()
assert remaining == 8 # 10 total - 2 deleted = 8
def test_rate_limit_metadata(self, session):
"""Test storing metadata with attempts."""
attempt = RateLimitAttempt(
engine_type="metadata_test",
timestamp=time.time(),
wait_time=2.0,
retry_count=1,
success=True,
error_type=None,
)
session.add(attempt)
session.commit()
# Verify
saved = session.query(RateLimitAttempt).first()
assert saved.engine_type == "metadata_test"
assert saved.created_at is not None # Auto-populated
def test_concurrent_engine_limits(self, session):
"""Test tracking concurrent rate limits for multiple engines."""
current_time = time.time()
# Create attempts for multiple engines at the same time
engines = ["google", "bing", "duckduckgo"]
for engine in engines:
for i in range(5):
attempt = RateLimitAttempt(
engine_type=engine,
timestamp=current_time + i,
wait_time=1.0 + i * 0.1,
retry_count=0,
success=True,
)
session.add(attempt)
session.commit()
# Verify each engine has its own attempts
for engine in engines:
count = (
session.query(RateLimitAttempt)
.filter_by(engine_type=engine)
.count()
)
assert count == 5
# Get latest attempt per engine
from sqlalchemy import func
latest_per_engine = (
session.query(
RateLimitAttempt.engine_type,
func.max(RateLimitAttempt.timestamp).label("latest"),
)
.group_by(RateLimitAttempt.engine_type)
.all()
)
assert len(latest_per_engine) == 3
+419
View File
@@ -0,0 +1,419 @@
"""Tests for research-related database models."""
import uuid
from datetime import datetime, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import (
Base,
Research,
ResearchHistory,
ResearchMode,
ResearchResource,
ResearchStatus,
ResearchStrategy,
ResearchTask,
SearchQuery,
SearchResult,
)
class TestResearchModels:
"""Test suite for research-related models."""
@pytest.fixture
def engine(self):
"""Create an in-memory SQLite database for testing."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(self, engine):
"""Create a database session for testing."""
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_research_history_creation(self, session):
"""Test creating a ResearchHistory record."""
research = ResearchHistory(
id=str(uuid.uuid4()),
query="What is quantum computing?",
mode="comprehensive",
status="completed",
created_at="2024-01-01T12:00:00",
completed_at="2024-01-01T12:05:00",
duration_seconds=300,
progress=100,
report_path="/reports/quantum_computing.md",
title="Quantum Computing Research",
research_meta={"sources": 10, "quality": "high"},
)
session.add(research)
session.commit()
# Verify the record
saved = session.query(ResearchHistory).first()
assert saved is not None
assert saved.query == "What is quantum computing?"
assert saved.mode == "comprehensive"
assert saved.status == "completed"
assert saved.duration_seconds == 300
assert saved.progress == 100
assert saved.research_meta["sources"] == 10
def test_research_status_enum(self, session):
"""Test Research model with ResearchStatus enum."""
# Create Research with enum status
research = Research(
query="Test query",
status=ResearchStatus.PENDING,
mode=ResearchMode.QUICK,
)
session.add(research)
session.commit()
# Test status transitions
research.status = ResearchStatus.IN_PROGRESS
session.commit()
assert research.status == ResearchStatus.IN_PROGRESS
# Test all status values
for status in ResearchStatus:
r = Research(
query=f"Test {status.value}",
status=status,
mode=ResearchMode.QUICK,
)
session.add(r)
session.commit()
# Verify all statuses
all_research = session.query(Research).all()
assert len(all_research) >= len(ResearchStatus)
def test_research_progress_log(self, session):
"""Test progress log JSON field in ResearchHistory."""
progress_log = {
"steps": [
{"time": "2024-01-01T12:00:00", "message": "Starting research"},
{"time": "2024-01-01T12:01:00", "message": "Searching sources"},
{"time": "2024-01-01T12:03:00", "message": "Analyzing results"},
{"time": "2024-01-01T12:05:00", "message": "Generating report"},
],
"current_step": 4,
"total_steps": 4,
}
research = ResearchHistory(
id=str(uuid.uuid4()),
query="AI research",
mode="normal",
status="completed",
created_at="2024-01-01T12:00:00",
progress_log=progress_log,
progress=100,
)
session.add(research)
session.commit()
# Verify progress log
saved = session.query(ResearchHistory).first()
assert saved.progress_log is not None
assert len(saved.progress_log["steps"]) == 4
assert saved.progress_log["current_step"] == 4
def test_research_task_creation(self, session):
"""Test ResearchTask model."""
task = ResearchTask(
title="Machine Learning Research",
description="Research current ML trends and applications",
status="in_progress",
priority=5,
tags=["ml", "ai", "trends"],
research_metadata={
"estimated_time": "2 hours",
"complexity": "medium",
},
)
session.add(task)
session.commit()
# Verify task
saved = session.query(ResearchTask).first()
assert saved.title == "Machine Learning Research"
assert saved.priority == 5
assert "ml" in saved.tags
assert saved.research_metadata["complexity"] == "medium"
def test_search_query_and_results(self, session):
"""Test SearchQuery and SearchResult models."""
# Create research task first
task = ResearchTask(
title="Test Research", description="Test", status="pending"
)
session.add(task)
session.commit()
# Create search query
query = SearchQuery(
research_task_id=task.id,
query="quantum computing applications",
search_engine="google",
search_type="web",
parameters={"num_results": 10, "time_range": "past_year"},
status="completed",
)
session.add(query)
session.commit()
# Add search results
results = [
SearchResult(
research_task_id=task.id,
search_query_id=query.id,
title="Quantum Computing in 2024",
url="https://example.com/quantum-2024",
snippet="Latest developments in quantum computing...",
relevance_score=0.95,
content="Full article content here...",
content_type="article",
position=1,
domain="example.com",
language="en",
author="Dr. Smith",
fetch_status="fetched",
),
SearchResult(
research_task_id=task.id,
search_query_id=query.id,
title="Practical Quantum Applications",
url="https://example.com/quantum-apps",
snippet="Real-world applications of quantum tech...",
relevance_score=0.87,
position=2,
domain="example.com",
fetch_status="pending",
),
]
session.add_all(results)
session.commit()
# Verify relationships
assert len(query.results) == 2
assert query.results[0].relevance_score == 0.95
assert task.searches[0].query == "quantum computing applications"
def test_research_strategy(self, session):
"""Test ResearchStrategy model.
ResearchStrategy.research_id is a String(36) FK to research_history.id —
the live UUID-keyed table. The legacy Integer FK to the dormant
``research`` table caused FOREIGN KEY constraint failed once
PRAGMA foreign_keys was enabled in v1.6.0; migration 0008 retargets it.
"""
history_id = str(uuid.uuid4())
history = ResearchHistory(
id=history_id,
query="Climate change solutions",
mode="detailed",
status="completed",
created_at="2026-01-01T10:00:00",
)
session.add(history)
session.commit()
strategy = ResearchStrategy(
research_id=history_id, strategy_name="comprehensive_search"
)
session.add(strategy)
session.commit()
saved = session.query(ResearchStrategy).first()
assert saved.strategy_name == "comprehensive_search"
assert saved.research_id == history_id
def test_research_relationships(self, session):
"""Test relationships between research models."""
# Create research history
history = ResearchHistory(
id=str(uuid.uuid4()),
query="AI Ethics",
mode="comprehensive",
status="completed",
created_at="2024-01-01T10:00:00",
)
session.add(history)
session.commit()
# Add resources
resources = [
ResearchResource(
research_id=history.id,
title="AI Ethics Guidelines",
url="https://example.com/ai-ethics",
content_preview="Guidelines for ethical AI development...",
source_type="article",
resource_metadata={"credibility": "high"},
created_at="2024-01-01T10:30:00",
),
ResearchResource(
research_id=history.id,
title="Ethics in Machine Learning",
url="https://example.com/ml-ethics",
content_preview="Exploring ethical considerations in ML...",
source_type="research_paper",
resource_metadata={"peer_reviewed": True},
created_at="2024-01-01T10:45:00",
),
]
session.add_all(resources)
session.commit()
# Verify relationships
assert len(history.resources) == 2
assert history.resources[0].title == "AI Ethics Guidelines"
# Test cascade delete
session.delete(history)
session.commit()
# Resources should be deleted too
remaining_resources = session.query(ResearchResource).count()
assert remaining_resources == 0
def test_research_metadata_handling(self, session):
"""Test JSON metadata fields across models."""
# ResearchHistory with complex metadata
history = ResearchHistory(
id=str(uuid.uuid4()),
query="Complex research",
mode="normal",
status="completed",
created_at="2024-01-01T00:00:00",
research_meta={
"sources": {"academic": 5, "news": 10, "blogs": 3},
"quality_metrics": {
"relevance": 0.85,
"credibility": 0.9,
"recency": 0.95,
},
"search_iterations": 3,
"total_sources_examined": 50,
},
)
session.add(history)
session.commit()
# Verify complex metadata
saved = session.query(ResearchHistory).first()
assert saved.research_meta["sources"]["academic"] == 5
assert saved.research_meta["quality_metrics"]["relevance"] == 0.85
def test_search_result_content(self, session):
"""Test SearchResult content storage and retrieval."""
# Create parent objects
task = ResearchTask(
title="Content Test",
description="Testing content storage",
status="pending",
)
session.add(task)
session.commit()
query = SearchQuery(
research_task_id=task.id,
query="test query",
search_engine="google",
status="completed",
)
session.add(query)
session.commit()
# Large content
large_content = "x" * 10000 # 10KB of content
result = SearchResult(
research_task_id=task.id,
search_query_id=query.id,
title="Large Article",
url="https://example.com/large",
snippet="Beginning of large article...",
content=large_content,
content_type="article",
relevance_score=0.8,
position=1,
domain="example.com",
language="en",
fetch_status="fetched",
fetched_at=datetime.now(timezone.utc),
)
session.add(result)
session.commit()
# Verify content
saved = session.query(SearchResult).first()
assert len(saved.content) == 10000
assert saved.fetch_status == "fetched"
assert saved.domain == "example.com"
def test_research_error_handling(self, session):
"""Test error tracking in research models."""
# Failed research
failed_research = ResearchHistory(
id=str(uuid.uuid4()),
query="This will fail",
mode="quick",
status="failed",
created_at="2024-01-01T00:00:00",
research_meta={
"error": "NetworkError",
"error_message": "Connection timeout",
"retry_count": 3,
"last_error_timestamp": "2024-01-01T00:05:00",
},
)
session.add(failed_research)
session.commit()
# Failed search query
task = ResearchTask(
title="Error Test", description="Test", status="failed"
)
session.add(task)
session.commit()
failed_query = SearchQuery(
research_task_id=task.id,
query="problematic query",
search_engine="bing",
status="failed",
error_message="Rate limit exceeded",
retry_count=5,
)
session.add(failed_query)
session.commit()
# Verify error tracking
assert failed_research.research_meta["error"] == "NetworkError"
assert failed_query.error_message == "Rate limit exceeded"
assert failed_query.retry_count == 5
@@ -0,0 +1,150 @@
"""Regression tests for the ResearchStrategy FK retarget (migration 0008).
Pre-fix: ``ResearchStrategy.research_id`` was ``Integer FK research.id``,
pointing at a dormant table that no production path uses. The live writer
``save_research_strategy`` passes ``research_history`` UUID strings, so
every commit raised ``FOREIGN KEY constraint failed`` once v1.6.0 enabled
``PRAGMA foreign_keys``.
Post-fix: the FK targets ``research_history.id`` (String(36)) with cascade
delete. This module verifies both the schema-level repair and the live
behavior the production code depends on.
"""
from __future__ import annotations
import pytest
from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.orm import Session
from local_deep_research.database.alembic_runner import run_migrations
from local_deep_research.database.models.research import ResearchStrategy
@pytest.fixture
def fk_enforced_engine(tmp_path):
"""Migrated engine with PRAGMA foreign_keys=ON for every connection.
Mirrors the production hook ``apply_performance_pragmas`` so the test
actually exercises FK enforcement (off by default in a bare SQLite
connection).
"""
db_path = tmp_path / "research_strategy_fk_regression.db"
engine = create_engine(f"sqlite:///{db_path}")
@event.listens_for(engine, "connect")
def _enable_fk(dbapi_connection, _):
dbapi_connection.execute("PRAGMA foreign_keys = ON")
run_migrations(engine)
yield engine
engine.dispose()
def _seed_research_history(engine, research_id: str) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history (id, query, mode, status, created_at) "
"VALUES (:id, 'q', 'quick_summary', 'completed', '2026-01-01')"
),
{"id": research_id},
)
class TestResearchStrategyFKTarget:
"""Schema-level: column type and FK target match the live writer."""
def test_research_id_is_varchar_36_fk_research_history(
self, fk_enforced_engine
):
inspector = inspect(fk_enforced_engine)
cols = {
c["name"]: c for c in inspector.get_columns("research_strategies")
}
assert "VARCHAR" in str(cols["research_id"]["type"]) or "CHAR" in str(
cols["research_id"]["type"]
)
fks = inspector.get_foreign_keys("research_strategies")
research_id_fks = [
fk for fk in fks if "research_id" in fk["constrained_columns"]
]
assert len(research_id_fks) == 1
assert research_id_fks[0]["referred_table"] == "research_history"
assert research_id_fks[0]["referred_columns"] == ["id"]
def test_cascade_on_delete(self, fk_enforced_engine):
# Read the on_delete action from PRAGMA directly. SQLAlchemy's SQLite
# reflection has historically been inconsistent about populating
# ``options.ondelete``; PRAGMA always returns it.
with fk_enforced_engine.connect() as conn:
rows = conn.execute(
text("PRAGMA foreign_key_list(research_strategies)")
).fetchall()
research_id_fk = next(row for row in rows if row[3] == "research_id")
# PRAGMA columns: id, seq, table, from, to, on_update, on_delete, match
assert research_id_fk[6] == "CASCADE"
class TestResearchStrategyLiveWrite:
"""Behavior: the live save path no longer raises FK errors."""
def test_save_strategy_with_research_history_uuid_succeeds(
self, fk_enforced_engine
):
"""The exact pattern from save_research_strategy: insert by UUID."""
rid = "11111111-1111-1111-1111-111111111111"
_seed_research_history(fk_enforced_engine, rid)
with Session(fk_enforced_engine) as session:
session.add(
ResearchStrategy(
research_id=rid, strategy_name="langgraph-agent"
)
)
session.commit()
with Session(fk_enforced_engine) as session:
stored = (
session.query(ResearchStrategy).filter_by(research_id=rid).one()
)
assert stored.strategy_name == "langgraph-agent"
def test_orphan_strategy_insert_is_rejected(self, fk_enforced_engine):
"""FK enforcement is real: insert without parent row must fail."""
from sqlalchemy.exc import IntegrityError
with Session(fk_enforced_engine) as session:
session.add(
ResearchStrategy(
research_id="22222222-2222-2222-2222-222222222222",
strategy_name="x",
)
)
with pytest.raises(IntegrityError):
session.commit()
def test_cascade_delete_removes_strategy(self, fk_enforced_engine):
rid = "33333333-3333-3333-3333-333333333333"
_seed_research_history(fk_enforced_engine, rid)
with Session(fk_enforced_engine) as session:
session.add(
ResearchStrategy(research_id=rid, strategy_name="standard")
)
session.commit()
with fk_enforced_engine.begin() as conn:
conn.execute(
text("DELETE FROM research_history WHERE id = :id"),
{"id": rid},
)
with Session(fk_enforced_engine) as session:
assert (
session.query(ResearchStrategy)
.filter_by(research_id=rid)
.first()
is None
)
@@ -0,0 +1,112 @@
"""Regression guard for the encrypted-DB CreateIndex defect.
Until v1.6.x, ``encrypted_db.create_user_database()`` only emitted
``CreateTable`` statements and never ``CreateIndex``. As a result every
model-declared index (``index=True``, ``unique=True``, explicit
``Index(...)`` in ``__table_args__``) was missing in user databases.
This test asserts that for every table in ``Base.metadata``, every named
index declared by the model is present in a freshly created encrypted
database — both column-level and table-level.
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
import pytest
from sqlalchemy import inspect
pytest.importorskip(
"sqlcipher3",
reason="SQLCipher is required to test the encrypted-DB schema path.",
)
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 Base
@pytest.fixture
def temp_data_dir(monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir)
monkeypatch.setattr(
"local_deep_research.database.encrypted_db.get_data_directory",
lambda: path,
)
yield path
@pytest.fixture
def db_manager(temp_data_dir):
m = DatabaseManager()
yield m
for username in list(m.connections.keys()):
m.close_user_database(username)
def test_every_model_declared_index_exists_in_fresh_db(db_manager):
"""Iterate Base.metadata; assert every named index is present."""
assert db_manager.has_encryption, (
"sqlcipher3 imports but DatabaseManager reported has_encryption=False"
)
username = "indexcheck"
password = "StrongPassword1!"
db_manager.create_user_database(username, password)
engine = db_manager.connections[username]
inspector = inspect(engine)
missing: list[tuple[str, str]] = []
for table in Base.metadata.sorted_tables:
if table.name == "users":
continue
if not inspector.has_table(table.name):
continue
existing = {idx["name"] for idx in inspector.get_indexes(table.name)}
for index in table.indexes:
if not index.name:
continue
if index.name not in existing:
missing.append((table.name, index.name))
assert not missing, (
f"Missing model-declared indexes in fresh user DB: {missing}. "
f"This usually means encrypted_db.create_user_database() is not "
f"emitting CreateIndex for declared indexes."
)
def test_download_tracker_url_hash_has_unique_backing(db_manager):
"""The exact regression that caused #3697 — url_hash must be UNIQUE.
Without UNIQUE backing on the FK target, SQLCipher raises
"foreign key mismatch — download_attempts referencing download_tracker"
on cascade delete.
"""
assert db_manager.has_encryption
username = "trackercheck"
password = "StrongPassword1!"
db_manager.create_user_database(username, password)
engine = db_manager.connections[username]
inspector = inspect(engine)
indexes = inspector.get_indexes("download_tracker")
unique_on_url_hash = [
idx
for idx in indexes
if idx.get("unique") and idx.get("column_names") == ["url_hash"]
]
assert unique_on_url_hash, (
"download_tracker.url_hash must have a UNIQUE backing index for "
"FK references from download_attempts/download_duplicates to "
f"resolve. Indexes present: {indexes}"
)
+220
View File
@@ -0,0 +1,220 @@
"""
Tests for Database Schema Migrations
Phase 21: Database & Encryption - Schema Migration Tests
Tests database schema creation, versioning, and migrations.
"""
import pytest
class TestSchemaMigrations:
"""Tests for schema migration functionality"""
def test_initial_schema_creation(self):
"""Test initial schema is created correctly"""
from local_deep_research.database.models import Base
# Verify Base.metadata has tables defined
assert len(Base.metadata.tables) > 0
def test_migration_models_importable(self):
"""Test all model modules can be imported"""
# Import each model module to ensure no syntax errors
from local_deep_research.database.models import auth
from local_deep_research.database.models import research
from local_deep_research.database.models import settings
from local_deep_research.database.models import metrics
from local_deep_research.database.models import queue
assert auth is not None
assert research is not None
assert settings is not None
assert metrics is not None
assert queue is not None
def test_base_model_columns(self):
"""Test base model has expected columns"""
from local_deep_research.database.models.base import Base
# Base should be a declarative base
assert hasattr(Base, "metadata")
def test_auth_model_schema(self):
"""Test auth model schema"""
from local_deep_research.database.models.auth import User
# Check expected columns exist
columns = User.__table__.columns.keys()
assert "id" in columns
assert "username" in columns
# Note: passwords are NOT stored in this model - they decrypt user databases
def test_research_model_schema(self):
"""Test research model schema"""
from local_deep_research.database.models.research import ResearchHistory
columns = ResearchHistory.__table__.columns.keys()
assert "id" in columns
assert "query" in columns
assert "status" in columns
def test_settings_model_schema(self):
"""Test settings model schema"""
from local_deep_research.database.models.settings import Setting
columns = Setting.__table__.columns.keys()
assert "id" in columns
assert "key" in columns
assert "value" in columns
def test_metrics_model_schema(self):
"""Test metrics model schema"""
from local_deep_research.database.models.metrics import TokenUsage
columns = TokenUsage.__table__.columns.keys()
assert "id" in columns
def test_sorted_tables_order(self):
"""Test tables are sorted correctly for creation"""
from local_deep_research.database.models import Base
tables = Base.metadata.sorted_tables
# Should have multiple tables
assert len(tables) > 0
# Tables should be sorted by dependency order
table_names = [t.name for t in tables]
assert len(table_names) == len(set(table_names)) # No duplicates
class TestModelRelationships:
"""Tests for model relationships"""
def test_research_source_relationship(self):
"""Test research model has expected columns"""
from local_deep_research.database.models.research import ResearchHistory
# Check research history model exists and has expected columns
columns = ResearchHistory.__table__.columns.keys()
assert "id" in columns
assert "query" in columns
def test_queued_research_relationship(self):
"""Test queued research model"""
from local_deep_research.database.models.queued_research import (
QueuedResearch,
)
columns = QueuedResearch.__table__.columns.keys()
assert "id" in columns
assert "query" in columns
class TestDatabaseInitialization:
"""Tests for database initialization"""
def test_initialize_database_function_exists(self):
"""Test initialize_database function exists"""
from local_deep_research.database.initialize import initialize_database
assert callable(initialize_database)
def test_initialize_module_importable(self):
"""Test initialize module can be imported"""
from local_deep_research.database import initialize
assert hasattr(initialize, "initialize_database")
class TestConstraints:
"""Tests for database constraints"""
def test_unique_username_constraint(self):
"""Test unique username constraint on User model"""
from local_deep_research.database.models.auth import User
# Check for unique constraint on username
username_col = User.__table__.columns["username"]
assert username_col.unique is True
def test_setting_key_uniqueness(self):
"""Test setting key uniqueness"""
from local_deep_research.database.models.settings import Setting
# Key should be unique within user context
_key_col = Setting.__table__.columns["key"] # noqa: F841
# May have unique constraint or unique together with user_id
class TestColumnTypes:
"""Tests for column type definitions"""
def test_datetime_columns_have_timezone(self):
"""Test datetime columns use timezone-aware type"""
from local_deep_research.database.models.research import ResearchHistory
# Check created_at column exists
if "created_at" in ResearchHistory.__table__.columns:
created_col = ResearchHistory.__table__.columns["created_at"]
# Column should exist (type checking varies by dialect)
assert created_col is not None
def test_text_columns_for_long_content(self):
"""Test long content uses Text type"""
from local_deep_research.database.models.research import ResearchHistory
# Check report column uses Text
if "report" in ResearchHistory.__table__.columns:
report_col = ResearchHistory.__table__.columns["report"]
assert report_col is not None
def test_json_columns(self):
"""Test JSON column support"""
from local_deep_research.database.models.settings import Setting
# Settings may store JSON values
value_col = Setting.__table__.columns["value"]
assert value_col is not None
class TestIndexes:
"""Tests for database indexes"""
def test_primary_key_indexes(self):
"""Test primary key columns are indexed"""
from local_deep_research.database.models.auth import User
# Primary key should be indexed by default
id_col = User.__table__.columns["id"]
assert id_col.primary_key is True
class TestTableNames:
"""Tests for table naming conventions"""
def test_table_names_lowercase(self):
"""Test table names are lowercase"""
from local_deep_research.database.models import Base
for table in Base.metadata.tables.values():
assert table.name == table.name.lower()
def test_no_reserved_keywords(self):
"""Test no reserved SQL keywords used as table names"""
reserved = {"user", "order", "group", "select", "table", "index"}
from local_deep_research.database.models import Base
for table in Base.metadata.tables.values():
# 'users' is fine, 'user' is reserved
if table.name in reserved:
pytest.fail(
f"Reserved keyword used as table name: {table.name}"
)
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""
Test that database schema (table names) never changes unexpectedly.
Renaming or removing tables will cause data loss for existing users.
These tests catch accidental schema changes before they're deployed.
"""
import pytest
# Expected table names - DO NOT REMOVE ANY
# Adding new tables is fine, but removing/renaming breaks existing databases
EXPECTED_TABLES = {
# Auth (in auth.db, not user dbs)
"users",
# Settings
"api_keys",
"settings",
"user_settings",
# Queue
"queue_status",
"task_metadata",
# Research
"research",
"research_history",
"research_resources",
"research_strategies",
"research_tasks",
"search_queries",
"search_results",
"queued_researches",
"user_active_researches",
# Reports
"reports",
"report_sections",
# Library
"collections",
"collection_folders",
"collection_folder_files",
"documents",
"document_blobs",
"document_chunks",
"document_collections",
"download_queue",
"library_statistics",
"rag_document_status",
"rag_indices",
"source_types",
"upload_batches",
# Download tracking
"download_tracker",
"download_duplicates",
"download_attempts",
# Metrics
"token_usage",
"model_usage",
"research_ratings",
"search_calls",
# News
"news_cards",
"news_interests",
"news_subscriptions",
"news_user_preferences",
"news_user_ratings",
"subscription_folders",
"user_news_search_history",
# File integrity
"file_integrity_records",
"file_verification_failures",
# Providers
"provider_models",
# Rate limiting
"rate_limit_attempts",
"rate_limit_estimates",
# Domain classification
"domain_classifications",
# Logs
"app_logs",
"journals",
# Papers (deduplicated academic papers)
"papers",
"paper_appearances",
# Benchmark
"benchmark_configs",
"benchmark_progress",
"benchmark_results",
"benchmark_runs",
# Chat
"chat_sessions",
"chat_messages",
"chat_progress_steps",
# Zotero
"zotero_sync_state",
"zotero_item_map",
}
class TestSchemaStability:
"""
Verify that database table names haven't changed.
Renaming or removing tables will cause existing user databases
to lose data or fail to open properly.
"""
def test_no_tables_removed(self):
"""
Ensure no expected tables have been removed.
Removing a table definition will cause data loss when users
upgrade, as SQLAlchemy won't know how to access that data.
"""
from local_deep_research.database.models import Base
# Get all actual table names from the models
actual_tables = set(Base.metadata.tables.keys())
# Check that all expected tables still exist
missing_tables = EXPECTED_TABLES - actual_tables
assert not missing_tables, (
f"CRITICAL: Database tables have been removed!\n"
f"Missing tables: {missing_tables}\n\n"
"Removing tables will cause data loss for existing users.\n"
"If you intentionally removed these tables, you need a migration plan.\n"
"Otherwise, REVERT THIS CHANGE."
)
def test_no_tables_renamed(self):
"""
Detect if tables might have been renamed.
If new tables appear and expected tables are missing,
it's likely a rename which will cause data loss.
"""
from local_deep_research.database.models import Base
actual_tables = set(Base.metadata.tables.keys())
missing_tables = EXPECTED_TABLES - actual_tables
if missing_tables:
# Check if there are new tables that might be renames
new_tables = actual_tables - EXPECTED_TABLES
if new_tables:
pytest.fail(
f"Possible table rename detected!\n"
f"Missing: {missing_tables}\n"
f"New: {new_tables}\n\n"
"If you renamed tables, existing data will be lost.\n"
"You need a migration to copy data from old to new tables."
)
def test_new_tables_are_documented(self):
"""
Ensure any new tables are added to EXPECTED_TABLES.
This is a reminder to update this test when adding new tables.
New tables should be added to EXPECTED_TABLES to track them.
"""
from local_deep_research.database.models import Base
actual_tables = set(Base.metadata.tables.keys())
new_tables = actual_tables - EXPECTED_TABLES
# These are okay - just a reminder to update the test
if new_tables:
pytest.fail(
f"New tables detected that aren't in EXPECTED_TABLES:\n"
f"{new_tables}\n\n"
"Please add these to EXPECTED_TABLES in this test file.\n"
"This ensures they'll be protected from accidental removal."
)
class TestCriticalColumns:
"""
Verify that critical columns in key tables haven't been removed.
These are columns that store important user data.
"""
def test_user_settings_has_required_columns(self):
"""Verify UserSettings table has all required columns."""
from local_deep_research.database.models import UserSettings
required_columns = {"id", "key", "value", "category"}
actual_columns = set(UserSettings.__table__.columns.keys())
missing = required_columns - actual_columns
assert not missing, (
f"UserSettings is missing required columns: {missing}\n"
"This will break user settings storage."
)
def test_research_has_required_columns(self):
"""Verify Research table has all required columns."""
from local_deep_research.database.models.research import Research
required_columns = {"id", "query", "status", "mode", "created_at"}
actual_columns = set(Research.__table__.columns.keys())
missing = required_columns - actual_columns
assert not missing, (
f"Research is missing required columns: {missing}\n"
"This will break research history."
)
def test_api_keys_has_required_columns(self):
"""Verify APIKey table has all required columns."""
from local_deep_research.database.models import APIKey
required_columns = {"id", "provider", "key", "is_active"}
actual_columns = set(APIKey.__table__.columns.keys())
missing = required_columns - actual_columns
assert not missing, (
f"APIKey is missing required columns: {missing}\n"
"This will break API key storage."
)
def test_journal_has_exact_column_set(self):
"""Journal is an LLM-only cache; the column set is deliberately
minimal. Lock it down so an accidental add/drop in the model
gets caught without a matching migration.
The journal-quality redesign intentionally excluded the
bibliometric columns (issn, h_index, impact_factor, ...) that
would have served a Tier 2/3 cache; those values live in the
read-only reference DB instead. Re-adding any of them to the
model without a new migration would cause the schema drift
this test catches.
"""
from local_deep_research.database.models.logs import Journal
expected = {
"id",
"name",
"name_lower",
"quality",
"score_source",
"quality_model",
"quality_analysis_time",
}
actual = set(Journal.__table__.columns.keys())
missing = expected - actual
extra = actual - expected
assert not missing, (
f"Journal model is missing required columns: {sorted(missing)}"
)
assert not extra, (
f"Journal model has unexpected columns: {sorted(extra)}. "
"If you intentionally added a column, also update this test "
"and the journal-quality migration (and its downgrade)."
)
def test_chat_tables_have_exact_column_sets(self):
"""Lock down the column sets for the three chat tables introduced
in migration 0010. Drift detection — accidental rename, removal,
or new column without a migration would surface as a clear diff.
Mirrors the journals pattern above. Currently `phase` on
ChatProgressStep has no behavior tests pinning it; this test
catches a silent drop.
"""
from local_deep_research.database.models.chat import (
ChatMessage,
ChatProgressStep,
ChatSession,
)
expected = {
ChatSession: {
"id",
"title",
"status",
"accumulated_context",
"created_at",
"message_count",
},
ChatMessage: {
"id",
"session_id",
"research_id",
"role",
"message_type",
"content",
"sequence_number",
"created_at",
},
ChatProgressStep: {
"id",
"research_id",
"session_id",
"phase",
"content",
"sequence_number",
"created_at",
},
}
for model, cols in expected.items():
actual = set(model.__table__.columns.keys())
missing = cols - actual
extra = actual - cols
assert not missing, (
f"{model.__name__} is missing required columns: "
f"{sorted(missing)}"
)
assert not extra, (
f"{model.__name__} has unexpected columns: {sorted(extra)}. "
"If intentional, update this test and migration 0010 "
"(or open a follow-up migration)."
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+301
View File
@@ -0,0 +1,301 @@
"""Tests for database/session_context.py."""
import pytest
from unittest.mock import Mock, patch
from flask import Flask
@pytest.fixture
def app():
"""Create test Flask application."""
app = Flask(__name__)
app.config["TESTING"] = True
app.secret_key = "test-secret-key"
return app
class TestDatabaseSessionError:
"""Tests for DatabaseSessionError exception."""
def test_exception_can_be_raised(self):
"""Test that DatabaseSessionError can be raised."""
from local_deep_research.database.session_context import (
DatabaseSessionError,
)
with pytest.raises(DatabaseSessionError):
raise DatabaseSessionError("Test error")
def test_exception_message(self):
"""Test that exception preserves message."""
from local_deep_research.database.session_context import (
DatabaseSessionError,
)
try:
raise DatabaseSessionError("Custom error message")
except DatabaseSessionError as e:
assert str(e) == "Custom error message"
class TestGetUserDbSession:
"""Tests for get_user_db_session context manager."""
def test_raises_when_no_username_provided(self, app):
"""Test that error is raised when no username available."""
from local_deep_research.database.session_context import (
get_user_db_session,
DatabaseSessionError,
)
with app.test_request_context():
# No username in session
with pytest.raises(
DatabaseSessionError, match="No authenticated user"
):
with get_user_db_session():
pass
def test_uses_provided_username(self, app):
"""Test that explicitly provided username is used."""
from local_deep_research.database.session_context import (
get_user_db_session,
)
with app.test_request_context():
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.has_encryption = False
with patch(
"local_deep_research.database.thread_local_session.get_metrics_session"
) as mock_get_session:
mock_session = Mock()
mock_get_session.return_value = mock_session
with get_user_db_session(
username="testuser", password="testpass"
) as session:
assert session is mock_session
def test_uses_flask_session_username_when_not_provided(self, app):
"""Test that Flask session username is used when not explicitly provided."""
from local_deep_research.database.session_context import (
get_user_db_session,
UNENCRYPTED_DB_PLACEHOLDER,
)
with app.test_request_context():
from flask import session as flask_session
flask_session["username"] = "flask_user"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.has_encryption = False
with patch(
"local_deep_research.database.thread_local_session.get_metrics_session"
) as mock_get_session:
mock_session = Mock()
mock_get_session.return_value = mock_session
with get_user_db_session() as _session:
mock_get_session.assert_called_once_with(
"flask_user", UNENCRYPTED_DB_PLACEHOLDER
)
def test_raises_when_encrypted_db_requires_password(self, app):
"""Test error when encrypted DB accessed without password."""
from local_deep_research.database.session_context import (
get_user_db_session,
DatabaseSessionError,
)
with app.test_request_context():
from flask import session as flask_session
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.has_encryption = True
mock_db.connections = {}
with patch(
"local_deep_research.database.session_context.get_search_context"
) as mock_ctx:
mock_ctx.return_value = None
with patch(
"local_deep_research.database.session_passwords.session_password_store"
) as mock_store:
mock_store.get_session_password.return_value = None
with pytest.raises(
DatabaseSessionError, match="requires password"
):
with get_user_db_session():
pass
class TestWithUserDatabase:
"""Tests for with_user_database decorator."""
def test_decorator_injects_db_session(self, app):
"""Test that decorator injects db_session as first argument."""
from local_deep_research.database.session_context import (
with_user_database,
)
@with_user_database
def test_func(db_session, arg1, arg2):
return (db_session, arg1, arg2)
with app.test_request_context():
from flask import session as flask_session
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_ctx:
mock_session = Mock()
mock_ctx.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = Mock(return_value=False)
result = test_func("value1", "value2")
assert result == (mock_session, "value1", "value2")
def test_decorator_passes_kwargs(self, app):
"""Test that decorator passes keyword arguments."""
from local_deep_research.database.session_context import (
with_user_database,
)
@with_user_database
def test_func(db_session, key1=None, key2=None):
return {"session": db_session, "key1": key1, "key2": key2}
with app.test_request_context():
from flask import session as flask_session
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_ctx:
mock_session = Mock()
mock_ctx.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = Mock(return_value=False)
result = test_func(key1="a", key2="b")
assert result["key1"] == "a"
assert result["key2"] == "b"
def test_decorator_extracts_special_kwargs(self, app):
"""Test that _username and _password are extracted from kwargs."""
from local_deep_research.database.session_context import (
with_user_database,
)
@with_user_database
def test_func(db_session):
return db_session
with app.app_context():
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_ctx:
mock_session = Mock()
mock_ctx.return_value.__enter__ = Mock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = Mock(return_value=False)
test_func(_username="custom_user", _password="custom_pass")
# Verify get_user_db_session was called with the custom credentials
mock_ctx.assert_called_once_with("custom_user", "custom_pass")
class TestDatabaseAccessMixin:
"""Tests for DatabaseAccessMixin class."""
def test_get_db_session_raises_deprecation_warning(self, app):
"""Test that get_db_session raises DeprecationWarning.
The method was deprecated because it returned a closed session
(context manager exits before returning).
"""
from local_deep_research.database.session_context import (
DatabaseAccessMixin,
)
import pytest
class TestService(DatabaseAccessMixin):
pass
service = TestService()
with pytest.raises(DeprecationWarning) as exc_info:
service.get_db_session()
assert "deprecated" in str(exc_info.value).lower()
assert "get_user_db_session" in str(exc_info.value)
class TestUnencryptedDbPlaceholder:
"""Tests for UNENCRYPTED_DB_PLACEHOLDER constant."""
def test_placeholder_value(self):
"""Test the placeholder constant value."""
from local_deep_research.database.session_context import (
UNENCRYPTED_DB_PLACEHOLDER,
)
assert UNENCRYPTED_DB_PLACEHOLDER == "unencrypted-mode"
assert isinstance(UNENCRYPTED_DB_PLACEHOLDER, str)
class TestSafeRollback:
"""Tests for the safe_rollback helper introduced for issue #3827."""
def test_calls_session_rollback(self):
from local_deep_research.database.session_context import (
safe_rollback,
)
session = Mock()
safe_rollback(session, "ctx")
session.rollback.assert_called_once()
def test_swallows_rollback_exception(self):
"""Rollback errors must not propagate — call sites are exception
handlers themselves and a raise here would mask the original error.
"""
from local_deep_research.database.session_context import (
safe_rollback,
)
session = Mock()
session.rollback.side_effect = RuntimeError("simulated rollback fail")
# Must NOT raise.
safe_rollback(session, "ctx")
session.rollback.assert_called_once()
def test_works_without_context_argument(self):
from local_deep_research.database.session_context import (
safe_rollback,
)
session = Mock()
safe_rollback(session)
session.rollback.assert_called_once()
@@ -0,0 +1,66 @@
"""Coverage tests for database/session_context.py — basic import-level checks.
Covered here:
- DatabaseSessionError is a proper Exception subclass
- with_user_database: decorator injects db_session
- DatabaseAccessMixin.get_db_session: raises DeprecationWarning
Additional branch coverage lives in test_session_context_deep_coverage.py.
"""
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.database.session_context import (
DatabaseAccessMixin,
DatabaseSessionError,
with_user_database,
)
MODULE = "local_deep_research.database.session_context"
class TestGetUserDbSessionErrors:
"""Tests for error paths in get_user_db_session."""
def test_database_session_error_is_exception(self):
"""DatabaseSessionError is a proper Exception subclass."""
assert issubclass(DatabaseSessionError, Exception)
err = DatabaseSessionError("test message")
assert str(err) == "test message"
class TestWithUserDatabase:
"""Tests for with_user_database decorator."""
def test_injects_db_session(self):
"""Decorator injects db_session as first argument."""
@with_user_database
def my_func(db_session, key):
return f"session={db_session}, key={key}"
mock_session = MagicMock()
with patch(f"{MODULE}.get_user_db_session") as mock_ctx:
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
result = my_func("test_key")
assert "test_key" in result
class TestDatabaseAccessMixin:
"""Tests for DatabaseAccessMixin."""
def test_get_db_session_raises_deprecation(self):
"""get_db_session raises DeprecationWarning."""
class MyService(DatabaseAccessMixin):
pass
svc = MyService()
with pytest.raises(DeprecationWarning):
svc.get_db_session()
@@ -0,0 +1,266 @@
"""Deep coverage tests for database/session_context.py.
Focuses on uncovered branches:
- get_user_db_session: g.db_session reuse path
- get_user_db_session: g.user_password path
- get_user_db_session: session_password_store path
- get_user_db_session: thread context password path
- get_user_db_session: unencrypted DB placeholder
- get_user_db_session: failed session raises DatabaseSessionError
- ensure_db_session: user not in session (unauthenticated)
- ensure_db_session: db not connected + encrypted -> redirect
- ensure_db_session: db not connected + unencrypted -> reopen
- UNENCRYPTED_DB_PLACEHOLDER constant
- DatabaseAccessMixin.execute_with_db
"""
from unittest.mock import MagicMock, patch
import pytest
MODULE = "local_deep_research.database.session_context"
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
class TestConstants:
def test_unencrypted_placeholder_is_string(self):
from local_deep_research.database.session_context import (
UNENCRYPTED_DB_PLACEHOLDER,
)
assert isinstance(UNENCRYPTED_DB_PLACEHOLDER, str)
assert len(UNENCRYPTED_DB_PLACEHOLDER) > 0
def test_database_session_error_is_exception(self):
from local_deep_research.database.session_context import (
DatabaseSessionError,
)
assert issubclass(DatabaseSessionError, Exception)
# ---------------------------------------------------------------------------
# get_user_db_session happy paths
# ---------------------------------------------------------------------------
class TestGetUserDbSessionHappyPaths:
def test_uses_g_db_session_when_available(self):
"""When g.db_session exists, it is yielded without opening a new session."""
from local_deep_research.database.session_context import (
get_user_db_session,
)
mock_sess = MagicMock()
mock_g = MagicMock()
mock_g.db_session = mock_sess
with (
patch(f"{MODULE}.has_request_context", return_value=False),
patch(f"{MODULE}.has_app_context", return_value=True),
patch(f"{MODULE}.g", mock_g),
):
with get_user_db_session(username="alice") as sess:
assert sess is mock_sess
def test_unencrypted_db_uses_placeholder_password( # DevSkim: ignore DS101155
self,
):
"""When no password and db is unencrypted, placeholder password is used.""" # DevSkim: ignore DS101155
from local_deep_research.database.session_context import (
get_user_db_session,
)
mock_sess = MagicMock()
mock_g = MagicMock(spec=[]) # No db_session attribute
with (
patch(f"{MODULE}.has_request_context", return_value=False),
patch(f"{MODULE}.has_app_context", return_value=False),
patch(f"{MODULE}.get_search_context", return_value=None),
patch(f"{MODULE}.db_manager") as mock_db,
patch(
"local_deep_research.database.thread_local_session.thread_session_manager.get_session",
return_value=mock_sess,
),
patch(f"{MODULE}.g", mock_g),
):
mock_db.has_encryption = False
with get_user_db_session(username="alice") as sess:
assert sess is mock_sess
def test_password_from_thread_context(self):
"""Password is retrieved from thread search context."""
from local_deep_research.database.session_context import (
get_user_db_session,
)
mock_sess = MagicMock()
mock_g = MagicMock(spec=[])
with (
patch(f"{MODULE}.has_request_context", return_value=False),
patch(f"{MODULE}.has_app_context", return_value=False),
patch(
f"{MODULE}.get_search_context",
return_value={"user_password": "thread_pw"},
),
patch(f"{MODULE}.db_manager") as mock_db,
patch(
"local_deep_research.database.thread_local_session.thread_session_manager.get_session",
return_value=mock_sess,
),
patch(f"{MODULE}.g", mock_g),
):
mock_db.has_encryption = True
with get_user_db_session(username="alice") as sess:
assert sess is mock_sess
# ---------------------------------------------------------------------------
# get_user_db_session error paths
# ---------------------------------------------------------------------------
class TestGetUserDbSessionErrors:
def test_no_username_raises(self):
from local_deep_research.database.session_context import (
get_user_db_session,
DatabaseSessionError,
)
with (
patch(f"{MODULE}.has_request_context", return_value=False),
patch(f"{MODULE}.has_app_context", return_value=False),
):
with pytest.raises(
DatabaseSessionError, match="No authenticated user"
):
with get_user_db_session():
pass
def test_encrypted_db_no_password_raises(self):
from local_deep_research.database.session_context import (
get_user_db_session,
DatabaseSessionError,
)
mock_g = MagicMock(spec=[])
with (
patch(f"{MODULE}.has_request_context", return_value=False),
patch(f"{MODULE}.has_app_context", return_value=False),
patch(f"{MODULE}.get_search_context", return_value=None),
patch(f"{MODULE}.db_manager") as mock_db,
patch(f"{MODULE}.g", mock_g),
):
mock_db.has_encryption = True
with pytest.raises(DatabaseSessionError, match="requires password"):
with get_user_db_session(username="alice"):
pass
def test_failed_session_raises(self):
"""When get_metrics_session returns None, DatabaseSessionError is raised."""
from local_deep_research.database.session_context import (
get_user_db_session,
DatabaseSessionError,
)
mock_g = MagicMock(spec=[])
with (
patch(f"{MODULE}.has_request_context", return_value=False),
patch(f"{MODULE}.has_app_context", return_value=False),
patch(f"{MODULE}.get_search_context", return_value=None),
patch(f"{MODULE}.db_manager") as mock_db,
patch(
"local_deep_research.database.thread_local_session.thread_session_manager.get_session",
return_value=None,
),
patch(f"{MODULE}.g", mock_g),
):
mock_db.has_encryption = False
with pytest.raises(
DatabaseSessionError, match="Could not establish session"
):
with get_user_db_session(username="alice"):
pass
# ---------------------------------------------------------------------------
# with_user_database decorator
# ---------------------------------------------------------------------------
class TestWithUserDatabase:
def test_injects_session_as_first_arg(self):
from local_deep_research.database.session_context import (
with_user_database,
)
mock_sess = MagicMock()
@with_user_database
def my_func(db_session, x, y=0):
return (db_session, x + y)
with patch(f"{MODULE}.get_user_db_session") as mock_ctx:
mock_ctx.return_value.__enter__ = MagicMock(return_value=mock_sess)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
result = my_func(10, y=5)
assert result == (mock_sess, 15)
def test_passes_username_and_password_from_kwargs(self):
from local_deep_research.database.session_context import (
with_user_database,
)
@with_user_database
def my_func(db_session):
return db_session
mock_sess = MagicMock()
with patch(f"{MODULE}.get_user_db_session") as mock_ctx:
mock_ctx.return_value.__enter__ = MagicMock(return_value=mock_sess)
mock_ctx.return_value.__exit__ = MagicMock(return_value=False)
result = my_func(_username="alice", _password="pw")
mock_ctx.assert_called_once_with("alice", "pw")
assert result is mock_sess
# ---------------------------------------------------------------------------
# DatabaseAccessMixin
# ---------------------------------------------------------------------------
class TestDatabaseAccessMixin:
def test_get_db_session_raises_deprecation_warning(self):
from local_deep_research.database.session_context import (
DatabaseAccessMixin,
)
class MyService(DatabaseAccessMixin):
pass
svc = MyService()
with pytest.raises(DeprecationWarning):
svc.get_db_session()
def test_get_db_session_message_content(self):
from local_deep_research.database.session_context import (
DatabaseAccessMixin,
)
svc = DatabaseAccessMixin()
try:
svc.get_db_session()
except DeprecationWarning as e:
assert "deprecated" in str(
e
).lower() or "get_user_db_session" in str(e)
@@ -0,0 +1,403 @@
"""Extended tests for database/session_context.py - targeting untested paths.
Covers:
- ensure_db_session() decorator (lines 160-212, entirely untested)
- get_user_db_session() reusing g.db_session
- get_user_db_session() password from g.user_password
- get_user_db_session() password from session_password_store
- get_user_db_session() password from thread context
- get_user_db_session() get_metrics_session returns None
"""
from unittest.mock import Mock, patch
import pytest
from flask import Flask, g, session as flask_session
@pytest.fixture
def app():
"""Create test Flask application."""
app = Flask(__name__)
app.config["TESTING"] = True
app.secret_key = "test-secret-key"
return app
# ── ensure_db_session decorator ──────────────────────────────────
class TestEnsureDbSession:
"""Tests for ensure_db_session decorator (lines 160-212, entirely untested)."""
def test_no_username_passes_through(self, app):
"""When no username in session, view runs without db_session setup."""
from local_deep_research.database.session_context import (
ensure_db_session,
)
view_called = []
@ensure_db_session
def my_view():
view_called.append(True)
return "ok"
with app.test_request_context():
result = my_view()
assert result == "ok"
assert view_called == [True]
def test_connected_user_gets_session(self, app):
"""Connected user gets g.db_session set from db_manager."""
from local_deep_research.database.session_context import (
ensure_db_session,
)
mock_session = Mock()
@ensure_db_session
def my_view():
return g.db_session
with app.test_request_context():
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.is_user_connected.return_value = True
mock_db.get_session.return_value = mock_session
result = my_view()
assert result is mock_session
mock_db.get_session.assert_called_once_with("testuser")
def test_encrypted_db_not_connected_redirects_to_login(self, app):
"""Encrypted DB + disconnected user → clear session + redirect."""
from local_deep_research.database.session_context import (
ensure_db_session,
)
# Register auth blueprint with login route
from flask import Blueprint
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/login")
def login():
return "login page"
app.register_blueprint(auth_bp)
@ensure_db_session
def my_view():
return "should not reach"
with app.test_request_context():
flask_session["username"] = "testuser"
flask_session["some_data"] = "preserve_check"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.is_user_connected.return_value = False
mock_db.has_encryption = True
result = my_view()
# Should be a redirect response
assert result.status_code == 302
assert "/login" in result.location
def test_unencrypted_db_not_connected_reopens(self, app):
"""Unencrypted DB + disconnected user → reopen database."""
from local_deep_research.database.session_context import (
ensure_db_session,
UNENCRYPTED_DB_PLACEHOLDER,
)
mock_session = Mock()
@ensure_db_session
def my_view():
return g.db_session
with app.test_request_context():
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.is_user_connected.return_value = False
mock_db.has_encryption = False
mock_engine = Mock()
mock_db.open_user_database.return_value = mock_engine
mock_db.get_session.return_value = mock_session
result = my_view()
mock_db.open_user_database.assert_called_once_with(
"testuser", UNENCRYPTED_DB_PLACEHOLDER
)
assert result is mock_session
def test_unencrypted_reopen_fails_gracefully(self, app):
"""Failed unencrypted reopen doesn't crash — view still runs."""
from local_deep_research.database.session_context import (
ensure_db_session,
)
@ensure_db_session
def my_view():
return "view ran"
with app.test_request_context():
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.is_user_connected.return_value = False
mock_db.has_encryption = False
mock_db.open_user_database.return_value = None # Engine failed
result = my_view()
assert result == "view ran"
def test_exception_during_session_setup_returns_500(self, app):
"""Exception in session setup returns 500 error response."""
from local_deep_research.database.session_context import (
ensure_db_session,
)
@ensure_db_session
def my_view():
return "recovered"
with app.test_request_context():
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.is_user_connected.side_effect = RuntimeError("DB crash")
result = my_view()
assert result[1] == 500
def test_preserves_function_name(self):
"""Decorator preserves wrapped function name via functools.wraps."""
from local_deep_research.database.session_context import (
ensure_db_session,
)
@ensure_db_session
def my_special_view():
pass
assert my_special_view.__name__ == "my_special_view"
def test_passes_args_to_view(self, app):
"""Decorator passes positional and keyword args to wrapped view."""
from local_deep_research.database.session_context import (
ensure_db_session,
)
@ensure_db_session
def my_view(item_id, mode="default"):
return f"{item_id}:{mode}"
with app.test_request_context():
# No username → passes through without DB setup
result = my_view(42, mode="edit")
assert result == "42:edit"
# ── get_user_db_session: g.db_session reuse ──────────────────────
class TestGetUserDbSessionReuse:
"""Tests for g.db_session reuse path (lines 62-65)."""
def test_reuses_existing_g_db_session(self, app):
"""When g.db_session exists, it is reused without creating new session."""
from local_deep_research.database.session_context import (
get_user_db_session,
)
existing_session = Mock()
with app.test_request_context():
flask_session["username"] = "testuser"
g.db_session = existing_session
with get_user_db_session() as session:
assert session is existing_session
# ── get_user_db_session: password resolution paths ───────────────
class TestGetUserDbSessionPasswordPaths:
"""Tests for password resolution fallback chain."""
def test_password_from_g_user_password(self, app):
"""Password is retrieved from g.user_password."""
from local_deep_research.database.session_context import (
get_user_db_session,
)
mock_session = Mock()
with app.test_request_context():
flask_session["username"] = "testuser"
g.user_password = "secret123"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.has_encryption = True
with patch(
"local_deep_research.database.thread_local_session.get_metrics_session"
) as mock_get:
mock_get.return_value = mock_session
with get_user_db_session() as session:
assert session is mock_session
mock_get.assert_called_once_with("testuser", "secret123")
def test_password_from_session_password_store(self, app):
"""Password is retrieved from session_password_store."""
from local_deep_research.database.session_context import (
get_user_db_session,
)
mock_session = Mock()
with app.test_request_context():
flask_session["username"] = "testuser"
flask_session["session_id"] = "sess-abc"
with (
patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db,
patch(
"local_deep_research.database.session_context.get_search_context"
) as mock_ctx,
):
mock_db.has_encryption = True
mock_ctx.return_value = None # No thread context
# session_password_store is imported locally inside get_user_db_session
with patch(
"local_deep_research.database.session_passwords.session_password_store"
) as mock_store:
mock_store.get_session_password.return_value = "store-pass"
with patch(
"local_deep_research.database.thread_local_session.get_metrics_session"
) as mock_get:
mock_get.return_value = mock_session
with get_user_db_session() as session:
assert session is mock_session
mock_get.assert_called_once_with(
"testuser", "store-pass"
)
mock_store.get_session_password.assert_called_once_with(
"testuser", "sess-abc"
)
def test_password_from_thread_context(self, app):
"""Password is retrieved from thread context (background threads)."""
from local_deep_research.database.session_context import (
get_user_db_session,
)
mock_session = Mock()
with app.test_request_context():
flask_session["username"] = "testuser"
with (
patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db,
patch(
"local_deep_research.database.session_context.get_search_context"
) as mock_ctx,
):
mock_db.has_encryption = True
mock_ctx.return_value = {"user_password": "thread-pass"}
with patch(
"local_deep_research.database.thread_local_session.get_metrics_session"
) as mock_get:
mock_get.return_value = mock_session
with get_user_db_session() as session:
assert session is mock_session
mock_get.assert_called_once_with("testuser", "thread-pass")
def test_get_metrics_session_returns_none_raises(self, app):
"""When get_metrics_session returns None, DatabaseSessionError is raised."""
from local_deep_research.database.session_context import (
get_user_db_session,
DatabaseSessionError,
)
with app.test_request_context():
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.has_encryption = False
with patch(
"local_deep_research.database.thread_local_session.get_metrics_session"
) as mock_get:
mock_get.return_value = None
with pytest.raises(
DatabaseSessionError,
match="Could not establish session",
):
with get_user_db_session():
pass
def test_password_stored_in_g_after_success(self, app):
"""After successful session creation, password is stored in g."""
from local_deep_research.database.session_context import (
get_user_db_session,
)
mock_session = Mock()
with app.test_request_context():
flask_session["username"] = "testuser"
with patch(
"local_deep_research.database.session_context.db_manager"
) as mock_db:
mock_db.has_encryption = False
with patch(
"local_deep_research.database.thread_local_session.get_metrics_session"
) as mock_get:
mock_get.return_value = mock_session
with get_user_db_session() as _session:
# After yield, g.user_password should be set
assert hasattr(g, "user_password")
@@ -0,0 +1,137 @@
"""Regression tests: ``get_user_db_session`` rolls back the reused thread-local
session when the caller's ``with`` block raises.
Root cause behind the family of bare-``session.commit()`` cascade bugs: the
session yielded by ``get_user_db_session`` is a *reused* thread-local session
that is never closed on exit. Before this fix, an exception escaping the
``with`` block — most commonly a failed ``commit()``/``flush()`` — left the
session in ``PendingRollbackError`` state, and the *next* operation on that
thread cascaded. The context manager now rolls the session back on exception
exit and re-raises, so a single unguarded block can no longer poison the whole
thread.
"""
from unittest.mock import Mock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import Base
from local_deep_research.database.models.library import SourceType
import local_deep_research.database.session_context as _sc_mod
SC = "local_deep_research.database.session_context"
TLS = "local_deep_research.database.thread_local_session.get_metrics_session"
# Captured at module import — before any test runs, so before a sibling test
# (e.g. the rag-route suites) can leak a patch over this attribute that its
# teardown failed to restore. The autouse fixture below pins it back so these
# tests, which exercise the real context manager, are order-independent.
_REAL_GET_USER_DB_SESSION = _sc_mod.get_user_db_session
class TestGetUserDbSessionRollback:
"""Rollback-on-exception behaviour of the get_user_db_session context manager."""
@pytest.fixture(autouse=True)
def _isolate_session_context(self):
"""Make these tests immune to leaked global state from sibling suites.
Two guards:
- Restore the real ``get_user_db_session`` (a rag-route test leaks a
MagicMock over it that its teardown fails to clear; without this our
``with get_user_db_session(...)`` would yield that mock, not our
injected session).
- Force ``get_g_db_session`` to None so we deterministically exercise
the thread-local branch regardless of any leaked ``g.current_user``.
"""
with (
patch.object(
_sc_mod, "get_user_db_session", _REAL_GET_USER_DB_SESSION
),
patch.object(_sc_mod, "get_g_db_session", return_value=None),
):
yield
def test_real_thread_local_session_recovered_after_failed_commit(self, app):
"""End-to-end proof with a real SQLite session: a failed commit in one
``with`` block must not poison the next block on the same thread-local
session. Without the fix, block 2's commit raises PendingRollbackError.
"""
from local_deep_research.database.session_context import (
get_user_db_session,
)
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
real_session = sessionmaker(bind=engine)()
# Pre-existing row so the duplicate-PK insert below is a *real*
# constraint failure at commit time.
real_session.add(SourceType(id="dup", name="a", display_name="A"))
real_session.commit()
with app.test_request_context():
with patch(f"{SC}.db_manager") as mock_db:
mock_db.has_encryption = False
with patch(TLS, return_value=real_session):
# Block 1: a bare commit fails on a duplicate primary key.
with pytest.raises(IntegrityError):
with get_user_db_session(
username="u", password="p"
) as s:
s.add(
SourceType(id="dup", name="b", display_name="B")
)
s.commit()
# Block 2: the SAME thread-local session must be usable.
with get_user_db_session(username="u", password="p") as s:
s.add(SourceType(id="ok", name="ok", display_name="OK"))
s.commit()
assert real_session.query(SourceType).filter_by(id="ok").count() == 1
real_session.close()
engine.dispose()
def test_clean_exit_does_not_roll_back(self, app):
"""Normal (non-exception) exit must NOT roll back — intentional pending
state is the caller's to commit; we only recover on error.
"""
from local_deep_research.database.session_context import (
get_user_db_session,
)
with app.test_request_context():
with patch(f"{SC}.db_manager") as mock_db:
mock_db.has_encryption = False
mock_session = Mock()
with patch(TLS, return_value=mock_session):
with get_user_db_session(username="u", password="p"):
pass
mock_session.rollback.assert_not_called()
def test_secondary_rollback_failure_does_not_mask_original_error(self, app):
"""If the recovery rollback itself raises, the ORIGINAL error must
still propagate (safe_rollback swallows the rollback failure).
"""
from local_deep_research.database.session_context import (
get_user_db_session,
)
with app.test_request_context():
with patch(f"{SC}.db_manager") as mock_db:
mock_db.has_encryption = False
mock_session = Mock()
mock_session.rollback.side_effect = RuntimeError(
"rollback boom"
)
with patch(TLS, return_value=mock_session):
with pytest.raises(ValueError, match="original"):
with get_user_db_session(username="u", password="p"):
raise ValueError("original")
mock_session.rollback.assert_called_once()
+184
View File
@@ -0,0 +1,184 @@
"""Tests for SessionPasswordStore."""
import time
class TestSessionPasswordStore:
"""Tests for SessionPasswordStore class."""
def test_init_with_default_ttl(self):
"""SessionPasswordStore initializes with default 24-hour TTL."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore()
# TTL should be 24 hours in seconds
assert store.ttl == 24 * 3600
def test_init_with_custom_ttl(self):
"""SessionPasswordStore accepts custom TTL in hours."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=12)
assert store.ttl == 12 * 3600
def test_store_session_password_stores_correctly(self):
"""store_session_password stores password correctly."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("testuser", "session123", "mypassword")
# Verify it was stored
result = store.get_session_password("testuser", "session123")
assert result == "mypassword"
def test_get_session_password_returns_password(self):
"""get_session_password returns the stored password."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("user1", "sess1", "pass123")
result = store.get_session_password("user1", "sess1")
assert result == "pass123"
def test_get_session_password_nonexistent_returns_none(self):
"""get_session_password returns None for nonexistent entries."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
result = store.get_session_password("nonexistent", "nosession")
assert result is None
def test_clear_session_clears_entry(self):
"""clear_session removes the stored password."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("user1", "sess1", "pass123")
# Verify stored
assert store.get_session_password("user1", "sess1") == "pass123"
# Clear
store.clear_session("user1", "sess1")
# Verify cleared
assert store.get_session_password("user1", "sess1") is None
def test_clear_session_nonexistent_entry_no_error(self):
"""clear_session does not raise error for nonexistent entry."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
# Should not raise
store.clear_session("nonexistent", "nosession")
def test_session_key_format_is_username_session_id(self):
"""Session key format is (username, session_id) tuple."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("myuser", "mysession", "pass")
# Check the internal key format
expected_key = ("myuser", "mysession")
assert expected_key in store._store
def test_password_expires_after_ttl(self):
"""Password expires and returns None after TTL."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
# Use a very short TTL (1 second converted from hours)
# But we can manipulate the store directly for testing
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("user1", "sess1", "pass123")
# Manually set expiration to past
key = ("user1", "sess1")
store._store[key]["expires_at"] = time.time() - 1
# Should return None
result = store.get_session_password("user1", "sess1")
assert result is None
def test_store_alias_method(self):
"""store() is an alias for store_session_password()."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
store.store("alias_user", "alias_session", "alias_pass")
result = store.get_session_password("alias_user", "alias_session")
assert result == "alias_pass"
def test_retrieve_alias_method(self):
"""retrieve() is an alias for get_session_password()."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("user1", "sess1", "pass123")
result = store.retrieve("user1", "sess1")
assert result == "pass123"
def test_multiple_sessions_same_user(self):
"""Can store multiple sessions for same user."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("user1", "session_a", "pass_a")
store.store_session_password("user1", "session_b", "pass_b")
assert store.get_session_password("user1", "session_a") == "pass_a"
assert store.get_session_password("user1", "session_b") == "pass_b"
def test_overwrite_session_password(self):
"""Storing same session again overwrites password."""
from local_deep_research.database.session_passwords import (
SessionPasswordStore,
)
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("user1", "sess1", "original")
store.store_session_password("user1", "sess1", "updated")
result = store.get_session_password("user1", "sess1")
assert result == "updated"
class TestSessionPasswordStoreGlobalInstance:
"""Tests for the global session_password_store instance."""
def test_global_instance_is_session_password_store(self):
"""Global instance is SessionPasswordStore type."""
from local_deep_research.database.session_passwords import (
session_password_store,
SessionPasswordStore,
)
assert isinstance(session_password_store, SessionPasswordStore)
@@ -0,0 +1,64 @@
"""Extra coverage tests for session_passwords.py — clear_all_for_user and aliases."""
from local_deep_research.database.session_passwords import SessionPasswordStore
class TestClearAllForUser:
def test_clears_all_sessions_for_user(self):
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("alice", "s1", "pw1")
store.store_session_password("alice", "s2", "pw2")
store.store_session_password("bob", "s3", "pw3")
store.clear_all_for_user("alice")
assert store.get_session_password("alice", "s1") is None
assert store.get_session_password("alice", "s2") is None
assert store.get_session_password("bob", "s3") == "pw3"
def test_clears_nothing_when_user_not_found(self):
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("alice", "s1", "pw1")
store.clear_all_for_user("nonexistent")
assert store.get_session_password("alice", "s1") == "pw1"
def test_clears_nothing_on_empty_store(self):
store = SessionPasswordStore(ttl_hours=1)
store.clear_all_for_user("alice") # should not raise
def test_does_not_clear_similar_prefix(self):
"""'alice' should not clear 'alice2:...' entries."""
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("alice", "s1", "pw1")
store.store_session_password("alice2", "s1", "pw2")
store.clear_all_for_user("alice")
assert store.get_session_password("alice", "s1") is None
assert store.get_session_password("alice2", "s1") == "pw2"
def test_clears_single_session(self):
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("alice", "only-one", "pw")
store.clear_all_for_user("alice")
assert store.get_session_password("alice", "only-one") is None
class TestAliases:
def test_store_alias(self):
store = SessionPasswordStore(ttl_hours=1)
store.store("user", "sid", "pass")
assert store.get_session_password("user", "sid") == "pass"
def test_retrieve_alias(self):
store = SessionPasswordStore(ttl_hours=1)
store.store_session_password("user", "sid", "pass")
assert store.retrieve("user", "sid") == "pass"
def test_retrieve_returns_none_when_missing(self):
store = SessionPasswordStore(ttl_hours=1)
assert store.retrieve("user", "nosid") is None
+422
View File
@@ -0,0 +1,422 @@
"""Tests for settings and API key database models."""
from datetime import datetime, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import (
APIKey,
Base,
Setting,
SettingType,
UserSettings,
)
class TestSettingsModels:
"""Test suite for settings-related models."""
@pytest.fixture
def engine(self):
"""Create an in-memory SQLite database for testing."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def session(self, engine):
"""Create a database session for testing."""
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_setting_creation(self, session):
"""Test creating various types of settings."""
# App setting
app_setting = Setting(
key="app.theme",
value={"mode": "dark", "accent": "blue"},
type=SettingType.APP,
name="Theme",
category="appearance",
description="Application theme settings",
ui_element="select",
options=["light", "dark", "auto"],
visible=True,
editable=True,
)
# LLM setting
llm_setting = Setting(
key="llm.temperature",
value=0.7,
type=SettingType.LLM,
name="Temperature",
category="model",
description="LLM response temperature",
ui_element="slider",
min_value=0.0,
max_value=2.0,
step=0.1,
visible=True,
editable=True,
)
# Search setting
search_setting = Setting(
key="search.max_results",
value=20,
type=SettingType.SEARCH,
name="Max Results",
category="search",
description="Maximum search results per query",
ui_element="number",
min_value=1,
max_value=100,
visible=True,
editable=True,
)
# Report setting
report_setting = Setting(
key="report.format",
value="markdown",
type=SettingType.REPORT,
name="Report Format",
category="output",
description="Default report format",
ui_element="select",
options=["markdown", "html", "pdf"],
visible=True,
editable=True,
)
session.add_all(
[app_setting, llm_setting, search_setting, report_setting]
)
session.commit()
# Verify settings
all_settings = session.query(Setting).all()
assert len(all_settings) == 4
llm = session.query(Setting).filter_by(key="llm.temperature").first()
assert llm.value == 0.7
assert llm.type == SettingType.LLM
assert llm.min_value == 0.0
assert llm.max_value == 2.0
def test_unique_key_constraint(self, session):
"""Test that setting keys must be unique."""
setting1 = Setting(
key="api.timeout",
value=30,
type=SettingType.APP,
name="API Timeout",
category="api",
)
setting2 = Setting(
key="api.timeout", # Duplicate key
value=60,
type=SettingType.APP,
name="API Timeout",
category="api",
)
session.add(setting1)
session.commit()
session.add(setting2)
with pytest.raises(IntegrityError):
session.commit()
def test_secret_settings(self, session):
"""Test handling of secret settings (though Setting model doesn't have is_secret)."""
# Using APIKey for secrets instead
api_key = APIKey(
provider="openai",
key="sk-abc123...", # Would be encrypted by SQLCipher
description="OpenAI API key for GPT models",
is_active=True,
)
session.add(api_key)
session.commit()
# Verify API key
saved = session.query(APIKey).filter_by(provider="openai").first()
assert saved is not None
assert saved.is_active is True
assert saved.key == "sk-abc123..."
# In real usage, the key would be encrypted by SQLCipher
def test_system_settings(self, session):
"""Test system-wide settings that shouldn't be user-editable."""
system_setting = Setting(
key="system.version",
value="1.0.0",
type=SettingType.APP,
name="System Version",
category="system",
description="Current system version",
visible=True,
editable=False, # System setting - not editable
)
session.add(system_setting)
session.commit()
saved = session.query(Setting).filter_by(key="system.version").first()
assert saved.editable is False
assert saved.visible is True
def test_user_settings(self, session):
"""Test UserSettings for user-specific preferences."""
# User preferences
preferences = [
UserSettings(
key="preferred_model",
value={"provider": "openai", "model": "gpt-4"},
category="preferences",
description="User's preferred LLM model",
),
UserSettings(
key="search_history_enabled",
value=True,
category="privacy",
description="Whether to save search history",
),
UserSettings(
key="ui_language",
value="en",
category="localization",
description="User interface language",
),
]
session.add_all(preferences)
session.commit()
# Query by category
privacy_settings = (
session.query(UserSettings).filter_by(category="privacy").all()
)
assert len(privacy_settings) == 1
assert privacy_settings[0].value is True
def test_api_key_management(self, session):
"""Test APIKey model for secure API key storage."""
# Add multiple API keys
keys = [
APIKey(
provider="openai",
key="sk-openai...",
description="OpenAI GPT models",
is_active=True,
),
APIKey(
provider="anthropic",
key="sk-ant...",
description="Claude models",
is_active=True,
),
APIKey(
provider="google",
key="AIza...",
description="Google search API",
is_active=False, # Disabled
),
]
session.add_all(keys)
session.commit()
# Query active keys
active_keys = session.query(APIKey).filter_by(is_active=True).all()
assert len(active_keys) == 2
# Test unique provider constraint
duplicate = APIKey(
provider="openai", # Already exists
key="sk-different...",
description="Duplicate provider",
)
session.add(duplicate)
with pytest.raises(IntegrityError):
session.commit()
def test_setting_categories(self, session):
"""Test organizing settings by category."""
categories = ["appearance", "performance", "security", "advanced"]
for i, cat in enumerate(categories):
for j in range(3):
setting = Setting(
key=f"{cat}.setting_{j}",
value=f"value_{j}",
type=SettingType.APP,
name=f"{cat.title()} Setting {j}",
category=cat,
visible=True if i < 3 else False, # Hide advanced
)
session.add(setting)
session.commit()
# Query by category
appearance = (
session.query(Setting).filter_by(category="appearance").all()
)
assert len(appearance) == 3
# Query visible settings
visible = session.query(Setting).filter_by(visible=True).count()
assert visible == 9 # 3 categories * 3 settings each
def test_setting_value_types(self, session):
"""Test different value types stored in JSON columns."""
settings = [
UserSettings(key="string_val", value="text value"),
UserSettings(key="int_val", value=42),
UserSettings(key="float_val", value=3.14),
UserSettings(key="bool_val", value=True),
UserSettings(key="list_val", value=[1, 2, 3]),
UserSettings(key="dict_val", value={"a": 1, "b": 2}),
UserSettings(key="null_val", value=None),
]
session.add_all(settings)
session.commit()
# Verify different types
assert (
session.query(UserSettings)
.filter_by(key="string_val")
.first()
.value
== "text value"
)
assert (
session.query(UserSettings).filter_by(key="int_val").first().value
== 42
)
assert (
session.query(UserSettings).filter_by(key="float_val").first().value
== 3.14
)
assert (
session.query(UserSettings).filter_by(key="bool_val").first().value
is True
)
assert session.query(UserSettings).filter_by(
key="list_val"
).first().value == [1, 2, 3]
assert session.query(UserSettings).filter_by(
key="dict_val"
).first().value == {"a": 1, "b": 2}
assert (
session.query(UserSettings).filter_by(key="null_val").first().value
is None
)
def test_api_key_rotation(self, session):
"""Test API key rotation and usage tracking."""
# Create initial key
api_key = APIKey(
provider="openai",
key="sk-old...",
description="OpenAI API key",
is_active=True,
usage_count=100,
)
session.add(api_key)
session.commit()
# Simulate key usage
api_key.usage_count += 1
api_key.last_used = datetime.now(timezone.utc)
session.commit()
# Rotate key (deactivate old, add new)
api_key.is_active = False
new_key = APIKey(
provider="openai_new", # Different provider name to avoid constraint
key="sk-new...",
description="OpenAI API key (rotated)",
is_active=True,
)
session.add(new_key)
session.commit()
# Verify rotation
old_key = session.query(APIKey).filter_by(provider="openai").first()
assert old_key.is_active is False
assert old_key.usage_count == 101
active_key = (
session.query(APIKey)
.filter_by(is_active=True, provider="openai_new")
.first()
)
assert active_key.key == "sk-new..."
def test_user_settings_defaults(self, session):
"""Test default values for user settings."""
# Get a non-existent setting
setting = (
session.query(UserSettings)
.filter_by(key="non_existent_setting")
.first()
)
assert setting is None
# Create setting with defaults
default_setting = UserSettings(
key="new_feature_enabled",
value=False, # Default to disabled
category="features",
description="New experimental feature",
)
session.add(default_setting)
session.commit()
saved = (
session.query(UserSettings)
.filter_by(key="new_feature_enabled")
.first()
)
assert saved.value is False
assert saved.created_at is not None
assert saved.updated_at is not None
def test_setting_update_tracking(self, session):
"""Test that updated_at is properly tracked."""
setting = Setting(
key="test.update",
value="initial",
type=SettingType.APP,
name="Test Update",
category="test",
)
session.add(setting)
session.commit()
# Update the setting
setting.value = "updated"
session.commit()
# Note: onupdate might not trigger in SQLite without proper configuration
# In production with PostgreSQL, this would work automatically
@@ -0,0 +1,343 @@
"""Tests for _make_sqlcipher_connection() factory method.
Verifies:
- Factory method creates connections with correct parameters
- Correct call order: key → pragmas → verify → performance
- Parameters are passed through correctly
- Failed verification raises ValueError
- Metrics path preserves error logging
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.database.encrypted_db import DatabaseManager
# Module path prefix for patching
_EDB = "local_deep_research.database.encrypted_db"
@pytest.fixture
def manager():
"""Create a DatabaseManager with encryption disabled for testing."""
with patch(f"{_EDB}.get_data_directory") as mock_dir:
mock_dir.return_value = Path("/tmp/test")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
return DatabaseManager()
@pytest.fixture
def mock_sqlcipher():
"""Mock all SQLCipher dependencies for _make_sqlcipher_connection."""
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_module = MagicMock()
mock_module.connect.return_value = mock_conn
with (
patch(
f"{_EDB}.get_sqlcipher_module", return_value=mock_module
) as mock_get,
patch(f"{_EDB}.set_sqlcipher_key") as mock_set_key,
patch(
f"{_EDB}.verify_sqlcipher_connection", return_value=True
) as mock_verify,
patch(f"{_EDB}.apply_sqlcipher_pragmas") as mock_pragmas,
patch(f"{_EDB}.apply_performance_pragmas") as mock_perf,
):
yield {
"module": mock_module,
"conn": mock_conn,
"cursor": mock_cursor,
"get_module": mock_get,
"set_key": mock_set_key,
"verify": mock_verify,
"pragmas": mock_pragmas,
"performance": mock_perf,
}
class TestMakeSqlcipherConnection:
"""Verify _make_sqlcipher_connection creates connections correctly."""
def test_returns_connection(self, manager, mock_sqlcipher):
"""Factory should return the connection object from sqlcipher3.connect."""
result = manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret"
)
assert result is mock_sqlcipher["conn"]
def test_connect_with_default_params(self, manager, mock_sqlcipher):
"""Default params: isolation_level=IMMEDIATE, check_same_thread=False."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["module"].connect.assert_called_once_with(
"/tmp/test.db",
isolation_level="IMMEDIATE",
check_same_thread=False,
)
def test_connect_with_custom_isolation_level(self, manager, mock_sqlcipher):
"""Metrics path passes isolation_level='' for deferred transactions."""
manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret", isolation_level=""
)
mock_sqlcipher["module"].connect.assert_called_once_with(
"/tmp/test.db",
isolation_level="",
check_same_thread=False,
)
def test_connect_with_check_same_thread(self, manager, mock_sqlcipher):
"""check_same_thread should be forwarded to sqlcipher3.connect."""
manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret", check_same_thread=True
)
mock_sqlcipher["module"].connect.assert_called_once_with(
"/tmp/test.db",
isolation_level="IMMEDIATE",
check_same_thread=True,
)
def test_sets_sqlcipher_key(self, manager, mock_sqlcipher):
"""Must call set_sqlcipher_key with cursor, password, and db_path."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "my_pass")
mock_sqlcipher["set_key"].assert_called_once_with(
mock_sqlcipher["cursor"], "my_pass", db_path=Path("/tmp/test.db")
)
def test_verifies_connection(self, manager, mock_sqlcipher):
"""Must verify connection works after setting key."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["verify"].assert_called_once_with(
mock_sqlcipher["cursor"]
)
def test_applies_sqlcipher_pragmas(self, manager, mock_sqlcipher):
"""Must apply SQLCipher pragmas with creation_mode=False."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["pragmas"].assert_called_once_with(
mock_sqlcipher["cursor"], creation_mode=False
)
def test_applies_performance_pragmas(self, manager, mock_sqlcipher):
"""Must call apply_performance_pragmas with cursor."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["performance"].assert_called_once_with(
mock_sqlcipher["cursor"]
)
def test_closes_cursor(self, manager, mock_sqlcipher):
"""Cursor must be closed before returning."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
def test_call_order(self, manager, mock_sqlcipher):
"""Operations must happen in order: key → pragmas → verify → performance."""
call_order = []
mock_sqlcipher["set_key"].side_effect = lambda *a, **kw: (
call_order.append("key")
)
mock_sqlcipher["verify"].side_effect = lambda *a: (
call_order.append("verify") or True
)
mock_sqlcipher["pragmas"].side_effect = lambda *a, **kw: (
call_order.append("pragmas")
)
mock_sqlcipher["performance"].side_effect = lambda *a: (
call_order.append("performance")
)
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
assert call_order == ["key", "pragmas", "verify", "performance"]
def test_raises_on_failed_verification(self, manager, mock_sqlcipher):
"""Must raise ValueError when verification fails."""
mock_sqlcipher["verify"].return_value = False
with pytest.raises(ValueError, match="Failed to verify database key"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
def test_no_performance_pragmas_after_failed_verification(
self, manager, mock_sqlcipher
):
"""Cipher pragmas run before verify, but performance pragmas must not."""
mock_sqlcipher["verify"].return_value = False
with pytest.raises(ValueError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["pragmas"].assert_called_once_with(
mock_sqlcipher["cursor"], creation_mode=False
)
mock_sqlcipher["performance"].assert_not_called()
def test_closes_cursor_and_conn_on_verification_failure(
self, manager, mock_sqlcipher
):
"""Cursor and connection must be closed when verification fails."""
mock_sqlcipher["verify"].return_value = False
with pytest.raises(ValueError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
def test_closes_cursor_and_conn_on_key_error(self, manager, mock_sqlcipher):
"""Cursor and connection must be closed when set_sqlcipher_key raises."""
mock_sqlcipher["set_key"].side_effect = RuntimeError("key failed")
with pytest.raises(RuntimeError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
def test_closes_cursor_and_conn_on_pragma_error(
self, manager, mock_sqlcipher
):
"""Cursor and connection must be closed when pragma application raises."""
mock_sqlcipher["pragmas"].side_effect = RuntimeError("pragma failed")
with pytest.raises(RuntimeError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
mock_sqlcipher["verify"].assert_not_called()
mock_sqlcipher["performance"].assert_not_called()
def test_connect_converts_path_to_string(self, manager, mock_sqlcipher):
"""Path objects must be converted to str for sqlcipher3.connect."""
manager._make_sqlcipher_connection(Path("/some/dir/test.db"), "secret")
call_args = mock_sqlcipher["module"].connect.call_args
assert call_args[0][0] == "/some/dir/test.db"
assert isinstance(call_args[0][0], str)
def test_connect_with_none_isolation_level(self, manager, mock_sqlcipher):
"""isolation_level=None (autocommit) must be forwarded to connect."""
manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret", isolation_level=None
)
mock_sqlcipher["module"].connect.assert_called_once_with(
"/tmp/test.db",
isolation_level=None,
check_same_thread=False,
)
def test_conn_not_closed_on_success(self, manager, mock_sqlcipher):
"""On success, cursor is closed but connection must remain open."""
result = manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret"
)
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_not_called()
assert result is mock_sqlcipher["conn"]
def test_key_error_prevents_further_calls(self, manager, mock_sqlcipher):
"""If set_sqlcipher_key fails, no pragmas or verify should run."""
mock_sqlcipher["set_key"].side_effect = RuntimeError("bad key")
with pytest.raises(RuntimeError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["pragmas"].assert_not_called()
mock_sqlcipher["verify"].assert_not_called()
mock_sqlcipher["performance"].assert_not_called()
def test_original_exception_propagated(self, manager, mock_sqlcipher):
"""The original exception must propagate without wrapping."""
original = RuntimeError("specific error")
mock_sqlcipher["pragmas"].side_effect = original
with pytest.raises(RuntimeError) as exc_info:
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
assert exc_info.value is original
def test_conn_closed_even_if_cursor_close_raises(
self, manager, mock_sqlcipher
):
"""Connection must close even when cursor.close() raises during cleanup."""
mock_sqlcipher["verify"].return_value = False
mock_sqlcipher["cursor"].close.side_effect = RuntimeError(
"cursor close failed"
)
with pytest.raises(ValueError, match="Failed to verify database key"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["conn"].close.assert_called_once()
def test_import_error_propagates(self, manager):
"""ImportError from get_sqlcipher_module() must propagate cleanly."""
with patch(
f"{_EDB}.get_sqlcipher_module",
side_effect=ImportError("no sqlcipher3"),
):
with pytest.raises(ImportError, match="no sqlcipher3"):
manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret"
)
def test_connect_failure_propagates(self, manager, mock_sqlcipher):
"""Exceptions from sqlcipher3.connect() must propagate without cleanup attempts."""
mock_sqlcipher["module"].connect.side_effect = OSError("disk full")
with pytest.raises(OSError, match="disk full"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["set_key"].assert_not_called()
def test_closes_cursor_and_conn_on_performance_pragma_error(
self, manager, mock_sqlcipher
):
"""Cursor and connection must be closed when apply_performance_pragmas raises."""
mock_sqlcipher["performance"].side_effect = RuntimeError(
"perf pragma failed"
)
with pytest.raises(RuntimeError, match="perf pragma failed"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
mock_sqlcipher["verify"].assert_called_once()
def test_closes_cursor_and_conn_on_verify_exception(
self, manager, mock_sqlcipher
):
"""Cleanup must happen when verify_sqlcipher_connection raises (not just returns False)."""
mock_sqlcipher["verify"].side_effect = RuntimeError("verify crashed")
with pytest.raises(RuntimeError, match="verify crashed"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
mock_sqlcipher["performance"].assert_not_called()
# NOTE: The former TestMetricsThreadConnectionLogging tests exercised
# the inline SQLCipher creator inside create_thread_safe_session_for_metrics.
# That method now delegates to open_user_database(), so connection-failure
# logging is covered by the open_user_database tests instead.
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
"""
Tests for SQLCipher missing scenario.
Verifies that users get helpful error messages when SQLCipher is not installed
and that the LDR_BOOTSTRAP_ALLOW_UNENCRYPTED workaround works.
"""
import os
import pytest
from unittest.mock import patch
from tests.test_utils import add_src_to_path
add_src_to_path()
def _clear_allow_unencrypted_env():
"""Clear both canonical and deprecated env vars."""
os.environ.pop("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED", None)
os.environ.pop("LDR_ALLOW_UNENCRYPTED", None)
class TestSQLCipherMissing:
"""Test behavior when SQLCipher is not available."""
def test_error_message_mentions_sqlcipher(self):
"""Error message should mention SQLCipher so users know what's missing."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
old_canonical = os.environ.pop("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED", None)
old_deprecated = os.environ.pop("LDR_ALLOW_UNENCRYPTED", None)
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
with pytest.raises(RuntimeError) as exc_info:
DatabaseManager()
error_msg = str(exc_info.value)
assert "SQLCipher" in error_msg, (
f"Error should mention SQLCipher. Got: {error_msg}"
)
finally:
if old_canonical is not None:
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = old_canonical
if old_deprecated is not None:
os.environ["LDR_ALLOW_UNENCRYPTED"] = old_deprecated
def test_error_message_mentions_workaround(self):
"""Error message should mention LDR_BOOTSTRAP_ALLOW_UNENCRYPTED workaround."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
old_canonical = os.environ.pop("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED", None)
old_deprecated = os.environ.pop("LDR_ALLOW_UNENCRYPTED", None)
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
with pytest.raises(RuntimeError) as exc_info:
DatabaseManager()
error_msg = str(exc_info.value)
assert "LDR_BOOTSTRAP_ALLOW_UNENCRYPTED" in error_msg, (
f"Error should mention workaround. Got: {error_msg}"
)
finally:
if old_canonical is not None:
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = old_canonical
if old_deprecated is not None:
os.environ["LDR_ALLOW_UNENCRYPTED"] = old_deprecated
def test_canonical_workaround_allows_startup_without_encryption(self):
"""LDR_BOOTSTRAP_ALLOW_UNENCRYPTED=true should allow startup without SQLCipher."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
old_canonical = os.environ.get("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED")
old_deprecated = os.environ.get("LDR_ALLOW_UNENCRYPTED")
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "true"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should NOT raise
manager = DatabaseManager()
assert manager.has_encryption is False, (
"With workaround and no SQLCipher, has_encryption should be False"
)
finally:
_clear_allow_unencrypted_env()
if old_canonical is not None:
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = old_canonical
if old_deprecated is not None:
os.environ["LDR_ALLOW_UNENCRYPTED"] = old_deprecated
def test_deprecated_workaround_still_works(self):
"""Deprecated LDR_ALLOW_UNENCRYPTED=true should still allow startup (backward compat)."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
old_canonical = os.environ.get("LDR_BOOTSTRAP_ALLOW_UNENCRYPTED")
old_deprecated = os.environ.get("LDR_ALLOW_UNENCRYPTED")
_clear_allow_unencrypted_env()
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should NOT raise (backward compatibility)
manager = DatabaseManager()
assert manager.has_encryption is False, (
"With deprecated workaround and no SQLCipher, has_encryption should be False"
)
finally:
_clear_allow_unencrypted_env()
if old_canonical is not None:
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = old_canonical
if old_deprecated is not None:
os.environ["LDR_ALLOW_UNENCRYPTED"] = old_deprecated
def test_db_manager_has_encryption_is_boolean(self):
"""db_manager.has_encryption should be a boolean."""
from local_deep_research.database.encrypted_db import db_manager
assert isinstance(db_manager.has_encryption, bool), (
f"has_encryption should be bool, got {type(db_manager.has_encryption)}"
)
class TestAllowUnencryptedEdgeCases:
"""Edge cases for LDR_BOOTSTRAP_ALLOW_UNENCRYPTED fallback.
The allow_unencrypted setting uses the BooleanSetting from the env
settings registry, which accepts "true", "1", "yes", "on", "enabled"
(case-insensitive) as truthy values and handles deprecated alias
fallback automatically.
"""
def test_canonical_empty_string_does_not_fall_through(self):
"""Empty string canonical should NOT fall through to deprecated.
When LDR_BOOTSTRAP_ALLOW_UNENCRYPTED="" is set, it takes precedence
and the check (empty_string or "").lower() == "true" is False.
"""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = ""
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should raise because empty string is not "true"
with pytest.raises(RuntimeError):
DatabaseManager()
finally:
_clear_allow_unencrypted_env()
def test_uppercase_true_not_accepted(self):
"""'TRUE' (uppercase) is not accepted as true.
The code uses .lower() == "true", so "TRUE".lower() == "true" is True.
"""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "TRUE"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should NOT raise - "TRUE".lower() == "true" is True
manager = DatabaseManager()
assert manager.has_encryption is False
finally:
_clear_allow_unencrypted_env()
def test_mixed_case_true_accepted(self):
"""'True' (mixed case) is accepted as true due to .lower()."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "True"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should NOT raise - "True".lower() == "true" is True
manager = DatabaseManager()
assert manager.has_encryption is False
finally:
_clear_allow_unencrypted_env()
def test_whitespace_padded_true_not_accepted(self):
"""' true ' (whitespace padded) is not accepted as true.
The code doesn't strip whitespace, so " true " != "true".
"""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = " true "
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should raise - " true " != "true"
with pytest.raises(RuntimeError):
DatabaseManager()
finally:
_clear_allow_unencrypted_env()
def test_yes_accepted_as_true(self):
"""'yes' is accepted as true by BooleanSetting."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "yes"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should NOT raise - BooleanSetting accepts "yes"
manager = DatabaseManager()
assert manager.has_encryption is False
finally:
_clear_allow_unencrypted_env()
def test_one_accepted_as_true(self):
"""'1' is accepted as true by BooleanSetting."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "1"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should NOT raise - BooleanSetting accepts "1"
manager = DatabaseManager()
assert manager.has_encryption is False
finally:
_clear_allow_unencrypted_env()
def test_canonical_false_does_not_check_deprecated(self):
"""canonical='false' should NOT fall through to deprecated='true'.
Once canonical is set (to any value), deprecated is not checked.
"""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "false"
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should raise - canonical "false" takes precedence
with pytest.raises(RuntimeError):
DatabaseManager()
finally:
_clear_allow_unencrypted_env()
def test_empty_canonical_with_deprecated_true_raises(self):
"""Empty canonical with deprecated='true' - empty takes precedence.
When canonical is "", the deprecated is not checked because
canonical is "set" (to empty string).
"""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = ""
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should raise - empty string canonical takes precedence
with pytest.raises(RuntimeError):
DatabaseManager()
finally:
_clear_allow_unencrypted_env()
def test_only_deprecated_set_to_true_works(self):
"""When only deprecated is set to 'true', it should work."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_ALLOW_UNENCRYPTED"] = "true"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should NOT raise - deprecated "true" works
manager = DatabaseManager()
assert manager.has_encryption is False
finally:
_clear_allow_unencrypted_env()
def test_deprecated_uppercase_true_accepted(self):
"""Deprecated 'TRUE' (uppercase) works due to .lower()."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
os.environ["LDR_ALLOW_UNENCRYPTED"] = "TRUE"
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
# Should NOT raise - "TRUE".lower() == "true"
manager = DatabaseManager()
assert manager.has_encryption is False
finally:
_clear_allow_unencrypted_env()
def test_neither_set_raises_runtime_error(self):
"""When neither canonical nor deprecated is set, RuntimeError is raised."""
from local_deep_research.database.encrypted_db import (
DatabaseManager,
)
_clear_allow_unencrypted_env()
try:
with patch(
"local_deep_research.database.encrypted_db.get_sqlcipher_module"
) as mock_get:
mock_get.side_effect = ImportError(
"No module named 'sqlcipher3'"
)
with pytest.raises(RuntimeError):
DatabaseManager()
finally:
_clear_allow_unencrypted_env()
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
"""Tests for TemporaryAuthStore."""
import time
class TestTemporaryAuthStore:
"""Tests for TemporaryAuthStore class."""
def test_init_with_default_ttl(self):
"""TemporaryAuthStore initializes with default 10-second TTL."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore()
assert store.ttl == 10
def test_init_with_custom_ttl(self):
"""TemporaryAuthStore accepts custom TTL in seconds."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
assert store.ttl == 60
def test_store_auth_returns_token(self):
"""store_auth returns a token string."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("testuser", "testpass")
assert token is not None
assert isinstance(token, str)
assert len(token) > 0
def test_store_auth_token_is_url_safe(self):
"""store_auth returns URL-safe token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("testuser", "testpass")
# URL-safe tokens should not contain +, /, =
# secrets.token_urlsafe uses - and _ instead
assert "+" not in token
assert "/" not in token
def test_store_auth_tokens_are_unique(self):
"""Each store_auth call returns a unique token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token1 = store.store_auth("user1", "pass1")
token2 = store.store_auth("user2", "pass2")
assert token1 != token2
def test_retrieve_auth_returns_credentials(self):
"""retrieve_auth returns (username, password) tuple."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("myuser", "mypass")
result = store.retrieve_auth(token)
assert result is not None
assert result == ("myuser", "mypass")
def test_retrieve_auth_removes_entry(self):
"""retrieve_auth removes the entry after retrieval."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("myuser", "mypass")
# First retrieval succeeds
result1 = store.retrieve_auth(token)
assert result1 == ("myuser", "mypass")
# Second retrieval returns None
result2 = store.retrieve_auth(token)
assert result2 is None
def test_retrieve_auth_nonexistent_returns_none(self):
"""retrieve_auth returns None for nonexistent token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
result = store.retrieve_auth("nonexistent-token")
assert result is None
def test_peek_auth_returns_credentials(self):
"""peek_auth returns (username, password) tuple."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("peekuser", "peekpass")
result = store.peek_auth(token)
assert result == ("peekuser", "peekpass")
def test_peek_auth_does_not_remove_entry(self):
"""peek_auth does not remove the entry."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("peekuser", "peekpass")
# Peek multiple times
result1 = store.peek_auth(token)
result2 = store.peek_auth(token)
assert result1 == ("peekuser", "peekpass")
assert result2 == ("peekuser", "peekpass")
def test_peek_auth_nonexistent_returns_none(self):
"""peek_auth returns None for nonexistent token."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
result = store.peek_auth("nonexistent-token")
assert result is None
def test_auth_expires_after_ttl(self):
"""Auth expires and returns None after TTL."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("expuser", "exppass")
# Manually set expiration to past
store._store[token]["expires_at"] = time.time() - 1
# Should return None
result = store.retrieve_auth(token)
assert result is None
def test_expired_peek_returns_none(self):
"""peek_auth returns None for expired entry."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("expuser", "exppass")
# Manually set expiration to past
store._store[token]["expires_at"] = time.time() - 1
# Should return None
result = store.peek_auth(token)
assert result is None
def test_store_alias_method(self):
"""store() is an alias for store_auth()."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store("aliasuser", "aliaspass")
result = store.retrieve_auth(token)
assert result == ("aliasuser", "aliaspass")
def test_retrieve_alias_method(self):
"""retrieve() is an alias for retrieve_auth()."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token = store.store_auth("aliasuser", "aliaspass")
result = store.retrieve(token)
assert result == ("aliasuser", "aliaspass")
def test_multiple_users(self):
"""Can store credentials for multiple users."""
from local_deep_research.database.temp_auth import TemporaryAuthStore
store = TemporaryAuthStore(ttl_seconds=60)
token1 = store.store_auth("user1", "pass1")
token2 = store.store_auth("user2", "pass2")
token3 = store.store_auth("user3", "pass3")
assert store.peek_auth(token1) == ("user1", "pass1")
assert store.peek_auth(token2) == ("user2", "pass2")
assert store.peek_auth(token3) == ("user3", "pass3")
class TestTemporaryAuthStoreGlobalInstance:
"""Tests for the global temp_auth_store instance."""
def test_global_instance_is_temporary_auth_store(self):
"""Global instance is TemporaryAuthStore type."""
from local_deep_research.database.temp_auth import (
temp_auth_store,
TemporaryAuthStore,
)
assert isinstance(temp_auth_store, TemporaryAuthStore)
+594
View File
@@ -0,0 +1,594 @@
"""Tests for database/thread_local_session.py."""
import threading
from unittest.mock import Mock, call, patch
from sqlalchemy.exc import OperationalError, PendingRollbackError
class TestThreadLocalSessionManager:
"""Tests for ThreadLocalSessionManager class."""
def test_init_creates_thread_local_storage(self):
"""Test that initialization creates thread-local storage."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
assert hasattr(manager, "_local")
assert isinstance(manager._local, threading.local)
def test_init_creates_credentials_tracking(self):
"""Test that initialization creates credentials tracking dict."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
assert hasattr(manager, "_thread_credentials")
assert isinstance(manager._thread_credentials, dict)
def test_init_creates_lock(self):
"""Test that initialization creates a threading lock."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
assert hasattr(manager, "_lock")
assert isinstance(manager._lock, type(threading.Lock()))
def test_get_session_creates_new_session(self):
"""Test that get_session creates a new session when none exists."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
with patch(
"local_deep_research.database.thread_local_session.db_manager"
) as mock_db:
mock_engine = Mock()
mock_session = Mock()
mock_db.open_user_database.return_value = mock_engine
mock_db.create_thread_safe_session_for_metrics.return_value = (
mock_session
)
result = manager.get_session("testuser", "testpass")
assert result is mock_session
mock_db.open_user_database.assert_called_once_with(
"testuser", "testpass"
)
def test_get_session_reuses_existing_session(self):
"""Test that get_session reuses an existing valid session."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
mock_existing_session = Mock()
mock_existing_session.execute.return_value = None
# Manually set up an existing session
manager._local.session = mock_existing_session
manager._local.username = "testuser"
result = manager.get_session("testuser", "testpass")
# Should return the existing session
assert result is mock_existing_session
# Verify text() wrapper type and content explicitly
from sqlalchemy.sql.elements import TextClause
call_args = mock_existing_session.execute.call_args[0][0]
assert isinstance(call_args, TextClause)
assert call_args.text == "SELECT 1"
def test_get_session_creates_new_when_existing_invalid(self):
"""Test that get_session creates new session when existing is invalid."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
mock_invalid_session = Mock()
mock_invalid_session.execute.side_effect = OperationalError(
"stmt", {}, Exception("Connection lost")
)
manager._local.session = mock_invalid_session
manager._local.username = "testuser"
with patch(
"local_deep_research.database.thread_local_session.db_manager"
) as mock_db:
mock_engine = Mock()
mock_new_session = Mock()
mock_db.open_user_database.return_value = mock_engine
mock_db.create_thread_safe_session_for_metrics.return_value = (
mock_new_session
)
result = manager.get_session("testuser", "testpass")
# Should create a new session
assert result is mock_new_session
def test_get_session_clears_cross_user_cached_session(self):
"""Cached session belonging to a different user is cleared and a fresh one created."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
stale_session = Mock()
manager._local.session = stale_session
manager._local.username = "alice" # cached for alice
with patch(
"local_deep_research.database.thread_local_session.db_manager"
) as mock_db:
mock_engine = Mock()
mock_db.open_user_database.return_value = mock_engine
new_session = Mock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
new_session
)
result = manager.get_session("bob", "bobpass")
assert result is new_session
assert result is not stale_session # old session was cleared
stale_session.close.assert_called() # cleanup was called
mock_db.open_user_database.assert_called_once_with("bob", "bobpass")
def test_get_session_returns_none_on_db_open_failure(self):
"""Test that get_session returns None when database fails to open."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
with patch(
"local_deep_research.database.thread_local_session.db_manager"
) as mock_db:
mock_db.open_user_database.return_value = None
result = manager.get_session("testuser", "testpass")
assert result is None
def test_get_current_session_returns_none_when_no_session(self):
"""Test that get_current_session returns None when no session exists."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
result = manager.get_current_session()
assert result is None
def test_get_current_session_returns_existing_session(self):
"""Test that get_current_session returns the existing session."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
mock_session = Mock()
manager._local.session = mock_session
result = manager.get_current_session()
assert result is mock_session
def test_cleanup_thread_cleans_current_thread(self):
"""Test that cleanup_thread cleans up the current thread's session."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
mock_session = Mock()
manager._local.session = mock_session
manager._local.username = "testuser"
thread_id = threading.get_ident()
manager._thread_credentials[thread_id] = ("testuser", "testpass")
with patch(
"local_deep_research.database.thread_local_session.db_manager"
):
manager.cleanup_thread()
mock_session.close.assert_called_once()
assert manager._local.session is None
assert thread_id not in manager._thread_credentials
def test_get_session_recovers_from_pending_rollback_error(self):
"""Test that get_session recovers from PendingRollbackError via rollback.
Rollback is called twice in the recovery path: once to clear the
pending-rollback state so the retry SELECT 1 can run, and once
after that SELECT 1 succeeds to release the SHARED lock the
validation transaction held under DEFERRED isolation.
"""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
mock_session = Mock()
# First execute raises PendingRollbackError, after rollback it succeeds
mock_session.execute.side_effect = [
PendingRollbackError("test"), # Initial validation fails
None, # Retry after rollback succeeds
]
manager._local.session = mock_session
manager._local.username = "testuser"
result = manager.get_session("testuser", "testpass")
# Should recover the same session via rollback
assert result is mock_session
assert mock_session.rollback.call_count == 2
assert mock_session.execute.call_count == 2
def test_get_session_recreates_when_rollback_recovery_fails(self):
"""Test that get_session recreates session when rollback recovery fails."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
mock_old_session = Mock()
# Both execute calls fail — rollback doesn't help
mock_old_session.execute.side_effect = PendingRollbackError("test")
mock_old_session.rollback.side_effect = OperationalError(
"stmt", {}, Exception("rollback failed")
)
manager._local.session = mock_old_session
manager._local.username = "testuser"
thread_id = threading.get_ident()
manager._thread_credentials[thread_id] = ("testuser", "testpass")
with patch(
"local_deep_research.database.thread_local_session.db_manager"
) as mock_db:
mock_new_session = Mock()
mock_db.open_user_database.return_value = Mock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
mock_new_session
)
result = manager.get_session("testuser", "testpass")
# Should fall back to creating a new session
assert result is mock_new_session
# close() must still be called on old session even though rollback() failed
mock_old_session.close.assert_called_once()
def test_cleanup_thread_session_calls_rollback_before_close(self):
"""Test that _cleanup_thread_session calls rollback before close."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
mock_session = Mock()
manager._local.session = mock_session
manager._local.username = "testuser"
thread_id = threading.get_ident()
manager._thread_credentials[thread_id] = ("testuser", "testpass")
with patch(
"local_deep_research.database.thread_local_session.db_manager"
):
manager._cleanup_thread_session()
# Verify rollback is called before close
expected_calls = [call.rollback(), call.close()]
mock_session.assert_has_calls(expected_calls, any_order=False)
def test_cleanup_thread_session_still_closes_when_rollback_fails(self):
"""Test that close() is called even when rollback() raises."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
mock_session = Mock()
mock_session.rollback.side_effect = OperationalError(
"stmt", {}, Exception("dead connection")
)
manager._local.session = mock_session
manager._local.username = "testuser"
thread_id = threading.get_ident()
manager._thread_credentials[thread_id] = ("testuser", "testpass")
with patch(
"local_deep_research.database.thread_local_session.db_manager"
):
manager._cleanup_thread_session()
mock_session.close.assert_called_once()
assert manager._local.session is None
def test_cleanup_all_cleans_all_threads(self):
"""Test that cleanup_all cleans up all tracked sessions."""
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
manager = ThreadLocalSessionManager()
manager._thread_credentials = {
1: ("user1", "pass1"),
2: ("user2", "pass2"),
}
# cleanup_all should iterate credentials and call cleanup_thread
# without touching any engine machinery.
manager.cleanup_all()
assert manager._thread_credentials == {}
class TestThreadSessionContext:
"""Tests for ThreadSessionContext context manager."""
def test_context_manager_returns_session(self):
"""Test that context manager returns a session on enter."""
from local_deep_research.database.thread_local_session import (
ThreadSessionContext,
)
with patch(
"local_deep_research.database.thread_local_session.get_metrics_session"
) as mock_get:
mock_session = Mock()
mock_get.return_value = mock_session
with ThreadSessionContext("testuser", "testpass") as session:
assert session is mock_session
def test_context_manager_stores_credentials(self):
"""Test that context manager stores username and password."""
from local_deep_research.database.thread_local_session import (
ThreadSessionContext,
)
ctx = ThreadSessionContext("myuser", "mypass")
assert ctx.username == "myuser"
assert ctx.password == "mypass"
class TestModuleFunctions:
"""Tests for module-level functions."""
def test_get_metrics_session_delegates_to_manager(self):
"""Test that get_metrics_session delegates to thread_session_manager."""
from local_deep_research.database.thread_local_session import (
get_metrics_session,
thread_session_manager,
)
with patch.object(thread_session_manager, "get_session") as mock_get:
mock_session = Mock()
mock_get.return_value = mock_session
result = get_metrics_session("testuser", "testpass")
mock_get.assert_called_once_with("testuser", "testpass")
assert result is mock_session
def test_get_current_thread_session_delegates_to_manager(self):
"""Test that get_current_thread_session delegates to manager."""
from local_deep_research.database.thread_local_session import (
get_current_thread_session,
thread_session_manager,
)
with patch.object(
thread_session_manager, "get_current_session"
) as mock_get:
mock_session = Mock()
mock_get.return_value = mock_session
result = get_current_thread_session()
mock_get.assert_called_once()
assert result is mock_session
def test_cleanup_current_thread_delegates_to_manager(self):
"""Test that cleanup_current_thread delegates to manager."""
from local_deep_research.database.thread_local_session import (
cleanup_current_thread,
thread_session_manager,
)
with patch.object(
thread_session_manager, "cleanup_thread"
) as mock_cleanup:
cleanup_current_thread()
mock_cleanup.assert_called_once()
class TestGlobalInstance:
"""Tests for the global thread_session_manager instance."""
def test_global_instance_is_correct_type(self):
"""Test that global instance is ThreadLocalSessionManager."""
from local_deep_research.database.thread_local_session import (
thread_session_manager,
ThreadLocalSessionManager,
)
assert isinstance(thread_session_manager, ThreadLocalSessionManager)
class TestThreadCleanup:
"""Tests for thread_cleanup decorator / context manager."""
def _patch_all_cleanup(self):
"""Return a stack of patches for the three cleanup functions."""
return (
patch(
"local_deep_research.database.thread_local_session.cleanup_current_thread"
),
patch(
"local_deep_research.database.thread_local_session._ThreadCleanup.__exit__",
wraps=None,
),
)
def test_bare_decorator_runs_cleanup(self):
"""@thread_cleanup runs cleanup on exit and returns result."""
from local_deep_research.database.thread_local_session import (
thread_cleanup,
)
mock_cleanup = Mock()
with patch(
"local_deep_research.database.thread_local_session.cleanup_current_thread",
mock_cleanup,
):
@thread_cleanup
def worker():
return 42
result = worker()
assert result == 42
mock_cleanup.assert_called_once()
def test_factory_decorator_runs_cleanup(self):
"""@thread_cleanup() (with parens) runs cleanup on exit and returns result."""
from local_deep_research.database.thread_local_session import (
thread_cleanup,
)
mock_cleanup = Mock()
with patch(
"local_deep_research.database.thread_local_session.cleanup_current_thread",
mock_cleanup,
):
@thread_cleanup()
def worker():
return 99
result = worker()
assert result == 99
mock_cleanup.assert_called_once()
def test_context_manager_runs_cleanup(self):
"""with thread_cleanup(): runs cleanup on exit."""
from local_deep_research.database.thread_local_session import (
thread_cleanup,
)
mock_cleanup = Mock()
with patch(
"local_deep_research.database.thread_local_session.cleanup_current_thread",
mock_cleanup,
):
with thread_cleanup():
pass
mock_cleanup.assert_called_once()
def test_inline_wrapper_runs_cleanup(self):
"""thread_cleanup(func) as inline wrapper runs cleanup on exit."""
from local_deep_research.database.thread_local_session import (
thread_cleanup,
)
mock_cleanup = Mock()
def worker(x):
return x * 2
with patch(
"local_deep_research.database.thread_local_session.cleanup_current_thread",
mock_cleanup,
):
wrapped = thread_cleanup(worker)
result = wrapped(5)
assert result == 10
mock_cleanup.assert_called_once()
def test_cleanup_exception_logged_not_raised(self):
"""Cleanup exceptions are logged at debug level, not raised."""
from local_deep_research.database.thread_local_session import (
thread_cleanup,
)
with (
patch(
"local_deep_research.database.thread_local_session.cleanup_current_thread",
side_effect=RuntimeError("cleanup boom"),
),
patch(
"local_deep_research.database.thread_local_session.logger"
) as mock_logger,
):
@thread_cleanup
def worker():
return "ok"
result = worker()
assert result == "ok"
mock_logger.debug.assert_called()
def test_original_exception_propagates_when_cleanup_fails(self):
"""Original exceptions propagate even when cleanup fails."""
from local_deep_research.database.thread_local_session import (
thread_cleanup,
)
with (
patch(
"local_deep_research.database.thread_local_session.cleanup_current_thread",
side_effect=RuntimeError("cleanup boom"),
),
patch("local_deep_research.database.thread_local_session.logger"),
):
@thread_cleanup
def worker():
raise ValueError("original error")
try:
worker()
assert False, "Should have raised ValueError"
except ValueError as e:
assert str(e) == "original error"
def test_functools_wraps_metadata_preserved(self):
"""functools.wraps metadata preserved on decorated functions."""
from local_deep_research.database.thread_local_session import (
thread_cleanup,
)
@thread_cleanup
def my_worker():
"""My docstring."""
pass
assert my_worker.__name__ == "my_worker"
assert my_worker.__doc__ == "My docstring."
@@ -0,0 +1,151 @@
"""Coverage tests for database/thread_local_session.py targeting ~8 missing statements.
Uncovered functions/branches:
- ThreadLocalSessionManager.get_session: PendingRollbackError recovery path
- ThreadLocalSessionManager.get_session: rollback recovery fails path
- ThreadLocalSessionManager.cleanup_thread: other thread branch (line 148-152)
- ThreadLocalSessionManager.cleanup_dead_threads: dead thread sweep
- _ThreadCleanup.__exit__: exception during cleanup paths
"""
from unittest.mock import MagicMock, patch
from sqlalchemy.exc import PendingRollbackError
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
ThreadSessionContext,
_ThreadCleanup,
cleanup_dead_threads,
thread_cleanup,
)
MODULE = "local_deep_research.database.thread_local_session"
class TestGetSessionPendingRollback:
"""Tests for PendingRollbackError recovery in get_session."""
def test_pending_rollback_recovery_succeeds(self):
"""PendingRollbackError is recovered via rollback.
Rollback is called twice in this path: once to clear the
pending-rollback state so the retry SELECT 1 can run, and once
more after the retry succeeds to release the SHARED lock the
validation transaction holds under DEFERRED isolation.
"""
mgr = ThreadLocalSessionManager()
mock_session = MagicMock()
# First call raises PendingRollbackError, after rollback it succeeds
mock_session.execute.side_effect = [
PendingRollbackError("pending"),
MagicMock(), # after rollback, SELECT 1 succeeds
]
mgr._local.session = mock_session
mgr._local.username = "user"
with patch(f"{MODULE}.db_manager"):
result = mgr.get_session("user", "pass")
assert result is mock_session
assert mock_session.rollback.call_count == 2
def test_pending_rollback_recovery_fails_creates_new(self):
"""When rollback recovery fails, creates a new session."""
mgr = ThreadLocalSessionManager()
mock_session = MagicMock()
mock_session.execute.side_effect = PendingRollbackError("pending")
mock_session.rollback.side_effect = Exception("rollback failed")
mgr._local.session = mock_session
mgr._local.username = "user"
new_session = MagicMock()
with patch(f"{MODULE}.db_manager") as mock_db:
mock_db.open_user_database.return_value = MagicMock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
new_session
)
result = mgr.get_session("user", "pass")
assert result is new_session
class TestCleanupThread:
"""Tests for cleanup_thread with different thread IDs."""
def test_cleanup_other_thread_removes_credentials(self):
"""Cleaning up another thread only removes credentials."""
mgr = ThreadLocalSessionManager()
other_tid = 99999999
mgr._thread_credentials[other_tid] = ("user", "pass")
mgr.cleanup_thread(other_tid)
assert other_tid not in mgr._thread_credentials
class TestCleanupDeadThreads:
"""Tests for cleanup_dead_threads."""
def test_sweeps_dead_thread_credentials(self):
"""Dead thread entries are removed."""
mgr = ThreadLocalSessionManager()
dead_tid = 11111111
mgr._thread_credentials[dead_tid] = ("user", "pass")
# Current thread is alive, dead_tid is not
mgr.cleanup_dead_threads()
assert dead_tid not in mgr._thread_credentials
def test_module_level_cleanup_dead_threads(self):
"""Module-level cleanup_dead_threads delegates to session manager."""
with patch(f"{MODULE}.thread_session_manager") as mock_mgr:
cleanup_dead_threads()
mock_mgr.cleanup_dead_threads.assert_called_once()
def test_module_level_cleanup_session_failure_swallowed(self):
"""If session sweep fails, the wrapper does not raise."""
with patch(f"{MODULE}.thread_session_manager") as mock_mgr:
mock_mgr.cleanup_dead_threads.side_effect = RuntimeError("boom")
# Should not raise
cleanup_dead_threads()
class TestThreadCleanupContextManager:
"""Tests for _ThreadCleanup context manager."""
def test_cleanup_exception_suppressed(self):
"""Exceptions during cleanup are suppressed (logged)."""
with patch(
f"{MODULE}.cleanup_current_thread", side_effect=Exception("boom")
):
# Should not raise
with _ThreadCleanup():
pass
def test_thread_cleanup_as_decorator(self):
"""thread_cleanup works as a bare decorator."""
@thread_cleanup
def worker():
return 42
with patch(f"{MODULE}.cleanup_current_thread"):
assert worker() == 42
def test_thread_cleanup_as_factory(self):
"""thread_cleanup() works as a decorator factory."""
with patch(f"{MODULE}.cleanup_current_thread"):
with thread_cleanup():
pass # Should not raise
class TestThreadSessionContext:
"""Tests for ThreadSessionContext."""
def test_context_manager_returns_session(self):
"""Context manager returns a session."""
with patch(
f"{MODULE}.get_metrics_session", return_value=MagicMock()
) as mock_get:
with ThreadSessionContext("user", "pass") as session:
assert session is not None
mock_get.assert_called_once_with("user", "pass")
@@ -0,0 +1,237 @@
"""Deep coverage tests for database/thread_local_session.py.
Focuses on:
- ThreadLocalSessionManager.get_session: session reuse, invalid session recovery,
PendingRollbackError rollback then re-execute fails -> new session
- ThreadLocalSessionManager.cleanup_all
- Module-level helpers: get_metrics_session, get_current_thread_session,
cleanup_current_thread
- _ThreadCleanup: clear_settings_context and clear_search_context exception paths
- ThreadSessionContext: None session handling
"""
import threading
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.exc import PendingRollbackError
MODULE = "local_deep_research.database.thread_local_session"
def _make_manager():
from local_deep_research.database.thread_local_session import (
ThreadLocalSessionManager,
)
return ThreadLocalSessionManager()
# ---------------------------------------------------------------------------
# get_session full coverage of branches
# ---------------------------------------------------------------------------
class TestGetSessionBranches:
def test_reuses_valid_session(self):
"""When an existing valid session is in thread-local, it is reused."""
mgr = _make_manager()
valid_sess = MagicMock()
mgr._local.session = valid_sess
mgr._local.username = "alice"
result = mgr.get_session("alice", "pw")
assert result is valid_sess
valid_sess.execute.assert_called()
def test_pending_rollback_then_re_execute_fails_creates_new(self):
"""PendingRollbackError, rollback succeeds but SELECT 1 again fails."""
mgr = _make_manager()
broken_sess = MagicMock()
broken_sess.execute.side_effect = [
PendingRollbackError("pending"),
Exception("still broken"), # second execute after rollback fails
]
mgr._local.session = broken_sess
mgr._local.username = "alice"
new_sess = MagicMock()
with patch(f"{MODULE}.db_manager") as mock_db:
mock_db.open_user_database.return_value = MagicMock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
new_sess
)
result = mgr.get_session("alice", "pw")
assert result is new_sess
def test_generic_exception_invalidates_session(self):
"""Any exception from execute causes cleanup and new session creation."""
mgr = _make_manager()
broken_sess = MagicMock()
broken_sess.execute.side_effect = OSError("db file locked")
mgr._local.session = broken_sess
mgr._local.username = "alice"
new_sess = MagicMock()
with patch(f"{MODULE}.db_manager") as mock_db:
mock_db.open_user_database.return_value = MagicMock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
new_sess
)
result = mgr.get_session("alice", "pw")
assert result is new_sess
def test_credentials_tracked_after_new_session(self):
mgr = _make_manager()
new_sess = MagicMock()
with patch(f"{MODULE}.db_manager") as mock_db:
mock_db.open_user_database.return_value = MagicMock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
new_sess
)
mgr.get_session("bob", "s3cr3t")
tid = threading.get_ident()
assert mgr._thread_credentials.get(tid) == ("bob", "s3cr3t")
# ---------------------------------------------------------------------------
# cleanup_all
# ---------------------------------------------------------------------------
class TestCleanupAll:
def test_cleanup_all_calls_cleanup_for_all_tracked_threads(self):
mgr = _make_manager()
mgr._thread_credentials[111] = ("a", "pw")
mgr._thread_credentials[222] = ("b", "pw")
called_with = []
def track_cleanup(tid=None):
called_with.append(tid)
mgr.cleanup_thread = track_cleanup
mgr.cleanup_all()
# Both thread IDs should have been cleaned up
assert len(called_with) == 2
# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------
class TestModuleLevelHelpers:
def test_get_metrics_session_delegates_to_manager(self):
from local_deep_research.database.thread_local_session import (
get_metrics_session,
)
mock_sess = MagicMock()
with patch(f"{MODULE}.thread_session_manager") as mock_mgr:
mock_mgr.get_session.return_value = mock_sess
result = get_metrics_session("alice", "pw")
assert result is mock_sess
mock_mgr.get_session.assert_called_once_with("alice", "pw")
def test_get_current_thread_session_delegates(self):
from local_deep_research.database.thread_local_session import (
get_current_thread_session,
)
mock_sess = MagicMock()
with patch(f"{MODULE}.thread_session_manager") as mock_mgr:
mock_mgr.get_current_session.return_value = mock_sess
result = get_current_thread_session()
assert result is mock_sess
def test_cleanup_current_thread_delegates(self):
from local_deep_research.database.thread_local_session import (
cleanup_current_thread,
)
with patch(f"{MODULE}.thread_session_manager") as mock_mgr:
cleanup_current_thread()
mock_mgr.cleanup_thread.assert_called_once_with()
# ---------------------------------------------------------------------------
# _ThreadCleanup exception suppression paths
# ---------------------------------------------------------------------------
class TestThreadCleanupExceptionPaths:
def test_clear_settings_context_exception_suppressed(self):
from local_deep_research.database.thread_local_session import (
_ThreadCleanup,
)
with (
patch(f"{MODULE}.cleanup_current_thread"),
patch(
"local_deep_research.config.thread_settings.clear_settings_context",
side_effect=RuntimeError("settings boom"),
create=True,
),
):
with _ThreadCleanup():
pass # Should not raise
def test_clear_search_context_exception_suppressed(self):
from local_deep_research.database.thread_local_session import (
_ThreadCleanup,
)
with (
patch(f"{MODULE}.cleanup_current_thread"),
patch(
"local_deep_research.utilities.thread_context.clear_search_context",
side_effect=RuntimeError("search boom"),
create=True,
),
):
with _ThreadCleanup():
pass # Should not raise
def test_returns_false_from_exit(self):
"""__exit__ returns False to propagate exceptions from the body."""
from local_deep_research.database.thread_local_session import (
_ThreadCleanup,
)
with patch(f"{MODULE}.cleanup_current_thread"):
cm = _ThreadCleanup()
cm.__enter__()
result = cm.__exit__(None, None, None)
assert result is False
# ---------------------------------------------------------------------------
# ThreadSessionContext None session
# ---------------------------------------------------------------------------
class TestThreadSessionContextNone:
def test_none_session_is_yielded(self):
from local_deep_research.database.thread_local_session import (
ThreadSessionContext,
)
with patch(f"{MODULE}.get_metrics_session", return_value=None):
with ThreadSessionContext("alice", "pw") as sess:
assert sess is None
def test_exception_in_body_propagates(self):
from local_deep_research.database.thread_local_session import (
ThreadSessionContext,
)
mock_sess = MagicMock()
with patch(f"{MODULE}.get_metrics_session", return_value=mock_sess):
with pytest.raises(ValueError, match="body error"):
with ThreadSessionContext("alice", "pw"):
raise ValueError("body error")
+324
View File
@@ -0,0 +1,324 @@
"""Tests for database/thread_metrics.py."""
import pytest
import threading
from unittest.mock import Mock, patch
class TestThreadSafeMetricsWriter:
"""Tests for ThreadSafeMetricsWriter class."""
def test_init_creates_thread_local_storage(self):
"""Test that initialization creates thread-local storage."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
assert hasattr(writer, "_thread_local")
assert isinstance(writer._thread_local, threading.local)
def test_set_user_password_stores_in_thread_local(self):
"""Test that set_user_password stores password in thread-local storage."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("testuser", "testpass")
assert hasattr(writer._thread_local, "passwords")
assert writer._thread_local.passwords["testuser"] == "testpass"
def test_set_user_password_creates_dict_if_missing(self):
"""Test that set_user_password creates passwords dict if it doesn't exist."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
# Initially no passwords dict
assert not hasattr(writer._thread_local, "passwords")
writer.set_user_password("user1", "pass1")
assert hasattr(writer._thread_local, "passwords")
assert isinstance(writer._thread_local.passwords, dict)
def test_set_user_password_supports_multiple_users(self):
"""Test that multiple users can have passwords stored."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("user1", "pass1")
writer.set_user_password("user2", "pass2")
assert writer._thread_local.passwords["user1"] == "pass1"
assert writer._thread_local.passwords["user2"] == "pass2"
def test_get_session_raises_when_no_password_set(self):
"""Test that get_session raises error when no password is set."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
with pytest.raises(ValueError, match="No password set"):
with writer.get_session("testuser"):
pass
def test_get_session_raises_when_user_password_missing(self):
"""Test that get_session raises error when user's password is missing."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("otheruser", "otherpass")
with pytest.raises(ValueError, match="No password available"):
with writer.get_session("testuser"):
pass
def test_get_session_creates_session_with_password(self):
"""Test that get_session creates session with stored password."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("testuser", "testpass")
with patch(
"local_deep_research.database.thread_metrics.db_manager"
) as mock_db:
mock_session = Mock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
mock_session
)
with writer.get_session("testuser") as session:
assert session is mock_session
mock_db.create_thread_safe_session_for_metrics.assert_called_once_with(
"testuser", "testpass"
)
def test_get_session_commits_on_success(self):
"""Test that get_session commits the session on successful exit."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("testuser", "testpass")
with patch(
"local_deep_research.database.thread_metrics.db_manager"
) as mock_db:
mock_session = Mock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
mock_session
)
with writer.get_session("testuser"):
pass
mock_session.commit.assert_called_once()
mock_session.close.assert_called_once()
def test_get_session_rollbacks_on_error(self):
"""Test that get_session rolls back on error."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("testuser", "testpass")
with patch(
"local_deep_research.database.thread_metrics.db_manager"
) as mock_db:
mock_session = Mock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
mock_session
)
with pytest.raises(RuntimeError):
with writer.get_session("testuser"):
raise RuntimeError("Test error")
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()
def test_get_session_raises_when_session_creation_fails(self):
"""Test that get_session raises error when session creation fails."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("testuser", "testpass")
with patch(
"local_deep_research.database.thread_metrics.db_manager"
) as mock_db:
mock_db.create_thread_safe_session_for_metrics.return_value = None
with pytest.raises(ValueError, match="Failed to create session"):
with writer.get_session("testuser"):
pass
class TestWriteTokenMetrics:
"""Tests for write_token_metrics method."""
def test_write_token_metrics_creates_token_usage_record(self):
"""Test that write_token_metrics creates a TokenUsage record."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("testuser", "testpass")
mock_session = Mock()
mock_token_usage_class = Mock()
with patch(
"local_deep_research.database.thread_metrics.db_manager"
) as mock_db:
mock_db.create_thread_safe_session_for_metrics.return_value = (
mock_session
)
with patch(
"local_deep_research.database.models.TokenUsage",
mock_token_usage_class,
):
with patch("local_deep_research.database.models.ModelUsage"):
mock_session.query.return_value.filter_by.return_value.first.return_value = None
token_data = {
"model_name": "gpt-4",
"provider": "openai",
"prompt_tokens": 100,
"completion_tokens": 50,
}
writer.write_token_metrics("testuser", 123, token_data)
# Verify TokenUsage was created and added to session
mock_token_usage_class.assert_called_once()
assert mock_session.add.call_count == 2
class TestGlobalInstance:
"""Tests for the global metrics_writer instance."""
def test_global_instance_is_correct_type(self):
"""Test that global instance is ThreadSafeMetricsWriter."""
from local_deep_research.database.thread_metrics import (
metrics_writer,
ThreadSafeMetricsWriter,
)
assert isinstance(metrics_writer, ThreadSafeMetricsWriter)
class TestClearPasswords:
"""Tests for clear_passwords() and its integration with cleanup_current_thread()."""
def test_clear_passwords_empties_dict(self):
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
writer.set_user_password("userA", "passA")
writer.set_user_password("userB", "passB")
assert writer._thread_local.passwords == {
"userA": "passA",
"userB": "passB",
}
writer.clear_passwords()
assert writer._thread_local.passwords == {}
def test_clear_passwords_is_noop_when_no_passwords_set(self):
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
# No passwords dict exists; clear_passwords should not raise.
writer.clear_passwords()
assert not hasattr(writer._thread_local, "passwords")
def test_cleanup_current_thread_clears_passwords(self):
"""Regression test — cleanup_current_thread() must clear the passwords dict
on the module-level metrics_writer so pooled workers don't retain creds."""
from local_deep_research.database.thread_metrics import metrics_writer
from local_deep_research.database.thread_local_session import (
cleanup_current_thread,
)
metrics_writer.set_user_password("leaked_user", "leaked_pass")
assert (
metrics_writer._thread_local.passwords.get("leaked_user")
== "leaked_pass"
)
cleanup_current_thread()
assert metrics_writer._thread_local.passwords == {}
class TestThreadIsolation:
"""Tests for thread isolation of password storage."""
def test_passwords_are_thread_isolated(self):
"""Test that passwords stored in one thread are not visible in another."""
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
)
writer = ThreadSafeMetricsWriter()
results = {}
def thread1_work():
writer.set_user_password("user1", "pass1")
results["thread1_has_password"] = hasattr(
writer._thread_local, "passwords"
)
results["thread1_user1"] = writer._thread_local.passwords.get(
"user1"
)
def thread2_work():
# Small delay to ensure thread1 runs first
import time
time.sleep(0.01)
results["thread2_has_password"] = hasattr(
writer._thread_local, "passwords"
)
t1 = threading.Thread(target=thread1_work)
t2 = threading.Thread(target=thread2_work)
t1.start()
t2.start()
t1.join()
t2.join()
# Thread 1 should have its password
assert results["thread1_has_password"] is True
assert results["thread1_user1"] == "pass1"
# Thread 2 should NOT have access to thread 1's passwords
assert results["thread2_has_password"] is False
@@ -0,0 +1,640 @@
"""Comprehensive tests for ThreadSafeMetricsWriter in database/thread_metrics.py."""
import threading
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.database.thread_metrics import (
ThreadSafeMetricsWriter,
metrics_writer,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def writer():
"""Return a fresh ThreadSafeMetricsWriter instance."""
return ThreadSafeMetricsWriter()
@pytest.fixture
def writer_with_password(writer):
"""Return a writer that already has a password stored for 'testuser'."""
writer.set_user_password("testuser", "testpass")
return writer
@pytest.fixture
def mock_db_manager():
"""Patch db_manager and yield (mock_db_manager, mock_session)."""
with patch(
"local_deep_research.database.thread_metrics.db_manager"
) as mock_db:
mock_session = MagicMock()
mock_db.create_thread_safe_session_for_metrics.return_value = (
mock_session
)
yield mock_db, mock_session
# ===========================================================================
# set_user_password
# ===========================================================================
class TestSetUserPassword:
"""Tests for ThreadSafeMetricsWriter.set_user_password."""
def test_stores_password_in_thread_local(self, writer):
"""set_user_password stores the password under _thread_local.passwords."""
writer.set_user_password("alice", "secret123")
assert writer._thread_local.passwords["alice"] == "secret123"
def test_creates_passwords_dict_if_not_exists(self, writer):
"""Passwords dict is lazily created on first call."""
assert not hasattr(writer._thread_local, "passwords")
writer.set_user_password("alice", "secret")
assert hasattr(writer._thread_local, "passwords")
assert isinstance(writer._thread_local.passwords, dict)
def test_can_store_multiple_users(self, writer):
"""Multiple users can be stored simultaneously."""
writer.set_user_password("alice", "pass_a")
writer.set_user_password("bob", "pass_b")
writer.set_user_password("charlie", "pass_c")
assert writer._thread_local.passwords == {
"alice": "pass_a",
"bob": "pass_b",
"charlie": "pass_c",
}
def test_overwrites_existing_password(self, writer):
"""Setting a password for the same user replaces the old value."""
writer.set_user_password("alice", "old_password")
writer.set_user_password("alice", "new_password")
assert writer._thread_local.passwords["alice"] == "new_password"
assert len(writer._thread_local.passwords) == 1
# ===========================================================================
# get_session
# ===========================================================================
class TestGetSession:
"""Tests for ThreadSafeMetricsWriter.get_session."""
# --- error paths -------------------------------------------------------
def test_raises_when_no_passwords_dict(self, writer):
"""Raises ValueError when passwords dict has never been created."""
with pytest.raises(ValueError, match="No password set"):
with writer.get_session("testuser"):
pass
def test_raises_when_password_not_found_for_user(self, writer):
"""Raises ValueError when the requested user has no stored password."""
writer.set_user_password("other_user", "other_pass")
with pytest.raises(ValueError, match="No password available"):
with writer.get_session("missing_user"):
pass
def test_raises_when_username_none_and_no_flask_context(self, writer):
"""Raises ValueError when username=None and Flask context is absent."""
writer.set_user_password("alice", "pass")
# The real code does `from flask import session as flask_session`
# inside the function body. A RuntimeError simulates no app context.
with pytest.raises((ValueError, RuntimeError)):
with writer.get_session(username=None):
pass
def test_raises_when_db_manager_returns_none_session(
self, writer_with_password
):
"""Raises ValueError when db_manager returns None for the session."""
with patch(
"local_deep_research.database.thread_metrics.db_manager"
) as mock_db:
mock_db.create_thread_safe_session_for_metrics.return_value = None
with pytest.raises(ValueError, match="Failed to create session"):
with writer_with_password.get_session("testuser"):
pass
# --- happy path --------------------------------------------------------
def test_yields_session_on_success(
self, writer_with_password, mock_db_manager
):
"""Successfully yields a session object when password is available."""
_mock_db, mock_session = mock_db_manager
with writer_with_password.get_session("testuser") as session:
assert session is mock_session
def test_creates_session_with_correct_args(
self, writer_with_password, mock_db_manager
):
"""Passes correct username and password to db_manager."""
mock_db, _mock_session = mock_db_manager
with writer_with_password.get_session("testuser"):
pass
mock_db.create_thread_safe_session_for_metrics.assert_called_once_with(
"testuser", "testpass"
)
def test_commits_session_on_success(
self, writer_with_password, mock_db_manager
):
"""Session is committed when context manager exits normally."""
_mock_db, mock_session = mock_db_manager
with writer_with_password.get_session("testuser"):
pass
mock_session.commit.assert_called_once()
mock_session.rollback.assert_not_called()
def test_rollback_on_exception(self, writer_with_password, mock_db_manager):
"""Session is rolled back when an exception occurs inside the block."""
_mock_db, mock_session = mock_db_manager
with pytest.raises(RuntimeError, match="boom"):
with writer_with_password.get_session("testuser"):
raise RuntimeError("boom")
mock_session.rollback.assert_called_once()
mock_session.commit.assert_not_called()
def test_closes_session_on_success(
self, writer_with_password, mock_db_manager
):
"""Session is closed in the finally block after normal exit."""
_mock_db, mock_session = mock_db_manager
with writer_with_password.get_session("testuser"):
pass
mock_session.close.assert_called_once()
def test_closes_session_on_exception(
self, writer_with_password, mock_db_manager
):
"""Session is closed in the finally block even after an exception."""
_mock_db, mock_session = mock_db_manager
with pytest.raises(RuntimeError):
with writer_with_password.get_session("testuser"):
raise RuntimeError("failure")
mock_session.close.assert_called_once()
def test_closes_session_when_db_manager_returns_none(
self, writer_with_password
):
"""When db_manager returns None, session is not closed (it is None)."""
with patch(
"local_deep_research.database.thread_metrics.db_manager"
) as mock_db:
mock_db.create_thread_safe_session_for_metrics.return_value = None
with pytest.raises(ValueError, match="Failed to create session"):
with writer_with_password.get_session("testuser"):
pass
# session was None, so close should never be called on it
# ===========================================================================
# write_token_metrics
# ===========================================================================
class TestWriteTokenMetrics:
"""Tests for ThreadSafeMetricsWriter.write_token_metrics."""
def _run_write(self, writer, mock_db_manager, token_data, research_id=42):
"""Helper: call write_token_metrics with mocked session and TokenUsage."""
mock_db, mock_session = mock_db_manager
mock_token_cls = MagicMock()
with patch(
"local_deep_research.database.models.TokenUsage",
mock_token_cls,
):
writer.write_token_metrics("testuser", research_id, token_data)
return mock_token_cls, mock_session
# --- field mapping -----------------------------------------------------
def test_creates_token_usage_with_correct_fields(
self, writer_with_password, mock_db_manager
):
"""TokenUsage is constructed with all expected fields from token_data."""
token_data = {
"model_name": "gpt-4",
"provider": "openai",
"prompt_tokens": 100,
"completion_tokens": 50,
"research_query": "test query",
"research_mode": "deep",
"research_phase": "search",
"search_iteration": 2,
"response_time_ms": 1234,
"success_status": "success",
"error_type": None,
"search_engines_planned": ["google", "bing"],
"search_engine_selected": "google",
"calling_file": "main.py",
"calling_function": "run_search",
"call_stack": ["a", "b"],
"context_limit": 4096,
"context_truncated": True,
"tokens_truncated": 500,
"truncation_ratio": 0.12,
"ollama_prompt_eval_count": 90,
"ollama_eval_count": 45,
"ollama_total_duration": 999999,
"ollama_load_duration": 111111,
"ollama_prompt_eval_duration": 222222,
"ollama_eval_duration": 333333,
}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data, research_id=7
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["research_id"] == 7
assert call_kwargs["model_name"] == "gpt-4"
assert call_kwargs["model_provider"] == "openai"
assert call_kwargs["prompt_tokens"] == 100
assert call_kwargs["completion_tokens"] == 50
assert call_kwargs["total_tokens"] == 150
assert call_kwargs["research_query"] == "test query"
assert call_kwargs["research_mode"] == "deep"
assert call_kwargs["research_phase"] == "search"
assert call_kwargs["search_iteration"] == 2
assert call_kwargs["response_time_ms"] == 1234
assert call_kwargs["success_status"] == "success"
assert call_kwargs["error_type"] is None
assert call_kwargs["search_engines_planned"] == ["google", "bing"]
assert call_kwargs["search_engine_selected"] == "google"
assert call_kwargs["calling_file"] == "main.py"
assert call_kwargs["calling_function"] == "run_search"
assert call_kwargs["call_stack"] == ["a", "b"]
assert call_kwargs["context_limit"] == 4096
assert call_kwargs["context_truncated"] is True
assert call_kwargs["tokens_truncated"] == 500
assert call_kwargs["truncation_ratio"] == 0.12
assert call_kwargs["ollama_prompt_eval_count"] == 90
assert call_kwargs["ollama_eval_count"] == 45
assert call_kwargs["ollama_total_duration"] == 999999
assert call_kwargs["ollama_load_duration"] == 111111
assert call_kwargs["ollama_prompt_eval_duration"] == 222222
assert call_kwargs["ollama_eval_duration"] == 333333
def test_adds_record_to_session(
self, writer_with_password, mock_db_manager
):
"""The TokenUsage record is added to the session via session.add()."""
token_data = {
"model_name": "gpt-4",
"provider": "openai",
"prompt_tokens": 10,
"completion_tokens": 5,
}
mock_token_cls, mock_session = self._run_write(
writer_with_password, mock_db_manager, token_data
)
mock_session.add.assert_called_once_with(mock_token_cls.return_value)
def test_total_tokens_calculated_as_sum(
self, writer_with_password, mock_db_manager
):
"""total_tokens equals prompt_tokens + completion_tokens."""
token_data = {
"prompt_tokens": 200,
"completion_tokens": 75,
}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["total_tokens"] == 275
assert (
call_kwargs["total_tokens"]
== call_kwargs["prompt_tokens"] + call_kwargs["completion_tokens"]
)
# --- defaults ----------------------------------------------------------
def test_default_prompt_tokens_zero(
self, writer_with_password, mock_db_manager
):
"""prompt_tokens defaults to 0 when not provided."""
token_data = {"completion_tokens": 30}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["prompt_tokens"] == 0
def test_default_completion_tokens_zero(
self, writer_with_password, mock_db_manager
):
"""completion_tokens defaults to 0 when not provided."""
token_data = {"prompt_tokens": 50}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["completion_tokens"] == 0
def test_default_success_status(
self, writer_with_password, mock_db_manager
):
"""success_status defaults to 'success' when not provided."""
token_data = {}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["success_status"] == "success"
def test_default_context_truncated_false(
self, writer_with_password, mock_db_manager
):
"""context_truncated defaults to False when not provided."""
token_data = {}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["context_truncated"] is False
def test_default_total_tokens_zero_when_both_missing(
self, writer_with_password, mock_db_manager
):
"""total_tokens is 0 when both prompt_tokens and completion_tokens are absent."""
token_data = {}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["total_tokens"] == 0
# --- missing optional fields -------------------------------------------
def test_missing_optional_fields_are_none(
self, writer_with_password, mock_db_manager
):
"""Optional fields not present in token_data resolve to None via .get()."""
token_data = {}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["model_name"] is None
assert call_kwargs["model_provider"] is None
assert call_kwargs["research_query"] is None
assert call_kwargs["research_mode"] is None
assert call_kwargs["research_phase"] is None
assert call_kwargs["search_iteration"] is None
assert call_kwargs["response_time_ms"] is None
assert call_kwargs["error_type"] is None
assert call_kwargs["search_engines_planned"] is None
assert call_kwargs["search_engine_selected"] is None
assert call_kwargs["calling_file"] is None
assert call_kwargs["calling_function"] is None
assert call_kwargs["call_stack"] is None
assert call_kwargs["context_limit"] is None
assert call_kwargs["tokens_truncated"] is None
assert call_kwargs["truncation_ratio"] is None
assert call_kwargs["ollama_prompt_eval_count"] is None
assert call_kwargs["ollama_eval_count"] is None
assert call_kwargs["ollama_total_duration"] is None
assert call_kwargs["ollama_load_duration"] is None
assert call_kwargs["ollama_prompt_eval_duration"] is None
assert call_kwargs["ollama_eval_duration"] is None
def test_research_id_passed_directly(
self, writer_with_password, mock_db_manager
):
"""research_id comes from the function argument, not from token_data."""
token_data = {"model_name": "test-model"}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data, research_id=999
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["research_id"] == 999
def test_research_id_none(self, writer_with_password, mock_db_manager):
"""research_id can be None."""
token_data = {"model_name": "test-model"}
mock_token_cls, _ = self._run_write(
writer_with_password, mock_db_manager, token_data, research_id=None
)
call_kwargs = mock_token_cls.call_args[1]
assert call_kwargs["research_id"] is None
# --- ModelUsage upsert -------------------------------------------------
def test_creates_model_usage_when_none_exists(
self, writer_with_password, mock_db_manager
):
"""New ModelUsage record is created when none exists."""
token_data = {
"model_name": "gpt-4",
"provider": "openai",
"prompt_tokens": 100,
"completion_tokens": 50,
}
mock_db, mock_session = mock_db_manager
mock_session.query.return_value.filter_by.return_value.first.return_value = None
with patch(
"local_deep_research.database.models.TokenUsage", MagicMock()
):
with patch(
"local_deep_research.database.models.ModelUsage"
) as mock_model_cls:
writer_with_password.write_token_metrics(
"testuser", 1, token_data
)
mock_model_cls.assert_called_once_with(
model_name="gpt-4",
model_provider="openai",
total_tokens=150,
total_calls=1,
)
assert mock_session.add.call_count == 2
def test_updates_existing_model_usage(
self, writer_with_password, mock_db_manager
):
"""Existing ModelUsage record is incremented."""
token_data = {
"model_name": "gpt-4",
"provider": "openai",
"prompt_tokens": 100,
"completion_tokens": 50,
}
mock_db, mock_session = mock_db_manager
existing = MagicMock()
existing.total_tokens = 1000
existing.total_calls = 5
mock_session.query.return_value.filter_by.return_value.first.return_value = existing
with patch(
"local_deep_research.database.models.TokenUsage", MagicMock()
):
writer_with_password.write_token_metrics("testuser", 1, token_data)
assert existing.total_tokens == 1150
assert existing.total_calls == 6
# ===========================================================================
# Global instance
# ===========================================================================
class TestGlobalInstance:
"""Tests for the module-level metrics_writer singleton."""
def test_metrics_writer_exists(self):
"""A global metrics_writer instance is importable."""
assert metrics_writer is not None
def test_metrics_writer_is_correct_type(self):
"""The global instance is a ThreadSafeMetricsWriter."""
assert isinstance(metrics_writer, ThreadSafeMetricsWriter)
def test_metrics_writer_has_thread_local(self):
"""The global instance has _thread_local attribute."""
assert hasattr(metrics_writer, "_thread_local")
assert isinstance(metrics_writer._thread_local, threading.local)
# ===========================================================================
# Thread isolation
# ===========================================================================
class TestThreadIsolation:
"""Verify that thread-local storage is genuinely isolated between threads."""
def test_passwords_not_shared_across_threads(self, writer):
"""Password set in one thread is invisible in another thread."""
results = {}
def thread_a():
writer.set_user_password("alice", "alice_pass")
results["a_passwords"] = dict(writer._thread_local.passwords)
def thread_b():
# Wait for thread_a to finish writing
import time
time.sleep(0.05)
results["b_has_passwords"] = hasattr(
writer._thread_local, "passwords"
)
t_a = threading.Thread(target=thread_a)
t_b = threading.Thread(target=thread_b)
t_a.start()
t_b.start()
t_a.join()
t_b.join()
assert results["a_passwords"] == {"alice": "alice_pass"}
assert results["b_has_passwords"] is False
def test_each_thread_has_own_password_store(self, writer):
"""Two threads can independently store passwords without interference."""
results = {}
barrier = threading.Barrier(2)
def thread_one():
writer.set_user_password("user_one", "pass_one")
barrier.wait()
results["one"] = dict(writer._thread_local.passwords)
def thread_two():
writer.set_user_password("user_two", "pass_two")
barrier.wait()
results["two"] = dict(writer._thread_local.passwords)
t1 = threading.Thread(target=thread_one)
t2 = threading.Thread(target=thread_two)
t1.start()
t2.start()
t1.join()
t2.join()
assert results["one"] == {"user_one": "pass_one"}
assert results["two"] == {"user_two": "pass_two"}
# Neither thread sees the other's password
assert "user_two" not in results["one"]
assert "user_one" not in results["two"]
def test_get_session_fails_in_different_thread(self, writer):
"""get_session in a thread that hasn't called set_user_password raises."""
writer.set_user_password("alice", "pass")
errors = []
def other_thread():
try:
with writer.get_session("alice"):
pass
except ValueError as e:
errors.append(str(e))
t = threading.Thread(target=other_thread)
t.start()
t.join()
assert len(errors) == 1
assert "No password set" in errors[0]