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
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:
@@ -0,0 +1 @@
|
||||
"""Database-related tests for Local Deep Research."""
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 0001–0013
|
||||
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"
|
||||
@@ -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
|
||||
@@ -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!")
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user