Files
wehub-resource-sync 7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

6528 lines
239 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Comprehensive tests for Alembic migration functionality.
Tests cover:
- Fresh database migrations
- Existing database handling (pre-Alembic databases)
- Column migrations for existing tables
- Idempotent migrations
- Error scenarios
- SQLCipher encrypted database migrations
"""
import os
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.alembic_runner import (
get_alembic_config,
get_current_revision,
get_head_revision,
get_migrations_dir,
needs_migration,
run_migrations,
stamp_database,
)
from local_deep_research.database.initialize import initialize_database
from local_deep_research.database.models import Base
class TestAlembicRunner:
"""Tests for the alembic_runner module."""
@pytest.fixture
def fresh_engine(self, tmp_path):
"""Create a fresh SQLite engine for testing."""
db_path = tmp_path / "fresh_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
@pytest.fixture
def existing_engine(self, tmp_path):
"""Create an engine with existing tables but no Alembic."""
db_path = tmp_path / "existing_test.db"
engine = create_engine(f"sqlite:///{db_path}")
# Create tables directly (simulating pre-Alembic database)
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def partial_engine(self, tmp_path):
"""Create an engine with only some tables (partial initialization)."""
db_path = tmp_path / "partial_test.db"
engine = create_engine(f"sqlite:///{db_path}")
# Create only a few tables
from local_deep_research.database.models import Setting, QueueStatus
Setting.__table__.create(engine, checkfirst=True)
QueueStatus.__table__.create(engine, checkfirst=True)
yield engine
engine.dispose()
def test_get_migrations_dir_exists(self):
"""Migrations directory should exist."""
migrations_dir = get_migrations_dir()
assert migrations_dir.exists()
assert (migrations_dir / "env.py").exists()
assert (migrations_dir / "versions").exists()
def test_get_head_revision_returns_value(self):
"""Should return the head revision."""
head = get_head_revision()
assert head is not None
assert isinstance(head, str)
def test_get_alembic_config(self, fresh_engine):
"""Should create a valid Alembic config."""
config = get_alembic_config(fresh_engine)
assert config is not None
assert config.get_main_option("script_location") is not None
assert "engine" not in config.attributes
def test_fresh_database_has_no_revision(self, fresh_engine):
"""Fresh database should have no current revision."""
# Fresh database with no tables
revision = get_current_revision(fresh_engine)
assert revision is None
def test_fresh_database_needs_migration(self, fresh_engine):
"""Fresh database should need migration."""
assert needs_migration(fresh_engine)
def test_run_migrations_on_fresh_database(self, fresh_engine):
"""Migrations should create all tables on fresh database."""
run_migrations(fresh_engine) # raises on failure
assert not needs_migration(fresh_engine)
# Verify tables were created
inspector = inspect(fresh_engine)
tables = inspector.get_table_names()
assert "alembic_version" in tables
assert "settings" in tables
assert "research" in tables
assert "task_metadata" in tables
def test_run_migrations_creates_all_expected_tables(self, fresh_engine):
"""Should create all core tables."""
run_migrations(fresh_engine)
inspector = inspect(fresh_engine)
tables = set(inspector.get_table_names())
# Core tables that should exist (not exhaustive)
expected_tables = {
"settings",
"research",
"research_history",
"journals",
"app_logs",
"queued_researches",
"token_usage",
"research_ratings",
"task_metadata",
"queue_status",
"alembic_version",
}
for table in expected_tables:
assert table in tables, f"Expected table '{table}' not found"
def test_existing_database_migration(self, existing_engine):
"""Existing database without Alembic should be migrated properly."""
# Verify no alembic_version yet
inspector = inspect(existing_engine)
tables = inspector.get_table_names()
assert "alembic_version" not in tables
assert len(tables) > 0 # Has other tables
# Run migrations
run_migrations(existing_engine) # raises on failure
# Verify alembic_version now exists
new_inspector = inspect(existing_engine)
new_tables = new_inspector.get_table_names()
assert "alembic_version" in new_tables
# Verify current revision is set
revision = get_current_revision(existing_engine)
assert revision is not None
def test_partial_database_migration(self, partial_engine):
"""Partial database should have missing tables created."""
# Verify only some tables exist
inspector = inspect(partial_engine)
initial_tables = set(inspector.get_table_names())
assert "settings" in initial_tables
assert "research" not in initial_tables
# Run migrations
run_migrations(partial_engine) # raises on failure
# Verify missing tables were created
new_inspector = inspect(partial_engine)
final_tables = set(new_inspector.get_table_names())
assert "settings" in final_tables # Still exists
assert "research" in final_tables # Now created
assert "task_metadata" in final_tables # Now created
assert "alembic_version" in final_tables
def test_stamp_database(self, fresh_engine):
"""Should stamp database without running migrations."""
# Create tables first
Base.metadata.create_all(fresh_engine)
# Stamp at head
stamp_database(fresh_engine, "head")
# Verify stamped
revision = get_current_revision(fresh_engine)
head = get_head_revision()
assert revision == head
def test_idempotent_migrations(self, fresh_engine):
"""Running migrations multiple times should be safe."""
# First run
run_migrations(fresh_engine) # raises on failure
inspector1 = inspect(fresh_engine)
tables1 = set(inspector1.get_table_names())
revision1 = get_current_revision(fresh_engine)
# Second run
run_migrations(fresh_engine) # raises on failure
inspector2 = inspect(fresh_engine)
tables2 = set(inspector2.get_table_names())
revision2 = get_current_revision(fresh_engine)
# Should be identical
assert tables1 == tables2
assert revision1 == revision2
# Third run
run_migrations(fresh_engine) # raises on failure
def test_needs_migration_after_complete(self, fresh_engine):
"""After migrations, needs_migration should return False."""
run_migrations(fresh_engine)
assert not needs_migration(fresh_engine)
def test_run_migrations_skips_upgrade_when_at_head(
self, fresh_engine, loguru_caplog
):
"""When the database is already at head, run_migrations must not
call command.upgrade() — that would open a no-op write transaction
and hold a RESERVED lock under isolation_level="IMMEDIATE", blocking
concurrent readers for no benefit.
"""
# Migrate to head first
run_migrations(fresh_engine)
assert get_current_revision(fresh_engine) == get_head_revision()
# Second call must short-circuit — command.upgrade() should not run,
# and the short-circuit log line must be emitted (positive signal
# that the guard fired, not that the function was gutted).
#
# Also pins that the orphan-cleanup and FK toggle introduced by
# PR #4000 are NOT invoked on the short-circuit path. If the
# guard is moved BELOW engine.connect() + _disable_fk_for_migration
# in a future refactor, this test fails — the existing
# command.upgrade mock alone would not catch that regression.
with (
patch(
"local_deep_research.database.alembic_runner.command.upgrade"
) as mock_upgrade,
patch(
"local_deep_research.database.alembic_runner."
"_drop_orphan_alembic_temp_tables"
) as mock_drop_orphans,
patch(
"local_deep_research.database.alembic_runner."
"_disable_fk_for_migration"
) as mock_disable_fk,
):
with loguru_caplog.at_level("INFO"):
run_migrations(fresh_engine)
assert mock_upgrade.call_count == 0
assert mock_drop_orphans.call_count == 0
assert mock_disable_fk.call_count == 0
assert "skipping upgrade" in loguru_caplog.text
def test_run_migrations_runs_upgrade_on_fresh_db(self, tmp_path):
"""Fresh DB (current revision None) must NOT short-circuit;
command.upgrade() must run so tables and alembic_version get created.
"""
db_path = tmp_path / "fresh_for_upgrade.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
assert get_current_revision(engine) is None
run_migrations(engine)
# If the short-circuit had applied (it must not), revision
# would still be None and alembic_version would not exist.
assert get_current_revision(engine) == get_head_revision()
assert "alembic_version" in inspect(engine).get_table_names()
finally:
engine.dispose()
def test_imports_without_errors(self):
"""All migration modules import without side effects."""
env_path = get_migrations_dir() / "env.py"
assert env_path.exists()
class TestColumnMigrations:
"""Tests for column-level migrations."""
@pytest.fixture
def old_schema_engine(self, tmp_path):
"""Create database with old schema (missing progress columns)."""
db_path = tmp_path / "old_schema.db"
engine = create_engine(f"sqlite:///{db_path}")
# Create task_metadata table without progress columns
# This simulates an old database before those columns were added
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE task_metadata (
task_id VARCHAR PRIMARY KEY,
status VARCHAR NOT NULL,
task_type VARCHAR NOT NULL,
created_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
error_message VARCHAR,
priority INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3
)
"""
)
)
# Create a few more tables to simulate existing database
conn.execute(
text(
"""
CREATE TABLE settings (
id INTEGER PRIMARY KEY,
key VARCHAR NOT NULL UNIQUE,
value VARCHAR,
type VARCHAR DEFAULT 'string'
)
"""
)
)
yield engine
engine.dispose()
def test_old_schema_missing_columns(self, old_schema_engine):
"""Verify old schema is missing expected columns."""
inspector = inspect(old_schema_engine)
columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "task_id" in columns
assert "status" in columns
assert "progress_current" not in columns
assert "progress_total" not in columns
assert "progress_message" not in columns
assert "metadata_json" not in columns
def test_column_migration_adds_missing_columns(self, old_schema_engine):
"""Migration should add missing columns to existing tables."""
# Run migrations
run_migrations(old_schema_engine) # raises on failure
# Verify columns were added
inspector = inspect(old_schema_engine)
columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "progress_current" in columns
assert "progress_total" in columns
assert "progress_message" in columns
assert "metadata_json" in columns
def test_column_migration_preserves_data(self, old_schema_engine):
"""Migration should preserve existing data in tables."""
# Insert test data
with old_schema_engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('test-123', 'completed', 'research')
"""
)
)
# Run migrations
run_migrations(old_schema_engine)
# Verify data preserved
with old_schema_engine.connect() as conn:
result = conn.execute(
text(
"SELECT task_id, status FROM task_metadata WHERE task_id = 'test-123'"
)
).fetchone()
assert result is not None
assert result[0] == "test-123"
assert result[1] == "completed"
def test_column_migration_sets_defaults(self, old_schema_engine):
"""New columns should have correct default values."""
# Insert test data before migration
with old_schema_engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('test-456', 'processing', 'benchmark')
"""
)
)
# Run migrations
run_migrations(old_schema_engine)
# Check that new columns have defaults
with old_schema_engine.connect() as conn:
row = conn.execute(
text(
"""
SELECT progress_current, progress_total, progress_message
FROM task_metadata WHERE task_id = 'test-456'
"""
)
).fetchone()
# New columns should have default values or NULL
# progress_current and progress_total default to 0
# progress_message is nullable
assert row is not None
assert row[0] == 0 # progress_current defaults to 0
assert row[1] == 0 # progress_total defaults to 0
class TestInitializeDatabaseIntegration:
"""Integration tests for initialize_database with Alembic."""
@pytest.fixture
def temp_engine(self, tmp_path):
"""Create a temporary database engine."""
db_path = tmp_path / "integration_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
def test_initialize_fresh_database(self, temp_engine):
"""initialize_database should work on fresh database."""
Session = sessionmaker(bind=temp_engine)
with Session() as session:
initialize_database(temp_engine, session)
inspector = inspect(temp_engine)
tables = inspector.get_table_names()
assert "alembic_version" in tables
assert "settings" in tables
assert len(tables) > 20 # Many tables
def test_initialize_existing_database(self, temp_engine):
"""initialize_database should work on existing database."""
# First initialization
Session = sessionmaker(bind=temp_engine)
with Session() as session:
initialize_database(temp_engine, session)
inspector1 = inspect(temp_engine)
tables1 = set(inspector1.get_table_names())
# Second initialization (should be safe)
with Session() as session:
initialize_database(temp_engine, session)
inspector2 = inspect(temp_engine)
tables2 = set(inspector2.get_table_names())
assert tables1 == tables2
def test_initialize_preserves_data(self, temp_engine):
"""initialize_database should preserve existing data."""
Session = sessionmaker(bind=temp_engine)
# First initialization
with Session() as session:
initialize_database(temp_engine, session)
# Insert test data into queue_status (simpler schema)
with temp_engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO queue_status (active_tasks, queued_tasks)
VALUES (5, 10)
"""
)
)
# Second initialization
with Session() as session:
initialize_database(temp_engine, session)
# Verify data preserved
with temp_engine.connect() as conn:
result = conn.execute(
text("SELECT active_tasks, queued_tasks FROM queue_status")
).fetchone()
assert result is not None
assert result[0] == 5
assert result[1] == 10
class TestMigrationEdgeCases:
"""Tests for edge cases and error scenarios."""
@pytest.fixture
def temp_engine(self, tmp_path):
"""Create a temporary database engine."""
db_path = tmp_path / "edge_case_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
def test_migration_with_empty_versions_dir(self, temp_engine, tmp_path):
"""Should handle case when no migrations exist."""
# This tests the edge case where migrations dir is empty
# The actual implementation should handle this gracefully
with patch(
"local_deep_research.database.alembic_runner.get_head_revision",
return_value=None,
):
run_migrations(temp_engine) # raises on failure
def test_get_current_revision_on_corrupted_table(self, temp_engine):
"""Should handle corrupted alembic_version table."""
# Create a corrupted alembic_version table
with temp_engine.begin() as conn:
conn.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR)")
)
# Don't insert any rows - table exists but is empty
# Should not crash
revision = get_current_revision(temp_engine)
assert revision is None # Empty table means no revision
def test_multiple_concurrent_migrations(self, tmp_path):
"""Multiple engines against same DB should work safely."""
db_path = tmp_path / "concurrent_test.db"
engine1 = create_engine(f"sqlite:///{db_path}")
engine2 = create_engine(f"sqlite:///{db_path}")
try:
# First migration
run_migrations(engine1) # raises on failure
# Second migration (should be idempotent)
run_migrations(engine2) # raises on failure
# Both should see same revision
rev1 = get_current_revision(engine1)
rev2 = get_current_revision(engine2)
assert rev1 == rev2
finally:
engine1.dispose()
engine2.dispose()
class TestSQLCipherMigrations:
"""Tests for SQLCipher encrypted database migrations."""
@pytest.fixture
def sqlcipher_available(self):
"""Check if SQLCipher is available."""
import importlib.util
if importlib.util.find_spec("sqlcipher3") is None:
pytest.skip("SQLCipher not available")
return True
@pytest.fixture
def encrypted_engine(self, tmp_path, sqlcipher_available):
"""Create an encrypted SQLCipher database."""
import sqlcipher3
db_path = tmp_path / "encrypted_test.db"
password = "test_password_123"
# Create encrypted connection
def create_connection():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.execute("PRAGMA kdf_iter = 256000")
cursor.execute("PRAGMA cipher_hmac_algorithm = HMAC_SHA512")
cursor.execute("PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512")
cursor.close()
return conn
engine = create_engine("sqlite://", creator=create_connection)
yield engine
engine.dispose()
def test_encrypted_database_migration(self, encrypted_engine):
"""Should be able to run migrations on encrypted database."""
run_migrations(encrypted_engine) # raises on failure
# Verify tables created
inspector = inspect(encrypted_engine)
tables = inspector.get_table_names()
assert "alembic_version" in tables
assert "settings" in tables
def test_encrypted_database_idempotent(self, encrypted_engine):
"""Multiple migrations on encrypted DB should be safe."""
run_migrations(encrypted_engine) # raises on failure
run_migrations(encrypted_engine) # raises on failure
rev1 = get_current_revision(encrypted_engine)
head = get_head_revision()
assert rev1 == head
class TestAlembicVersionTable:
"""Tests for alembic_version table behavior."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "migrated_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_alembic_version_table_exists(self, migrated_engine):
"""Alembic version table should exist after migration."""
inspector = inspect(migrated_engine)
assert "alembic_version" in inspector.get_table_names()
def test_alembic_version_has_single_row(self, migrated_engine):
"""Alembic version table should have exactly one row."""
with migrated_engine.connect() as conn:
result = conn.execute(
text("SELECT COUNT(*) FROM alembic_version")
).fetchone()
assert result[0] == 1
def test_alembic_version_matches_head(self, migrated_engine):
"""Alembic version should match head revision."""
with migrated_engine.connect() as conn:
result = conn.execute(
text("SELECT version_num FROM alembic_version")
).fetchone()
assert result[0] == get_head_revision()
def test_revision_updates_after_migration(self, tmp_path):
"""Revision should update when new migrations run."""
db_path = tmp_path / "revision_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run migrations
run_migrations(engine)
revision = get_current_revision(engine)
# Verify it's at head
assert revision == get_head_revision()
finally:
engine.dispose()
class TestMigrationChain:
"""Tests for migration chain and ordering."""
def test_migration_files_exist(self):
"""All expected migration files should exist."""
versions_dir = get_migrations_dir() / "versions"
expected_files = [
"0001_initial_schema.py",
"0002_add_task_progress_columns.py",
"0003_add_research_indexes.py",
"0004_migrate_legacy_app_settings.py",
"0005_add_resource_document_id.py",
"0006_journal_quality_system.py",
"0007_backfill_missing_indexes.py",
"0008_fix_research_strategy_fk.py",
"0009_default_fetch_mode_summary.py",
"0010_add_chat_tables.py",
"0011_add_collection_is_public.py",
"0012_add_collection_agent_enabled.py",
"0013_remove_meta_search_engines.py",
"0014_benchmark_run_version_and_snapshot.py",
"0015_drop_document_notes.py",
"0016_drop_orphaned_cache_tables.py",
]
for filename in expected_files:
filepath = versions_dir / filename
assert filepath.exists(), f"Migration file {filename} not found"
def test_migration_chain_is_valid(self):
"""Migration chain should be properly linked."""
from alembic.config import Config
from alembic.script import ScriptDirectory
migrations_dir = get_migrations_dir()
config = Config()
config.set_main_option("script_location", str(migrations_dir))
script = ScriptDirectory.from_config(config)
# Get all revisions
revisions = list(script.walk_revisions())
# Should have at least 3 revisions
assert len(revisions) >= 3
# Head should be the latest migration
assert script.get_current_head() == get_head_revision()
# Verify chain link 0010 -> 0009 -> 0008 (the rest of the chain is checked below).
rev_0010 = script.get_revision("0010")
assert rev_0010.down_revision == "0009"
rev_0009 = script.get_revision("0009")
assert rev_0009.down_revision == "0008"
rev_0008 = script.get_revision("0008")
assert rev_0008.down_revision == "0007"
rev_0007 = script.get_revision("0007")
assert rev_0007.down_revision == "0006"
rev_0006 = script.get_revision("0006")
assert rev_0006.down_revision == "0005"
rev_0005 = script.get_revision("0005")
assert rev_0005.down_revision == "0004"
rev_0004 = script.get_revision("0004")
assert rev_0004.down_revision == "0003"
rev_0003 = script.get_revision("0003")
assert rev_0003.down_revision == "0002"
rev_0002 = script.get_revision("0002")
assert rev_0002.down_revision == "0001"
rev_0001 = script.get_revision("0001")
assert rev_0001.down_revision is None
def test_run_to_specific_revision(self, tmp_path):
"""Should be able to run migrations to a specific revision."""
db_path = tmp_path / "specific_rev_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run only to revision 0001
run_migrations(engine, target="0001") # raises on failure
# Should be at 0001
revision = get_current_revision(engine)
assert revision == "0001"
# Should still need migration (not at head)
assert needs_migration(engine)
# Run to head
run_migrations(engine, target="head") # raises on failure
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
class TestUsersTableExclusion:
"""Tests to verify users table is correctly excluded from user databases."""
@pytest.fixture
def user_db_engine(self, tmp_path):
"""Create a user database (not auth database)."""
db_path = tmp_path / "user_db_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_users_table_not_in_user_database(self, user_db_engine):
"""Users table should NOT be created in user databases."""
inspector = inspect(user_db_engine)
tables = inspector.get_table_names()
# Users table should NOT exist in user databases
# It only exists in the auth database
assert "users" not in tables
def test_user_specific_tables_exist(self, user_db_engine):
"""User-specific tables should exist."""
inspector = inspect(user_db_engine)
tables = set(inspector.get_table_names())
# These tables should exist in user databases
user_tables = {
"settings",
"research",
"token_usage",
"api_keys",
"task_metadata",
}
for table in user_tables:
assert table in tables, f"User table '{table}' not found"
class TestCheckDatabaseSchemaWithAlembic:
"""Tests for check_database_schema function with Alembic."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "schema_check_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_check_schema_shows_no_missing_tables(self, migrated_engine):
"""After migration, no tables should be missing."""
from local_deep_research.database.initialize import (
check_database_schema,
)
schema_info = check_database_schema(migrated_engine)
# Should have many tables
assert len(schema_info["tables"]) > 20
# Should have no missing tables (except users which is excluded)
assert len(schema_info["missing_tables"]) == 0
def test_check_schema_excludes_users_table(self, migrated_engine):
"""check_database_schema should not report users as missing."""
from local_deep_research.database.initialize import (
check_database_schema,
)
schema_info = check_database_schema(migrated_engine)
# Users should not appear in missing tables
assert "users" not in schema_info["missing_tables"]
# Users should not appear in existing tables either
assert "users" not in schema_info["tables"]
class TestNeedsMigrationStates:
"""Tests for needs_migration in various database states."""
def test_needs_migration_empty_database(self, tmp_path):
"""Empty database should need migration."""
db_path = tmp_path / "empty_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
assert needs_migration(engine)
finally:
engine.dispose()
def test_needs_migration_partial_tables(self, tmp_path):
"""Database with partial tables should need migration."""
db_path = tmp_path / "partial_tables_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create only one table
with engine.begin() as conn:
conn.execute(
text("CREATE TABLE settings (id INTEGER PRIMARY KEY)")
)
assert needs_migration(engine)
finally:
engine.dispose()
def test_needs_migration_old_revision(self, tmp_path):
"""Database at old revision should need migration."""
db_path = tmp_path / "old_revision_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run only to 0001
run_migrations(engine, target="0001")
# Should still need migration
assert needs_migration(engine)
finally:
engine.dispose()
def test_needs_migration_at_head(self, tmp_path):
"""Database at head should not need migration."""
db_path = tmp_path / "at_head_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
run_migrations(engine)
assert not needs_migration(engine)
finally:
engine.dispose()
def test_needs_migration_stamped_database(self, tmp_path):
"""Stamped database at head should not need migration."""
db_path = tmp_path / "stamped_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create tables and stamp
Base.metadata.create_all(engine)
stamp_database(engine, "head")
assert not needs_migration(engine)
finally:
engine.dispose()
class TestDataIntegrityAcrossMigrations:
"""Tests to verify data integrity during migrations."""
@pytest.fixture
def database_with_data(self, tmp_path):
"""Create a database with existing data."""
db_path = tmp_path / "data_integrity_test.db"
engine = create_engine(f"sqlite:///{db_path}")
# Run initial migration
run_migrations(engine, target="0001")
# Insert test data into multiple tables
with engine.begin() as conn:
# Insert into task_metadata
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type, priority)
VALUES
('task-1', 'completed', 'research', 1),
('task-2', 'processing', 'benchmark', 2),
('task-3', 'queued', 'research', 3)
"""
)
)
# Insert into queue_status
conn.execute(
text(
"""
INSERT INTO queue_status (active_tasks, queued_tasks)
VALUES (2, 5)
"""
)
)
yield engine
engine.dispose()
def test_data_preserved_after_column_migration(self, database_with_data):
"""Data should be preserved when adding columns."""
# Verify current state
with database_with_data.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count == 3
# Run migration to add columns
run_migrations(database_with_data)
# Verify data still exists
with database_with_data.connect() as conn:
result = conn.execute(
text(
"SELECT task_id, status, priority FROM task_metadata ORDER BY task_id"
)
).fetchall()
assert len(result) == 3
assert result[0][0] == "task-1"
assert result[0][1] == "completed"
assert result[0][2] == 1
def test_new_columns_have_defaults_for_existing_rows(
self, database_with_data
):
"""New columns should have default values for existing rows."""
# Run migration
run_migrations(database_with_data)
# Check new column values
with database_with_data.connect() as conn:
result = conn.execute(
text(
"""
SELECT task_id, progress_current, progress_total
FROM task_metadata
ORDER BY task_id
"""
)
).fetchall()
# All rows should have default values (0) for progress columns
for row in result:
# progress_current and progress_total should be 0 or NULL
assert row[1] is None or row[1] == 0
assert row[2] is None or row[2] == 0
def test_multiple_tables_data_preserved(self, database_with_data):
"""Data in multiple tables should be preserved."""
run_migrations(database_with_data)
with database_with_data.connect() as conn:
# Check queue_status
qs_result = conn.execute(
text("SELECT active_tasks, queued_tasks FROM queue_status")
).fetchone()
assert qs_result[0] == 2
assert qs_result[1] == 5
# Check task_metadata
tm_count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert tm_count == 3
class TestColumnMigrationIdempotency:
"""Tests for column migration idempotency."""
@pytest.fixture
def old_schema_engine(self, tmp_path):
"""Create database with old schema."""
db_path = tmp_path / "column_idempotent_test.db"
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE task_metadata (
task_id VARCHAR PRIMARY KEY,
status VARCHAR NOT NULL,
task_type VARCHAR NOT NULL
)
"""
)
)
yield engine
engine.dispose()
def test_column_migration_idempotent(self, old_schema_engine):
"""Running column migration multiple times should be safe."""
# First run
run_migrations(old_schema_engine)
inspector1 = inspect(old_schema_engine)
columns1 = {
col["name"] for col in inspector1.get_columns("task_metadata")
}
# Second run (should not fail)
run_migrations(old_schema_engine) # raises on failure
inspector2 = inspect(old_schema_engine)
columns2 = {
col["name"] for col in inspector2.get_columns("task_metadata")
}
# Columns should be identical
assert columns1 == columns2
def test_column_already_exists_no_error(self, tmp_path):
"""Adding column that already exists should not error."""
db_path = tmp_path / "column_exists_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create table WITH the columns already
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE task_metadata (
task_id VARCHAR PRIMARY KEY,
status VARCHAR NOT NULL,
task_type VARCHAR NOT NULL,
progress_current INTEGER DEFAULT 0,
progress_total INTEGER DEFAULT 0,
progress_message VARCHAR,
metadata_json TEXT
)
"""
)
)
# Run migrations - should not fail
run_migrations(engine) # raises on failure
# Should be at head
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
class TestMigrationErrorHandling:
"""Tests for migration error handling."""
def test_migration_raises_on_corrupted_schema(self, tmp_path):
"""Migration should raise when alembic_version has wrong schema."""
db_path = tmp_path / "error_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create a conflicting table structure
with engine.begin() as conn:
# Create alembic_version with wrong schema
conn.execute(
text(
"CREATE TABLE alembic_version (bad_column INTEGER PRIMARY KEY)"
)
)
conn.execute(text("INSERT INTO alembic_version VALUES (999)"))
with pytest.raises(Exception):
run_migrations(engine)
finally:
engine.dispose()
def test_migration_with_read_only_database(self, tmp_path):
"""Migration should handle read-only database gracefully."""
import stat
db_path = tmp_path / "readonly_test.db"
# Create and migrate database first
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
engine.dispose()
# Make database read-only
os.chmod(db_path, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
engine = create_engine(f"sqlite:///{db_path}")
try:
# Migration on a read-only DB that's already at head should
# be a no-op (no schema changes needed), so it must succeed
# and the DB must remain at head.
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision(), (
"Read-only DB at head should remain at head after no-op migration"
)
finally:
engine.dispose()
# Restore permissions for cleanup
os.chmod(
db_path,
stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH,
)
class TestStampDatabaseBehavior:
"""Tests for stamp_database function."""
def test_stamp_at_specific_revision(self, tmp_path):
"""Should be able to stamp at a specific revision."""
db_path = tmp_path / "stamp_specific_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create tables
Base.metadata.create_all(engine)
# Stamp at 0001
stamp_database(engine, "0001")
# Should be at 0001
assert get_current_revision(engine) == "0001"
# Should need migration (not at head)
assert needs_migration(engine)
finally:
engine.dispose()
def test_stamp_creates_alembic_version_table(self, tmp_path):
"""Stamp should create alembic_version table if missing."""
db_path = tmp_path / "stamp_creates_table_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create some tables but not alembic_version
with engine.begin() as conn:
conn.execute(
text("CREATE TABLE settings (id INTEGER PRIMARY KEY)")
)
# Verify alembic_version doesn't exist
inspector = inspect(engine)
assert "alembic_version" not in inspector.get_table_names()
# Stamp
stamp_database(engine, "head")
# Verify alembic_version now exists
inspector = inspect(engine)
assert "alembic_version" in inspector.get_table_names()
finally:
engine.dispose()
def test_stamp_overwrites_existing_revision(self, tmp_path):
"""Stamp should overwrite existing revision."""
db_path = tmp_path / "stamp_overwrite_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run migrations to 0001
run_migrations(engine, target="0001")
assert get_current_revision(engine) == "0001"
# Stamp at head
stamp_database(engine, "head")
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
class TestMigrationWithForeignKeys:
"""Tests for migrations with foreign key relationships."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "fk_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_foreign_key_tables_created(self, migrated_engine):
"""Tables with foreign keys should be created correctly."""
inspector = inspect(migrated_engine)
tables = inspector.get_table_names()
# Tables that have foreign key relationships
fk_tables = [
"research_history",
"document_collections",
"document_chunks",
]
for table in fk_tables:
assert table in tables, f"FK table '{table}' not found"
def test_can_insert_with_foreign_keys(self, migrated_engine):
"""Should be able to insert data respecting foreign keys."""
# Use research_history and research_resources which have a clear FK relationship
with migrated_engine.begin() as conn:
# Insert a research history record (uses TEXT columns, UUID-like id)
conn.execute(
text(
"""
INSERT INTO research_history (id, query, mode, status, created_at)
VALUES ('test-uuid-001', 'Test Query', 'quick', 'completed', datetime('now'))
"""
)
)
# Insert research resource which references research_history
conn.execute(
text(
"""
INSERT INTO research_resources (research_id, title, url, created_at)
VALUES ('test-uuid-001', 'Test Resource', 'http://example.com', datetime('now'))
"""
)
)
# Verify data and foreign key relationship
with migrated_engine.connect() as conn:
result = conn.execute(
text(
"SELECT COUNT(*) FROM research_resources WHERE research_id = 'test-uuid-001'"
)
).fetchone()
assert result[0] == 1
class TestMigrationWithIndexes:
"""Tests for index creation during migrations."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "index_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_indexes_created(self, migrated_engine):
"""Important indexes should be created."""
inspector = inspect(migrated_engine)
# Check indexes on settings table
settings_indexes = inspector.get_indexes("settings")
index_names = {idx["name"] for idx in settings_indexes}
# Should have index on key column
assert any("key" in name.lower() for name in index_names if name)
def test_primary_keys_exist(self, migrated_engine):
"""Primary keys should exist on all tables."""
inspector = inspect(migrated_engine)
tables_checked = 0
for table_name in inspector.get_table_names():
if table_name == "alembic_version":
continue
pk = inspector.get_pk_constraint(table_name)
# Most tables should have a primary key
# (some junction tables might not)
# Verify we can at least retrieve the constraint
assert pk is not None
tables_checked += 1
# Verify we actually checked some tables
assert tables_checked > 0
class TestMigrationRobustness:
"""Tests for migration robustness and recovery."""
def test_migration_after_crash_simulation(self, tmp_path):
"""Database should recover from simulated crash during migration."""
db_path = tmp_path / "crash_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run partial migration
run_migrations(engine, target="0001")
# Simulate "crash" by just disposing without cleanup
engine.dispose()
# Reconnect and continue
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine) # raises on failure
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
def test_migration_with_wal_mode(self, tmp_path):
"""Migration should work with WAL journal mode."""
db_path = tmp_path / "wal_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Enable WAL mode
with engine.begin() as conn:
conn.execute(text("PRAGMA journal_mode=WAL"))
# Run migrations
run_migrations(engine) # raises on failure
# Verify
inspector = inspect(engine)
assert "alembic_version" in inspector.get_table_names()
finally:
engine.dispose()
def test_migration_with_busy_timeout(self, tmp_path):
"""Migration should respect busy timeout settings."""
db_path = tmp_path / "busy_test.db"
engine = create_engine(
f"sqlite:///{db_path}",
connect_args={"timeout": 30},
)
try:
run_migrations(engine)
# Verify migration actually ran to head, not just "didn't raise"
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
class TestAllModelsCreated:
"""Tests to verify all expected models are created."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "all_models_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_research_models_created(self, migrated_engine):
"""Research-related tables should be created."""
inspector = inspect(migrated_engine)
tables = set(inspector.get_table_names())
research_tables = [
"research",
"research_history",
"research_tasks",
"search_queries",
"search_results",
]
for table in research_tables:
assert table in tables, f"Research table '{table}' not found"
def test_library_models_created(self, migrated_engine):
"""Library-related tables should be created."""
inspector = inspect(migrated_engine)
tables = set(inspector.get_table_names())
library_tables = [
"collections",
"documents",
"document_collections",
"source_types",
]
for table in library_tables:
assert table in tables, f"Library table '{table}' not found"
def test_metrics_models_created(self, migrated_engine):
"""Metrics-related tables should be created."""
inspector = inspect(migrated_engine)
tables = set(inspector.get_table_names())
metrics_tables = [
"token_usage",
"model_usage",
"research_ratings",
]
for table in metrics_tables:
assert table in tables, f"Metrics table '{table}' not found"
def test_news_models_created(self, migrated_engine):
"""News-related tables should be created."""
inspector = inspect(migrated_engine)
tables = set(inspector.get_table_names())
news_tables = [
"news_subscriptions",
"news_cards",
"news_interests",
]
for table in news_tables:
assert table in tables, f"News table '{table}' not found"
def test_benchmark_models_created(self, migrated_engine):
"""Benchmark-related tables should be created."""
inspector = inspect(migrated_engine)
tables = set(inspector.get_table_names())
benchmark_tables = [
"benchmark_runs",
"benchmark_results",
"benchmark_configs",
]
for table in benchmark_tables:
assert table in tables, f"Benchmark table '{table}' not found"
class TestColumnTypes:
"""Tests to verify column types are correct after migration."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "column_types_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_task_metadata_columns(self, migrated_engine):
"""task_metadata should have correct column types."""
inspector = inspect(migrated_engine)
columns = {
col["name"]: col for col in inspector.get_columns("task_metadata")
}
# Check required columns exist
assert "task_id" in columns
assert "status" in columns
assert "progress_current" in columns
assert "progress_total" in columns
assert "progress_message" in columns
assert "metadata_json" in columns
def test_settings_columns(self, migrated_engine):
"""settings should have correct column types."""
inspector = inspect(migrated_engine)
columns = {
col["name"]: col for col in inspector.get_columns("settings")
}
assert "id" in columns
assert "key" in columns
assert "value" in columns
assert "name" in columns
def test_datetime_columns_exist(self, migrated_engine):
"""Tables should have datetime columns where expected."""
inspector = inspect(migrated_engine)
# Check research table has datetime columns
research_columns = {
col["name"] for col in inspector.get_columns("research")
}
assert "created_at" in research_columns
# Check task_metadata has datetime columns
task_columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "created_at" in task_columns
class TestMigrationPerformance:
"""Performance-related tests for migrations."""
def test_migration_completes_in_reasonable_time(self, tmp_path):
"""Migration should complete within reasonable time."""
import time
db_path = tmp_path / "perf_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
start = time.time()
run_migrations(engine)
elapsed = time.time() - start
# Should complete in under 30 seconds even on slow systems
assert elapsed < 30, (
f"Migration took {elapsed:.1f}s, expected < 30s"
)
finally:
engine.dispose()
def test_migration_with_large_existing_data(self, tmp_path):
"""Migration should handle tables with existing data."""
db_path = tmp_path / "large_data_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run initial migration
run_migrations(engine, target="0001")
# Insert many rows
with engine.begin() as conn:
for i in range(100):
conn.execute(
text(
f"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('task-{i}', 'completed', 'research')
"""
)
)
# Run remaining migrations
run_migrations(engine) # raises on failure
# Verify all data preserved
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count == 100
finally:
engine.dispose()
class TestDatabaseManagerIntegration:
"""Integration tests with DatabaseManager (encrypted databases)."""
@pytest.fixture
def sqlcipher_available(self):
"""Check if SQLCipher is available."""
import importlib.util
if importlib.util.find_spec("sqlcipher3") is None:
pytest.skip("SQLCipher not available")
return True
def test_migration_through_database_manager_flow(
self, tmp_path, sqlcipher_available
):
"""Test migrations work through the full DatabaseManager flow."""
import sqlcipher3
db_path = tmp_path / "dm_flow_test.db"
password = "test_password"
# Simulate DatabaseManager creating a new database
def create_connection():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.execute("PRAGMA kdf_iter = 256000")
cursor.execute("PRAGMA cipher_hmac_algorithm = HMAC_SHA512")
cursor.execute("PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512")
cursor.close()
return conn
engine = create_engine("sqlite://", creator=create_connection)
try:
# This simulates what initialize_database does
run_migrations(engine) # raises on failure
# Verify database is properly set up
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "alembic_version" in tables
assert "settings" in tables
assert "task_metadata" in tables
# Verify we can query
with engine.connect() as conn:
result = conn.execute(
text("SELECT version_num FROM alembic_version")
).fetchone()
assert result[0] == get_head_revision()
finally:
engine.dispose()
def test_reopening_encrypted_database(self, tmp_path, sqlcipher_available):
"""Test that reopening an encrypted database works correctly."""
import sqlcipher3
db_path = tmp_path / "reopen_test.db"
password = "test_password"
def create_connection():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.execute("PRAGMA kdf_iter = 256000")
cursor.execute("PRAGMA cipher_hmac_algorithm = HMAC_SHA512")
cursor.execute("PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512")
cursor.close()
return conn
# First open - create and migrate
engine1 = create_engine("sqlite://", creator=create_connection)
run_migrations(engine1)
rev1 = get_current_revision(engine1)
engine1.dispose()
# Second open - should recognize existing migration
engine2 = create_engine("sqlite://", creator=create_connection)
run_migrations(engine2) # raises on failure
rev2 = get_current_revision(engine2)
assert rev1 == rev2
# Should not need migration
assert not needs_migration(engine2)
engine2.dispose()
class TestMigrationWithDifferentEngineConfigs:
"""Test migrations with various engine configurations."""
def test_migration_with_echo_enabled(self, tmp_path):
"""Migration should work with SQL echo enabled."""
db_path = tmp_path / "echo_test.db"
engine = create_engine(f"sqlite:///{db_path}", echo=False)
try:
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
def test_migration_with_pool_pre_ping(self, tmp_path):
"""Migration should work with pool pre-ping enabled."""
db_path = tmp_path / "preping_test.db"
engine = create_engine(f"sqlite:///{db_path}", pool_pre_ping=True)
try:
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
def test_migration_with_static_pool(self, tmp_path):
"""Migration should work with StaticPool."""
from sqlalchemy.pool import StaticPool
db_path = tmp_path / "static_pool_test.db"
engine = create_engine(
f"sqlite:///{db_path}",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
try:
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
def test_migration_in_memory_database(self):
"""Migration should work with in-memory database."""
from sqlalchemy.pool import StaticPool
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
try:
run_migrations(engine) # raises on failure
inspector = inspect(engine)
assert "alembic_version" in inspector.get_table_names()
finally:
engine.dispose()
class TestDowngradeMigrations:
"""Tests for downgrade functionality."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "downgrade_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_downgrade_from_head_to_specific_revision(self, tmp_path):
"""Test downgrade from head to a specific revision.
Starts at 0008 (last fully-reversible) rather than head; 0010
is intentionally non-reversible (SQLite ALTER TABLE limitations).
"""
from alembic import command
db_path = tmp_path / "downgrade_specific_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Upgrade to 0008 (last reversible)
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
assert get_current_revision(engine) == "0008"
# Downgrade to 0001
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "0001")
# Verify revision
assert get_current_revision(engine) == "0001"
# Verify progress columns were removed
inspector = inspect(engine)
columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "progress_current" not in columns
assert "progress_total" not in columns
finally:
engine.dispose()
def test_downgrade_preserves_core_data(self, tmp_path):
"""Test that downgrade preserves data in core columns.
Starts at 0008 (last fully-reversible) rather than head; 0010
is intentionally non-reversible (SQLite ALTER TABLE limitations).
"""
from alembic import command
db_path = tmp_path / "downgrade_data_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Upgrade to 0008 and insert data
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type, priority)
VALUES ('task-preserve-1', 'completed', 'research', 5)
"""
)
)
# Downgrade
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "0001")
# Verify core data preserved
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT task_id, status, priority FROM task_metadata WHERE task_id = 'task-preserve-1'"
)
).fetchone()
assert result is not None
assert result[0] == "task-preserve-1"
assert result[1] == "completed"
assert result[2] == 5
finally:
engine.dispose()
def test_full_downgrade_to_empty_database(self, tmp_path):
"""Test full downgrade removes all tables.
Starts from 0008 (last fully-reversible revision) rather than
head, since 0010 is intentionally non-reversible (SQLite ALTER
TABLE limitations against legacy unnamed constraints + FK-target
columns; see NON_REVERSIBLE_REVISIONS).
"""
from alembic import command
db_path = tmp_path / "full_downgrade_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Upgrade to 0008 (last reversible) instead of head
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
# Verify tables exist
inspector = inspect(engine)
tables_before = set(inspector.get_table_names())
assert len(tables_before) > 10
# Downgrade to base (empty)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "base")
# Verify most tables removed (only alembic_version may remain)
inspector = inspect(engine)
tables_after = set(inspector.get_table_names())
# After full downgrade, should have minimal tables
assert len(tables_after) <= 1 # Only alembic_version or empty
finally:
engine.dispose()
def test_downgrade_then_upgrade_roundtrip(self, tmp_path):
"""Test downgrade and re-upgrade works correctly.
Starts from 0008 (last fully-reversible revision) rather than
head, since 0010 is intentionally non-reversible. After the
roundtrip we re-upgrade to head to verify forward path still
works through the non-reversible step.
"""
from alembic import command
db_path = tmp_path / "roundtrip_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Upgrade to 0008 (last reversible)
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
# Insert data
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('roundtrip-task', 'queued', 'test')
"""
)
)
# Downgrade to 0001
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "0001")
# Re-upgrade to head (passes through 0010 in forward direction)
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
# Verify data still exists
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT task_id FROM task_metadata WHERE task_id = 'roundtrip-task'"
)
).fetchone()
assert result is not None
finally:
engine.dispose()
class TestMigrationWithExistingData:
"""Tests for migration with various data scenarios."""
@pytest.fixture
def engine_at_0001(self, tmp_path):
"""Create a database at revision 0001 for data tests."""
db_path = tmp_path / "data_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine, target="0001")
yield engine
engine.dispose()
def test_migration_with_thousands_of_rows(self, tmp_path):
"""Test migration handles tables with thousands of rows."""
db_path = tmp_path / "large_data_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run initial migration
run_migrations(engine, target="0001")
# Insert 5000 rows
with engine.begin() as conn:
for batch in range(50):
values = ", ".join(
[
f"('task-{batch}-{i}', 'completed', 'research')"
for i in range(100)
]
)
conn.execute(
text(
f"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES {values}
"""
)
)
# Verify count
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count == 5000
# Run remaining migrations
run_migrations(engine) # raises on failure
# Verify all data preserved
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count == 5000
# Verify new columns exist
inspector = inspect(engine)
columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "progress_current" in columns
finally:
engine.dispose()
def test_migration_preserves_json_data(self, engine_at_0001):
"""Test migration preserves JSON data in columns."""
# Insert data with JSON - use research_history which has simpler constraints
with engine_at_0001.begin() as conn:
conn.execute(
text(
"""
INSERT INTO research_history (id, query, mode, status, created_at, research_meta)
VALUES ('json-test-id', 'test query', 'quick', 'completed', datetime('now'),
'{"nested": {"key": "value"}, "array": [1, 2, 3]}')
"""
)
)
# Run migration
run_migrations(engine_at_0001)
# Verify JSON data preserved
with engine_at_0001.connect() as conn:
result = conn.execute(
text(
"SELECT research_meta FROM research_history WHERE id = 'json-test-id'"
)
).fetchone()
assert result is not None
import json
data = json.loads(result[0])
assert data["nested"]["key"] == "value"
assert data["array"] == [1, 2, 3]
def test_migration_preserves_datetime_precision(self, engine_at_0001):
"""Test migration preserves datetime precision."""
# Insert data with precise datetime
with engine_at_0001.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type, created_at)
VALUES ('datetime-test', 'completed', 'research', '2025-06-15 14:30:45.123456')
"""
)
)
# Run migration
run_migrations(engine_at_0001)
# Verify datetime preserved
with engine_at_0001.connect() as conn:
result = conn.execute(
text(
"SELECT created_at FROM task_metadata WHERE task_id = 'datetime-test'"
)
).fetchone()
assert result is not None
# SQLite stores as string, verify it contains the microseconds
datetime_str = str(result[0])
assert "14:30:45" in datetime_str
def test_migration_with_null_values_in_nullable_columns(
self, engine_at_0001
):
"""Test migration handles NULL values in nullable columns."""
# Insert data with NULL values
with engine_at_0001.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type, error_message, started_at, completed_at)
VALUES ('null-test', 'queued', 'research', NULL, NULL, NULL)
"""
)
)
# Run migration
run_migrations(engine_at_0001)
# Verify NULL values preserved and new columns also NULL
with engine_at_0001.connect() as conn:
result = conn.execute(
text(
"""
SELECT error_message, started_at, completed_at, progress_message
FROM task_metadata WHERE task_id = 'null-test'
"""
)
).fetchone()
assert result is not None
assert result[0] is None # error_message
assert result[1] is None # started_at
assert result[2] is None # completed_at
assert result[3] is None # progress_message (new column)
def test_migration_with_unicode_data(self, engine_at_0001):
"""Test migration preserves unicode and special characters."""
# Insert unicode data
with engine_at_0001.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type, error_message)
VALUES ('unicode-test', 'failed', 'research',
'Error: 日本語テスト 🔥 Ñoño émojis café')
"""
)
)
# Run migration
run_migrations(engine_at_0001)
# Verify unicode preserved
with engine_at_0001.connect() as conn:
result = conn.execute(
text(
"SELECT error_message FROM task_metadata WHERE task_id = 'unicode-test'"
)
).fetchone()
assert result is not None
assert "日本語テスト" in result[0]
assert "🔥" in result[0]
assert "café" in result[0]
def test_migration_with_empty_strings(self, engine_at_0001):
"""Test migration handles empty string values."""
with engine_at_0001.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type, error_message)
VALUES ('empty-string-test', 'completed', 'research', '')
"""
)
)
run_migrations(engine_at_0001)
with engine_at_0001.connect() as conn:
result = conn.execute(
text(
"SELECT error_message FROM task_metadata WHERE task_id = 'empty-string-test'"
)
).fetchone()
assert result is not None
assert result[0] == ""
class TestConcurrentDatabaseAccess:
"""Tests for concurrent database access during migrations."""
def test_migration_while_database_is_being_read(self, tmp_path):
"""Test migration completes while concurrent reads occur."""
import threading
import time
db_path = tmp_path / "concurrent_read_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine, target="0001")
# Insert some test data
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('concurrent-test', 'completed', 'research')
"""
)
)
read_results = []
read_errors = []
def concurrent_reader():
"""Read from database concurrently."""
try:
for _ in range(10):
with engine.connect() as conn:
result = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()
read_results.append(result[0])
time.sleep(0.01)
except Exception as e:
read_errors.append(str(e))
try:
# Start reader thread
reader = threading.Thread(target=concurrent_reader)
reader.start()
# Run migration while reading
run_migrations(engine) # raises on failure
reader.join()
# Verify reads succeeded
assert len(read_errors) == 0, f"Read errors: {read_errors}"
assert len(read_results) > 0
finally:
engine.dispose()
def test_two_engines_migrate_same_database(self, tmp_path):
"""Test two processes trying to migrate same database."""
db_path = tmp_path / "dual_migrate_test.db"
engine1 = create_engine(f"sqlite:///{db_path}")
engine2 = create_engine(f"sqlite:///{db_path}")
try:
# Both engines try to migrate
run_migrations(engine1) # raises on failure
run_migrations(engine2) # raises on failure
# Both should see same revision
rev1 = get_current_revision(engine1)
rev2 = get_current_revision(engine2)
assert rev1 == rev2 == get_head_revision()
finally:
engine1.dispose()
engine2.dispose()
def test_migration_with_sqlite_busy_timeout(self, tmp_path):
"""Test migration respects SQLite busy timeout."""
db_path = tmp_path / "busy_timeout_test.db"
# Create engine with explicit busy timeout
engine = create_engine(
f"sqlite:///{db_path}",
connect_args={"timeout": 30},
)
try:
run_migrations(engine) # raises on failure
# Verify database is correctly migrated
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
def test_sequential_migrations_different_engines(self, tmp_path):
"""Test sequential migrations from different engine instances."""
db_path = tmp_path / "sequential_test.db"
# First engine migrates to 0001
engine1 = create_engine(f"sqlite:///{db_path}")
run_migrations(engine1, target="0001")
engine1.dispose()
# Second engine continues to head
engine2 = create_engine(f"sqlite:///{db_path}")
run_migrations(engine2) # raises on failure
assert get_current_revision(engine2) == get_head_revision()
engine2.dispose()
# Third engine verifies no migration needed
engine3 = create_engine(f"sqlite:///{db_path}")
assert not needs_migration(engine3)
engine3.dispose()
class TestMigrationRecovery:
"""Tests for migration recovery scenarios."""
def test_recovery_after_partial_migration(self, tmp_path):
"""Test recovery after a partial migration state."""
db_path = tmp_path / "partial_recovery_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Create a partial state - some tables but not all
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE settings (
id INTEGER PRIMARY KEY,
key VARCHAR UNIQUE,
value VARCHAR,
name VARCHAR,
description VARCHAR,
type VARCHAR
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE task_metadata (
task_id VARCHAR PRIMARY KEY,
status VARCHAR NOT NULL,
task_type VARCHAR NOT NULL
)
"""
)
)
# Try to recover by running full migration
run_migrations(engine) # raises on failure
# Verify recovery succeeded
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "alembic_version" in tables
assert "research" in tables # Should have been created
finally:
engine.dispose()
def test_database_state_after_interrupted_migration(self, tmp_path):
"""Test database state is consistent after simulated interruption."""
db_path = tmp_path / "interrupted_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run partial migration
run_migrations(engine, target="0001")
# Simulate "interruption" by just stopping here
engine.dispose()
# Reconnect and check state
engine = create_engine(f"sqlite:///{db_path}")
# Database should be at 0001
assert get_current_revision(engine) == "0001"
# Should be able to continue
run_migrations(engine) # raises on failure
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
def test_re_running_migration_after_failure(self, tmp_path):
"""Test re-running migration after a previous failure."""
db_path = tmp_path / "rerun_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# First run succeeds
run_migrations(engine) # raises on failure
# Simulate database being in inconsistent state
# by manually inserting conflicting data
with engine.begin() as conn:
# This shouldn't cause issues on re-run
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('conflict-test', 'completed', 'research')
"""
)
)
# Re-running should still work
run_migrations(engine) # raises on failure
# Data should be preserved
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT task_id FROM task_metadata WHERE task_id = 'conflict-test'"
)
).fetchone()
assert result is not None
finally:
engine.dispose()
def test_migration_with_foreign_key_violations_in_source(self, tmp_path):
"""Test migration handles pre-existing data correctly."""
db_path = tmp_path / "fk_data_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run full migration first
run_migrations(engine)
# Insert valid data
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type, priority)
VALUES ('fk-test-1', 'completed', 'research', 1),
('fk-test-2', 'queued', 'benchmark', 2)
"""
)
)
# Re-run migration (should be idempotent)
run_migrations(engine) # raises on failure
# Verify data intact
with engine.connect() as conn:
count = conn.execute(
text(
"SELECT COUNT(*) FROM task_metadata WHERE task_id LIKE 'fk-test%'"
)
).fetchone()[0]
assert count == 2
finally:
engine.dispose()
class TestSchemaValidation:
"""Tests for schema correctness after migration."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "schema_validation_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_all_foreign_key_relationships_valid(self, migrated_engine):
"""Test that all foreign key relationships reference existing tables."""
inspector = inspect(migrated_engine)
existing_tables = set(inspector.get_table_names())
for table_name in existing_tables:
if table_name == "alembic_version":
continue
fks = inspector.get_foreign_keys(table_name)
for fk in fks:
referred_table = fk.get("referred_table")
if referred_table:
assert referred_table in existing_tables, (
f"Table {table_name} has FK to non-existent table {referred_table}"
)
def test_all_expected_indexes_exist(self, migrated_engine):
"""Test that important indexes exist."""
inspector = inspect(migrated_engine)
# Check settings table has index on key
if "settings" in inspector.get_table_names():
indexes = inspector.get_indexes("settings")
index_columns = []
for idx in indexes:
index_columns.extend(idx.get("column_names", []))
# Settings should have unique constraint or index on key
# Check if 'key' has unique constraint
unique_constraints = inspector.get_unique_constraints("settings")
has_key_constraint = any(
"key" in uc.get("column_names", []) for uc in unique_constraints
)
has_key_index = "key" in index_columns
assert has_key_constraint or has_key_index, (
"settings.key should have unique index"
)
def test_column_types_match_expectations(self, migrated_engine):
"""Test that column types are as expected."""
inspector = inspect(migrated_engine)
# Check task_metadata columns
tm_columns = {
col["name"]: col for col in inspector.get_columns("task_metadata")
}
# task_id should be string type
assert (
"VARCHAR" in str(tm_columns["task_id"]["type"]).upper()
or "TEXT" in str(tm_columns["task_id"]["type"]).upper()
)
# progress_current should be integer
assert (
"INTEGER" in str(tm_columns["progress_current"]["type"]).upper()
or "INT" in str(tm_columns["progress_current"]["type"]).upper()
)
def test_nullable_constraints_correct(self, migrated_engine):
"""Test nullable constraints are set correctly."""
inspector = inspect(migrated_engine)
# Check task_metadata
tm_columns = {
col["name"]: col for col in inspector.get_columns("task_metadata")
}
# status should not be nullable
assert tm_columns["status"]["nullable"] is False
# error_message should be nullable
assert tm_columns["error_message"]["nullable"] is True
# progress_message should be nullable
assert tm_columns["progress_message"]["nullable"] is True
def test_primary_keys_defined_correctly(self, migrated_engine):
"""Test all tables have properly defined primary keys."""
inspector = inspect(migrated_engine)
for table_name in inspector.get_table_names():
if table_name == "alembic_version":
continue
pk = inspector.get_pk_constraint(table_name)
pk_columns = pk.get("constrained_columns", [])
# Most tables should have a primary key
# (some association tables might not)
if table_name not in ["document_collections"]: # Junction tables
assert len(pk_columns) > 0, (
f"Table {table_name} missing primary key"
)
def test_all_model_tables_exist(self, migrated_engine):
"""Test that all model tables are created."""
inspector = inspect(migrated_engine)
tables = set(inspector.get_table_names())
# Core tables that must exist
required_tables = [
"settings",
"task_metadata",
"queue_status",
"research",
"research_history",
"token_usage",
"benchmark_runs",
"news_subscriptions",
]
for table in required_tables:
assert table in tables, f"Required table '{table}' not found"
class TestMigrationVersionConsistency:
"""Tests for migration version tracking consistency."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "version_consistency_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_alembic_version_never_has_multiple_rows(self, migrated_engine):
"""Test that alembic_version table never has multiple rows."""
with migrated_engine.connect() as conn:
result = conn.execute(
text("SELECT COUNT(*) FROM alembic_version")
).fetchone()
assert result[0] == 1, "alembic_version should have exactly one row"
def test_revision_chain_has_no_gaps(self):
"""Test that the revision chain is continuous with no gaps."""
from alembic.config import Config
from alembic.script import ScriptDirectory
migrations_dir = get_migrations_dir()
config = Config()
config.set_main_option("script_location", str(migrations_dir))
script = ScriptDirectory.from_config(config)
# Get all revisions
revisions = list(script.walk_revisions())
# Build the chain from head to base
head = script.get_current_head()
current = head
chain = []
while current is not None:
rev = script.get_revision(current)
chain.append(current)
current = rev.down_revision
# Verify chain matches walk_revisions
assert len(chain) == len(revisions), "Revision chain has gaps"
def test_head_revision_matches_latest_migration_file(self):
"""Test that head revision matches the latest migration file."""
from alembic.config import Config
from alembic.script import ScriptDirectory
migrations_dir = get_migrations_dir()
config = Config()
config.set_main_option("script_location", str(migrations_dir))
script = ScriptDirectory.from_config(config)
head = script.get_current_head()
# Current head should be the latest migration
assert head == get_head_revision(), (
f"Head revision mismatch: expected {get_head_revision()}, got {head}"
)
def test_all_revision_files_have_valid_structure(self):
"""Test that all migration files have required functions."""
versions_dir = get_migrations_dir() / "versions"
for py_file in versions_dir.glob("*.py"):
if py_file.name.startswith("__"):
continue
content = py_file.read_text()
# Check required elements
assert "revision = " in content, f"{py_file.name} missing revision"
assert "down_revision = " in content, (
f"{py_file.name} missing down_revision"
)
assert "def upgrade(" in content, (
f"{py_file.name} missing upgrade function"
)
assert "def downgrade(" in content, (
f"{py_file.name} missing downgrade function"
)
def test_revision_ids_are_unique(self):
"""Test that all revision IDs are unique."""
from alembic.config import Config
from alembic.script import ScriptDirectory
migrations_dir = get_migrations_dir()
config = Config()
config.set_main_option("script_location", str(migrations_dir))
script = ScriptDirectory.from_config(config)
revisions = list(script.walk_revisions())
revision_ids = [rev.revision for rev in revisions]
assert len(revision_ids) == len(set(revision_ids)), (
"Duplicate revision IDs found"
)
def test_stamp_updates_version_correctly(self, tmp_path):
"""Test that stamping updates version correctly."""
db_path = tmp_path / "stamp_version_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
Base.metadata.create_all(engine)
# Stamp at 0001
stamp_database(engine, "0001")
assert get_current_revision(engine) == "0001"
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM alembic_version")
).fetchone()[0]
assert count == 1
# Stamp at head
stamp_database(engine, "head")
assert get_current_revision(engine) == get_head_revision()
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM alembic_version")
).fetchone()[0]
assert count == 1 # Still only one row
finally:
engine.dispose()
class TestEdgeCaseTableNames:
"""Tests for edge cases with table names and SQL handling."""
@pytest.fixture
def migrated_engine(self, tmp_path):
"""Create a fully migrated database."""
db_path = tmp_path / "edge_case_names_test.db"
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()
def test_tables_with_underscores_work(self, migrated_engine):
"""Test that tables with underscores in names work correctly."""
inspector = inspect(migrated_engine)
tables = inspector.get_table_names()
# Find tables with underscores
underscore_tables = [t for t in tables if "_" in t]
assert len(underscore_tables) > 0, "Should have tables with underscores"
# Verify they can be queried
for table in underscore_tables[:3]: # Test first 3
with migrated_engine.connect() as conn:
# Should not raise
conn.execute(text(f"SELECT * FROM {table} LIMIT 1"))
def test_reserved_sql_keywords_handled(self, migrated_engine):
"""Test that any reserved SQL keywords are properly escaped."""
inspector = inspect(migrated_engine)
# Check all tables can be queried
for table_name in inspector.get_table_names():
with migrated_engine.connect() as conn:
# This should work even if table name is a reserved word
conn.execute(text(f'SELECT * FROM "{table_name}" LIMIT 1'))
def test_column_names_with_special_chars(self, migrated_engine):
"""Test columns can be accessed correctly."""
inspector = inspect(migrated_engine)
for table_name in inspector.get_table_names():
if table_name == "alembic_version":
continue
columns = inspector.get_columns(table_name)
for col in columns[:5]: # Test first 5 columns per table
col_name = col["name"]
with migrated_engine.connect() as conn:
# Should be able to select each column
conn.execute(
text(f'SELECT "{col_name}" FROM "{table_name}" LIMIT 1')
)
def test_case_sensitivity_in_table_names(self, migrated_engine):
"""Test case handling in table names (SQLite is case-insensitive)."""
inspector = inspect(migrated_engine)
tables = inspector.get_table_names()
# SQLite table names are case-insensitive by default
if "settings" in tables:
with migrated_engine.connect() as conn:
# Both should work in SQLite
conn.execute(text("SELECT * FROM settings LIMIT 1"))
conn.execute(text("SELECT * FROM SETTINGS LIMIT 1"))
def test_numeric_prefixed_table_handling(self, migrated_engine):
"""Test handling of tables that might have numeric-looking parts."""
# Alembic uses revision IDs like 0001, 0002
# Verify these don't cause issues
from alembic.config import Config
from alembic.script import ScriptDirectory
migrations_dir = get_migrations_dir()
config = Config()
config.set_main_option("script_location", str(migrations_dir))
script = ScriptDirectory.from_config(config)
# Should be able to get revisions with numeric IDs
rev_0001 = script.get_revision("0001")
rev_0002 = script.get_revision("0002")
assert rev_0001 is not None
assert rev_0002 is not None
def test_empty_table_operations(self, migrated_engine):
"""Test operations on empty tables work correctly."""
inspector = inspect(migrated_engine)
for table_name in inspector.get_table_names():
if table_name == "alembic_version":
continue
with migrated_engine.connect() as conn:
# Count on empty table
result = conn.execute(
text(f'SELECT COUNT(*) FROM "{table_name}"')
).fetchone()
# Should return 0 or some number, not error
assert result[0] >= 0
def test_long_value_insertion(self, migrated_engine):
"""Test handling of long string values."""
# Create a very long string
long_string = "x" * 10000
with migrated_engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type, error_message)
VALUES ('long-value-test', 'failed', 'research', :msg)
"""
),
{"msg": long_string},
)
# Verify retrieval
with migrated_engine.connect() as conn:
result = conn.execute(
text(
"SELECT error_message FROM task_metadata WHERE task_id = 'long-value-test'"
)
).fetchone()
assert result is not None
assert len(result[0]) == 10000
class TestSQLCipherMigrationsComprehensive:
"""Comprehensive tests for SQLCipher encrypted database migrations.
These tests cover scenarios specific to encrypted databases including:
- Real file-based encrypted databases (not in-memory)
- Data preservation during migration
- Wrong password handling
- Password change followed by migration
"""
@pytest.fixture
def sqlcipher_available(self):
"""Check if SQLCipher is available."""
import importlib.util
if importlib.util.find_spec("sqlcipher3") is None:
pytest.skip("SQLCipher not available")
return True
@pytest.fixture
def encrypted_file_engine(self, tmp_path, sqlcipher_available):
"""Create an encrypted file-based SQLCipher database (not in-memory)."""
import sqlcipher3
db_path = tmp_path / "encrypted_file_test.db"
password = "secure_test_password_123!"
def create_connection():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.execute("PRAGMA kdf_iter = 256000")
cursor.execute("PRAGMA cipher_hmac_algorithm = HMAC_SHA512")
cursor.execute("PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512")
cursor.close()
return conn
engine = create_engine("sqlite://", creator=create_connection)
yield {"engine": engine, "db_path": db_path, "password": password}
engine.dispose()
def test_migration_on_encrypted_file_database(
self, encrypted_file_engine, sqlcipher_available
):
"""Test migrations work correctly on encrypted file-based databases."""
engine = encrypted_file_engine["engine"]
db_path = encrypted_file_engine["db_path"]
# Run migrations
run_migrations(engine) # raises on failure
# Verify tables created
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "alembic_version" in tables
assert "settings" in tables
assert "task_metadata" in tables
assert "research" in tables
# Verify the file was actually created (not just in-memory)
assert db_path.exists()
# Encrypted file should have some content
assert db_path.stat().st_size > 0
def test_migration_preserves_encrypted_data(
self, tmp_path, sqlcipher_available
):
"""Test that data is preserved during migration on encrypted database."""
import sqlcipher3
db_path = tmp_path / "data_preserve_encrypted.db"
password = "data_preserve_password"
def create_connection():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.execute("PRAGMA kdf_iter = 256000")
cursor.execute("PRAGMA cipher_hmac_algorithm = HMAC_SHA512")
cursor.execute("PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512")
cursor.close()
return conn
engine = create_engine("sqlite://", creator=create_connection)
try:
# Run initial migration to 0001
run_migrations(engine, target="0001")
# Insert test data
with engine.begin() as conn:
for i in range(100):
conn.execute(
text(
f"""
INSERT INTO task_metadata (task_id, status, task_type, priority)
VALUES ('encrypted-task-{i}', 'completed', 'research', {i})
"""
)
)
# Verify data count before migration
with engine.connect() as conn:
count_before = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count_before == 100
# Run remaining migrations
run_migrations(engine) # raises on failure
# Verify all data preserved after migration
with engine.connect() as conn:
count_after = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count_after == 100
# Verify specific data integrity
sample = conn.execute(
text(
"SELECT task_id, priority FROM task_metadata WHERE task_id = 'encrypted-task-50'"
)
).fetchone()
assert sample is not None
assert sample[1] == 50
finally:
engine.dispose()
def test_wrong_password_fails_gracefully(
self, tmp_path, sqlcipher_available
):
"""Test that using wrong password on encrypted database fails gracefully."""
import sqlcipher3
db_path = tmp_path / "wrong_password_test.db"
correct_password = "correct_password_123"
wrong_password = "wrong_password_456"
# Create database with correct password
def create_connection_correct():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{correct_password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.close()
return conn
engine1 = create_engine("sqlite://", creator=create_connection_correct)
run_migrations(engine1)
engine1.dispose()
# Try to open with wrong password
def create_connection_wrong():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{wrong_password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.close()
return conn
engine2 = create_engine("sqlite://", creator=create_connection_wrong)
# Trying to access the database should fail
with pytest.raises(Exception):
with engine2.connect() as conn:
conn.execute(text("SELECT * FROM settings LIMIT 1"))
engine2.dispose()
def test_encrypted_database_reopen_preserves_revision(
self, tmp_path, sqlcipher_available
):
"""Test that reopening encrypted database preserves migration revision."""
import sqlcipher3
db_path = tmp_path / "reopen_revision_test.db"
password = "reopen_test_password"
def create_connection():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.execute("PRAGMA kdf_iter = 256000")
cursor.close()
return conn
# First session - create and migrate
engine1 = create_engine("sqlite://", creator=create_connection)
run_migrations(engine1)
revision1 = get_current_revision(engine1)
engine1.dispose()
# Second session - reopen and check
engine2 = create_engine("sqlite://", creator=create_connection)
revision2 = get_current_revision(engine2)
assert revision1 == revision2
assert revision2 == get_head_revision()
# Should not need migration
assert not needs_migration(engine2)
engine2.dispose()
def test_encrypted_migration_with_large_data(
self, tmp_path, sqlcipher_available
):
"""Test migration on encrypted database with realistic data volume."""
import sqlcipher3
db_path = tmp_path / "large_encrypted_test.db"
password = "large_data_password"
def create_connection():
conn = sqlcipher3.connect(str(db_path), check_same_thread=False)
cursor = conn.cursor()
cursor.execute(f"PRAGMA key = '{password}'")
cursor.execute("PRAGMA cipher_page_size = 4096")
cursor.execute("PRAGMA kdf_iter = 256000")
cursor.close()
return conn
engine = create_engine("sqlite://", creator=create_connection)
try:
# Run initial migration
run_migrations(engine, target="0001")
# Insert 1000 rows (realistic for a user database)
with engine.begin() as conn:
for batch in range(10):
values = ", ".join(
[
f"('large-task-{batch}-{i}', 'completed', 'research')"
for i in range(100)
]
)
conn.execute(
text(
f"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES {values}
"""
)
)
# Run column migration (adds columns to existing table)
run_migrations(engine) # raises on failure
# Verify data preserved
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count == 1000
# Verify new columns exist and have defaults
inspector = inspect(engine)
columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "progress_current" in columns
assert "progress_total" in columns
finally:
engine.dispose()
class TestMigrationPerformanceBenchmarks:
"""Performance benchmark tests for migrations.
These tests establish baselines for migration performance to catch
any regressions in batch mode operations on large tables.
"""
@pytest.mark.slow
def test_batch_migration_performance_small_table(self, tmp_path):
"""Batch column migration should complete quickly on small tables (1K rows)."""
import time
db_path = tmp_path / "perf_small_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run initial migration
run_migrations(engine, target="0001")
# Insert 1,000 rows
with engine.begin() as conn:
for batch in range(10):
values = ", ".join(
[
f"('small-perf-{batch}-{i}', 'completed', 'research')"
for i in range(100)
]
)
conn.execute(
text(
f"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES {values}
"""
)
)
# Time the batch column migration
start = time.time()
run_migrations(engine) # raises on failure
elapsed = time.time() - start
# 1K rows should migrate in under 5 seconds
assert elapsed < 5, (
f"Small table migration took {elapsed:.2f}s, expected < 5s"
)
# Verify data preserved
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count == 1000
finally:
engine.dispose()
@pytest.mark.slow
def test_batch_migration_performance_medium_table(self, tmp_path):
"""Batch column migration should complete reasonably on medium tables (10K rows)."""
import time
db_path = tmp_path / "perf_medium_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run initial migration
run_migrations(engine, target="0001")
# Insert 10,000 rows
with engine.begin() as conn:
for batch in range(100):
values = ", ".join(
[
f"('medium-perf-{batch}-{i}', 'completed', 'research')"
for i in range(100)
]
)
conn.execute(
text(
f"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES {values}
"""
)
)
# Verify row count
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count == 10000
# Time the batch column migration
start = time.time()
run_migrations(engine) # raises on failure
elapsed = time.time() - start
# 10K rows should migrate in under 30 seconds
assert elapsed < 30, (
f"Medium table migration took {elapsed:.2f}s, expected < 30s"
)
# Verify data preserved
with engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()[0]
assert count == 10000
# Verify new columns have correct defaults
with engine.connect() as conn:
sample = conn.execute(
text(
"""
SELECT progress_current, progress_total
FROM task_metadata LIMIT 1
"""
)
).fetchone()
# New columns should have defaults
assert sample[0] == 0 or sample[0] is None
assert sample[1] == 0 or sample[1] is None
finally:
engine.dispose()
def test_initial_migration_performance(self, tmp_path):
"""Initial schema creation should be fast."""
import time
db_path = tmp_path / "initial_perf_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
start = time.time()
run_migrations(engine) # raises on failure
elapsed = time.time() - start
# Fresh migration should complete in under 10 seconds
assert elapsed < 10, (
f"Initial migration took {elapsed:.2f}s, expected < 10s"
)
# Verify all tables created
inspector = inspect(engine)
tables = inspector.get_table_names()
assert len(tables) > 20, "Should create many tables"
finally:
engine.dispose()
class TestConcurrentMigrationProcesses:
"""Tests for concurrent migration scenarios.
Tests what happens when multiple processes/threads try to migrate
the same database. Note: SQLite has limited concurrency support,
so we test with staggered timing and sequential engine usage.
"""
def test_sequential_engine_migrations(self, tmp_path):
"""Test sequential migrations from different engine instances.
This tests the realistic scenario where different processes/sessions
try to migrate the same database at different times. SQLite uses
file-level locking which means truly parallel migrations would
cause lock contention.
"""
db_path = tmp_path / "sequential_engine_test.db"
for i in range(5):
engine = create_engine(f"sqlite:///{db_path}")
try:
run_migrations(engine) # raises on failure
finally:
engine.dispose()
# Verify database is correctly migrated
engine = create_engine(f"sqlite:///{db_path}")
try:
assert get_current_revision(engine) == get_head_revision()
assert not needs_migration(engine)
finally:
engine.dispose()
def test_migration_during_active_reads(self, tmp_path):
"""Test migration completes correctly while reads are happening."""
import threading
import time
db_path = tmp_path / "active_reads_test.db"
# Create and migrate database first
engine = create_engine(f"sqlite:///{db_path}")
run_migrations(engine, target="0001")
# Insert data
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('read-test', 'completed', 'research')
"""
)
)
read_counts = []
read_errors = []
migration_done = threading.Event()
def reader_thread():
"""Continuously read from database."""
read_engine = create_engine(f"sqlite:///{db_path}")
try:
while not migration_done.is_set():
with read_engine.connect() as conn:
result = conn.execute(
text("SELECT COUNT(*) FROM task_metadata")
).fetchone()
read_counts.append(result[0])
time.sleep(0.01)
except Exception as e:
read_errors.append(str(e))
finally:
read_engine.dispose()
# Start reader thread
reader = threading.Thread(target=reader_thread)
reader.start()
# Run migration while reads are happening
time.sleep(0.05) # Let some reads happen first
run_migrations(engine) # raises on failure
migration_done.set()
reader.join()
engine.dispose()
# Verify results
assert len(read_errors) == 0, f"Read errors: {read_errors}"
assert len(read_counts) > 0, "Should have completed some reads"
def test_no_corruption_on_sequential_migrations(self, tmp_path):
"""Test that sequential migrations from different engines don't corrupt the database."""
db_path = tmp_path / "no_corruption_test.db"
# Run migrations sequentially from different engine instances
for i in range(3):
engine = create_engine(f"sqlite:///{db_path}")
try:
run_migrations(engine) # raises on failure
finally:
engine.dispose()
# Verify integrity
check_engine = create_engine(f"sqlite:///{db_path}")
try:
with check_engine.connect() as conn:
# Run quick integrity check
result = conn.execute(text("PRAGMA quick_check")).fetchone()
assert result[0] == "ok"
# Verify alembic_version has exactly one row
version_count = conn.execute(
text("SELECT COUNT(*) FROM alembic_version")
).fetchone()[0]
assert version_count == 1
finally:
check_engine.dispose()
class TestDowngradeVerification:
"""Tests to verify and document downgrade behavior.
These tests document the expected behavior of downgrade migrations,
including destructive operations.
"""
def test_downgrade_0002_removes_progress_columns(self, tmp_path):
"""Test that downgrading from 0002 removes the progress columns.
Starts at 0008 (last fully-reversible) rather than head; 0010
is intentionally non-reversible (SQLite ALTER TABLE limitations).
"""
from alembic import command
db_path = tmp_path / "downgrade_0002_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Upgrade to 0008 (last reversible)
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
# Verify progress columns exist
inspector = inspect(engine)
columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "progress_current" in columns
assert "progress_total" in columns
assert "progress_message" in columns
assert "metadata_json" in columns
# Downgrade to 0001
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "0001")
# Verify progress columns removed
inspector = inspect(engine)
columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "progress_current" not in columns
assert "progress_total" not in columns
assert "progress_message" not in columns
assert "metadata_json" not in columns
# Core columns should still exist
assert "task_id" in columns
assert "status" in columns
finally:
engine.dispose()
def test_downgrade_0001_is_destructive_warning(self, tmp_path):
"""WARNING: Test documents that downgrade from 0001 to base drops ALL tables.
This test verifies the destructive nature of the 0001 downgrade.
In production, this should NEVER be run without a backup.
Starts at 0008 (last fully-reversible) rather than head; 0010
is intentionally non-reversible (SQLite ALTER TABLE limitations).
"""
from alembic import command
db_path = tmp_path / "destructive_downgrade_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Upgrade to 0008 (last reversible)
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
# Count tables before downgrade
inspector = inspect(engine)
tables_before = set(inspector.get_table_names())
assert len(tables_before) > 10, "Should have many tables"
assert "settings" in tables_before
assert "research" in tables_before
# Downgrade to base (DESTRUCTIVE)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "base")
# Verify most tables dropped
inspector = inspect(engine)
tables_after = set(inspector.get_table_names())
# Only alembic_version may remain (or nothing)
assert len(tables_after) <= 1, (
f"Expected most tables dropped, got {tables_after}"
)
finally:
engine.dispose()
def test_downgrade_preserves_data_in_kept_columns(self, tmp_path):
"""Test that downgrade preserves data in columns that are kept.
Starts at 0008 (last fully-reversible) rather than head; 0010
is intentionally non-reversible (SQLite ALTER TABLE limitations).
"""
from alembic import command
db_path = tmp_path / "downgrade_preserve_data_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Upgrade to 0008 (last reversible)
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
# Insert data using both old and new columns
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata
(task_id, status, task_type, priority, progress_current, progress_total)
VALUES ('downgrade-data-test', 'completed', 'research', 42, 10, 100)
"""
)
)
# Downgrade to 0001 (removes progress columns but keeps core columns)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "0001")
# Verify core data preserved
with engine.connect() as conn:
result = conn.execute(
text(
"""
SELECT task_id, status, task_type, priority
FROM task_metadata WHERE task_id = 'downgrade-data-test'
"""
)
).fetchone()
assert result is not None
assert result[0] == "downgrade-data-test"
assert result[1] == "completed"
assert result[2] == "research"
assert result[3] == 42 # Priority preserved
finally:
engine.dispose()
def test_upgrade_after_downgrade_restores_columns(self, tmp_path):
"""Test that upgrading after downgrade correctly restores columns.
Starts at 0008 (last fully-reversible) rather than head; 0010
is intentionally non-reversible (SQLite ALTER TABLE limitations).
Re-upgrade to head still verifies forward path through 0010.
"""
from alembic import command
db_path = tmp_path / "upgrade_after_downgrade_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Upgrade to 0008 (last reversible)
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
# Insert data
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('roundtrip-test', 'completed', 'research')
"""
)
)
# Downgrade to 0001
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "0001")
# Upgrade back to head (passes through 0010 in forward direction)
run_migrations(engine)
# Verify columns restored
inspector = inspect(engine)
columns = {
col["name"] for col in inspector.get_columns("task_metadata")
}
assert "progress_current" in columns
assert "progress_total" in columns
# Verify data preserved
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT task_id, status FROM task_metadata WHERE task_id = 'roundtrip-test'"
)
).fetchone()
assert result is not None
assert result[0] == "roundtrip-test"
finally:
engine.dispose()
# =============================================================================
# Security Tests
# =============================================================================
class TestDatabaseFileSecurity:
"""Tests for database file security properties."""
@pytest.mark.skipif(
os.name == "nt", reason="File permissions work differently on Windows"
)
def test_database_file_not_world_readable(self, tmp_path):
"""Database files should not be world-readable after creation."""
db_path = tmp_path / "permission_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
run_migrations(engine)
# Check file permissions - verify file exists and is readable
# Note: SQLite doesn't set restrictive permissions by default,
# but this test documents the current behavior and verifies umask is applied
assert db_path.exists(), "Database file should exist"
file_mode = os.stat(db_path).st_mode
# The file should have been created with current umask
# This assertion documents that permissions are set
assert file_mode & 0o600, "Database should be readable by owner"
finally:
engine.dispose()
def test_encrypted_db_unreadable_without_password(self, tmp_path):
"""Verify encrypted DB cannot be accessed without correct password."""
pytest.importorskip("sqlcipher3")
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
set_sqlcipher_key,
apply_sqlcipher_pragmas,
)
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
db_path = tmp_path / "encrypted_test.db"
password = "correct_password_123"
# Create encrypted database
conn = create_sqlcipher_connection(str(db_path), password)
cursor = conn.cursor()
cursor.execute(
"CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)"
)
cursor.execute("INSERT INTO test_data (value) VALUES ('secret')")
conn.commit()
conn.close()
# Try to open with wrong password
sqlcipher3 = get_sqlcipher_module()
wrong_conn = sqlcipher3.connect(str(db_path))
wrong_cursor = wrong_conn.cursor()
set_sqlcipher_key(wrong_cursor, "wrong_password")
apply_sqlcipher_pragmas(wrong_cursor, creation_mode=False)
with pytest.raises(Exception):
# Should fail to read data with wrong password
wrong_cursor.execute("SELECT * FROM test_data")
wrong_cursor.fetchall()
wrong_conn.close()
def test_encrypted_db_readable_with_correct_password(self, tmp_path):
"""Verify encrypted DB can be accessed with correct password."""
pytest.importorskip("sqlcipher3")
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
)
db_path = tmp_path / "encrypted_correct_test.db"
password = "test_password_456"
# Create encrypted database with test data
conn = create_sqlcipher_connection(str(db_path), password)
cursor = conn.cursor()
cursor.execute(
"CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)"
)
cursor.execute("INSERT INTO test_data (value) VALUES ('accessible')")
conn.commit()
conn.close()
# Reopen with correct password
conn2 = create_sqlcipher_connection(str(db_path), password)
cursor2 = conn2.cursor()
cursor2.execute("SELECT value FROM test_data")
result = cursor2.fetchone()
conn2.close()
assert result is not None
assert result[0] == "accessible"
class TestErrorMessageSanitization:
"""Tests for error message sanitization to prevent information leakage."""
def test_migration_error_does_not_expose_full_path(self, tmp_path):
"""Error messages should not expose full filesystem paths."""
# Test with a path that would be sensitive if exposed
sensitive_path = tmp_path / "sensitive_user_data" / "private.db"
sensitive_path.parent.mkdir(parents=True, exist_ok=True)
# Create a read-only directory scenario
engine = create_engine(f"sqlite:///{sensitive_path}")
try:
# This test verifies that if errors occur, they don't leak paths
# in a way that could be exploited
run_migrations(engine)
# If we get here, migration worked - verify database exists
assert sensitive_path.exists()
finally:
engine.dispose()
def test_invalid_revision_error_is_sanitized(self, tmp_path):
"""Invalid revision errors should not expose internal details."""
db_path = tmp_path / "revision_error_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# First run valid migrations
run_migrations(engine)
# Try to migrate to a non-existent revision
# This should fail gracefully without exposing internals
try:
run_migrations(engine, target="non_existent_revision_xyz")
except Exception as e:
error_msg = str(e)
# Error message should not contain sensitive paths or details
# that could be used for reconnaissance
assert "/home/" not in error_msg or "non_existent" in error_msg
finally:
engine.dispose()
def test_connection_error_no_credential_exposure(self):
"""Connection errors should not expose credentials in messages."""
# Create an engine with invalid credentials in URL
# (SQLite doesn't use credentials, but the pattern is important)
# Test that error handling doesn't echo back sensitive info
try:
# This should fail but not expose the path in a dangerous way
engine = create_engine("sqlite:///nonexistent_path/db.sqlite")
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception as e:
error_msg = str(e).lower()
# Should not contain common credential patterns
assert "password" not in error_msg
assert "secret" not in error_msg
assert "key=" not in error_msg
def test_alembic_runner_logs_sanitized(self, tmp_path, caplog):
"""Verify that alembic runner logs don't expose sensitive data."""
db_path = tmp_path / "log_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# DEBUG level value is 10 in the standard hierarchy
with caplog.at_level(10):
run_migrations(engine)
# Check log messages don't contain sensitive patterns
for record in caplog.records:
msg = record.getMessage().lower()
# Should not log passwords or keys
assert (
"password" not in msg or "wrong" in msg
) # test messages ok
assert "secret" not in msg
assert "credential" not in msg
finally:
engine.dispose()
class TestCryptographicConsistency:
"""Tests for cryptographic consistency in SQLCipher operations."""
def test_rekey_uses_pbkdf2_like_set_key(self, tmp_path):
"""Verify rekey uses same PBKDF2 derivation as set_key for consistency."""
pytest.importorskip("sqlcipher3")
from local_deep_research.database.sqlcipher_utils import (
create_sqlcipher_connection,
set_sqlcipher_rekey,
set_sqlcipher_key,
apply_sqlcipher_pragmas,
)
from local_deep_research.database.sqlcipher_compat import (
get_sqlcipher_module,
)
db_path = tmp_path / "rekey_consistency_test.db"
original_password = "original_password_123"
new_password = "new_password_456"
# Create encrypted database with original password
conn = create_sqlcipher_connection(str(db_path), original_password)
cursor = conn.cursor()
cursor.execute(
"CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)"
)
cursor.execute("INSERT INTO test_data (value) VALUES ('secret_data')")
conn.commit()
# Rekey to new password
set_sqlcipher_rekey(cursor, new_password)
conn.commit()
conn.close()
# Verify we can open with new password using set_sqlcipher_key
# This proves rekey used the same PBKDF2 derivation
sqlcipher3 = get_sqlcipher_module()
conn2 = sqlcipher3.connect(str(db_path))
cursor2 = conn2.cursor()
set_sqlcipher_key(cursor2, new_password)
apply_sqlcipher_pragmas(cursor2, creation_mode=False)
# Should be able to read data with new password
cursor2.execute("SELECT value FROM test_data")
result = cursor2.fetchone()
conn2.close()
assert result is not None
assert result[0] == "secret_data"
def test_rekey_derived_key_matches_set_key_derived_key(self):
"""Verify _get_key_from_password produces consistent results."""
from local_deep_research.database.sqlcipher_utils import (
_get_key_from_password,
)
password = "test_password_xyz"
salt = os.urandom(16)
kdf_iterations = 256000
# Call twice to ensure consistency (cached result should match)
key1 = _get_key_from_password(password, salt, kdf_iterations)
key2 = _get_key_from_password(password, salt, kdf_iterations)
assert key1 == key2
assert len(key1) > 0
# Verify it's a bytes object (PBKDF2 output)
assert isinstance(key1, bytes)
def _insert_witness_then_fail(config, target):
"""side_effect for patched ``command.upgrade``.
Inserts a witness row through the transaction's connection, then
raises. If ``engine.begin()``'s rollback works, the row must not
be present after the raise — this is what makes the rollback
tests non-tautological. A plain ``side_effect=RuntimeError(...)``
fires before any DB write, so the post-failure "revision
preserved" assertion would be trivially true.
Uses DML (INSERT) rather than DDL (CREATE TABLE) because pysqlite
does not reliably roll back DDL statements on default SQLite. The
caller must pre-create a ``rollback_witness`` table before
patching ``command.upgrade`` with this side_effect.
"""
conn = config.attributes["connection"]
conn.execute(text("INSERT INTO rollback_witness (x) VALUES (1)"))
raise RuntimeError("simulated failure")
class TestMigrationErrorSanitization:
"""Tests for migration error message sanitization."""
def test_migration_error_logs_type_not_full_path(self, tmp_path, caplog):
"""Verify migration errors log exception type, not full paths."""
# Create a scenario that will cause a migration error
db_path = tmp_path / "error_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Run migrations first
run_migrations(engine)
# Now corrupt the alembic_version to cause an error
with engine.begin() as conn:
conn.execute(text("DELETE FROM alembic_version"))
conn.execute(
text(
"INSERT INTO alembic_version (version_num) VALUES ('invalid')"
)
)
# Clear logs and try to migrate again
caplog.clear()
# This should raise due to the invalid version
with caplog.at_level(10): # DEBUG level
with pytest.raises(Exception):
run_migrations(engine)
# Check that any error logs don't contain sensitive home paths
for record in caplog.records:
msg = record.getMessage()
# Should not contain home directory paths in error messages
if "error" in msg.lower() or "failed" in msg.lower():
# Error messages should be sanitized
assert "/home/" not in msg or "Migration" in msg
finally:
engine.dispose()
def test_migration_raises_on_failure_and_preserves_revision(self, tmp_path):
"""Failed migration must raise, leave DB at previous revision,
AND roll back any writes made inside the transaction.
Uses a fresh DB so the short-circuit for already-at-head does not
bypass the patched failure. A fresh DB has current revision None,
which falls through to command.upgrade().
The patched side_effect inserts a witness row before raising,
so the rollback assertion is a real rollback proof — not a
tautology.
"""
db_path = tmp_path / "failure_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Pre-create the witness table outside the transaction the
# patched call will run in. DML (INSERT) rollback is
# reliable on default SQLite; DDL (CREATE TABLE) is not.
with engine.begin() as conn:
conn.execute(text("CREATE TABLE rollback_witness (x INTEGER)"))
rev_before = get_current_revision(engine)
assert rev_before is None
with patch(
"local_deep_research.database.alembic_runner.command.upgrade",
side_effect=_insert_witness_then_fail,
):
with pytest.raises(RuntimeError, match="simulated failure"):
run_migrations(engine)
assert get_current_revision(engine) == rev_before
with engine.begin() as conn:
rows = list(
conn.execute(text("SELECT x FROM rollback_witness"))
)
assert rows == []
finally:
engine.dispose()
def test_migration_failure_preserves_concrete_prior_revision(
self, tmp_path
):
"""Failed upgrade from a concrete prior revision must roll back
cleanly — the DB must still report that concrete revision, and
any write made inside the transaction must be undone.
Passes an explicit revision target (not "head") so the at-head
short-circuit does not skip the patched upgrade call. The
patched side_effect inserts a witness row before raising, so
the rollback assertion actually exercises rollback.
"""
db_path = tmp_path / "failure_concrete_test.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
run_migrations(engine)
rev_before = get_current_revision(engine)
assert rev_before is not None
# Pre-create the witness table outside the transaction the
# patched call will run in.
with engine.begin() as conn:
conn.execute(text("CREATE TABLE rollback_witness (x INTEGER)"))
with patch(
"local_deep_research.database.alembic_runner.command.upgrade",
side_effect=_insert_witness_then_fail,
):
with pytest.raises(RuntimeError, match="simulated failure"):
run_migrations(engine, target=rev_before)
assert get_current_revision(engine) == rev_before
with engine.begin() as conn:
rows = list(
conn.execute(text("SELECT x FROM rollback_witness"))
)
assert rows == []
finally:
engine.dispose()
class TestMigrationPathSecurity:
"""Tests for migration directory path security."""
def test_migrations_dir_within_package(self):
"""Verify migrations directory is within expected package path."""
migrations_dir = get_migrations_dir()
real_path = migrations_dir.resolve()
# Should be within the database package directory
assert "local_deep_research" in str(real_path)
assert "database" in str(real_path)
assert "migrations" in str(real_path)
def test_migrations_dir_is_not_symlink_by_default(self):
"""Verify the migrations directory is not a symlink by default."""
migrations_dir = get_migrations_dir()
# The directory itself should not be a symlink
# (this documents expected behavior, actual symlink detection is in the function)
assert migrations_dir.exists()
@pytest.mark.skipif(
os.name == "nt", reason="Symlink creation requires admin on Windows"
)
def test_symlink_attack_detection(self, tmp_path):
"""Verify symlink attacks are detected (mocked test)."""
from pathlib import Path
# Create a mock symlinked path scenario
malicious_target = tmp_path / "malicious_migrations"
malicious_target.mkdir()
def mock_get_migrations_dir():
"""
Simulates what get_migrations_dir() does but with a path
that resolves outside the expected package boundary.
"""
# The actual function should detect this
real_path = malicious_target
expected_parent = Path(__file__).parent.resolve()
if not str(real_path).startswith(str(expected_parent)):
raise ValueError(
"Invalid migrations path (possible symlink attack)"
)
return malicious_target
# Verify that a simulated attack would be caught
with pytest.raises(ValueError) as exc_info:
mock_get_migrations_dir()
assert "symlink attack" in str(exc_info.value)
class TestMigrationFilePermissions:
"""Tests for migration file permission validation."""
@pytest.mark.skipif(
os.name == "nt", reason="File permissions work differently on Windows"
)
def test_migration_files_not_world_writable(self):
"""Verify actual migration files are not world-writable."""
migrations_dir = get_migrations_dir()
versions_dir = migrations_dir / "versions"
if not versions_dir.exists():
pytest.skip("No versions directory exists yet")
for migration_file in versions_dir.glob("*.py"):
st = migration_file.stat()
# Check world-writable bit is NOT set
assert not (st.st_mode & 0o002), (
f"Migration file {migration_file.name} is world-writable"
)
@pytest.mark.skipif(
os.name == "nt", reason="File permissions work differently on Windows"
)
def test_world_writable_migration_rejected(self, tmp_path):
"""Verify world-writable migration files are rejected."""
from local_deep_research.database.alembic_runner import (
_validate_migrations_permissions,
)
# Create a mock migrations directory structure
mock_migrations = tmp_path / "migrations"
mock_versions = mock_migrations / "versions"
mock_versions.mkdir(parents=True)
# Create a world-writable migration file
bad_migration = mock_versions / "001_bad_migration.py"
bad_migration.write_text("# malicious content")
os.chmod(bad_migration, 0o666) # noqa: S103 — intentionally testing permission validation
# Should raise ValueError
with pytest.raises(ValueError) as exc_info:
_validate_migrations_permissions(mock_migrations)
assert "world-writable" in str(exc_info.value)
assert "001_bad_migration.py" in str(exc_info.value)
@pytest.mark.skipif(
os.name == "nt", reason="File permissions work differently on Windows"
)
def test_secure_migration_permissions_accepted(self, tmp_path):
"""Verify migrations with secure permissions pass validation."""
from local_deep_research.database.alembic_runner import (
_validate_migrations_permissions,
)
# Create a mock migrations directory with secure files
mock_migrations = tmp_path / "migrations"
mock_versions = mock_migrations / "versions"
mock_versions.mkdir(parents=True)
# Create secure migration files
good_migration = mock_versions / "001_good_migration.py"
good_migration.write_text("# secure content")
os.chmod(
good_migration, 0o644
) # Owner rw, group r, other r (not writable)
# Should not raise
_validate_migrations_permissions(mock_migrations)
def test_permission_check_skipped_on_windows(self, tmp_path, monkeypatch):
"""Verify permission checks are skipped on Windows."""
from local_deep_research.database.alembic_runner import (
_validate_migrations_permissions,
)
# Mock os.name to be 'nt' (Windows)
monkeypatch.setattr(os, "name", "nt")
# Create a mock migrations directory (permissions don't matter)
mock_migrations = tmp_path / "migrations"
mock_versions = mock_migrations / "versions"
mock_versions.mkdir(parents=True)
bad_migration = mock_versions / "001_migration.py"
bad_migration.write_text("# content")
# Should not raise even if file would be "insecure" on Unix
_validate_migrations_permissions(mock_migrations)
@pytest.mark.skipif(
os.name == "nt", reason="File permissions work differently on Windows"
)
def test_world_writable_versions_dir_rejected(self, tmp_path):
"""Reject a world-writable versions/ directory even when files inside are secure.
Regression: a Docker image landed with mode 0o777 on the directory
itself (files were 0o644). Every per-user login then tripped this
check, swallowed the resulting ValueError, and silently left the DB
at its previous Alembic revision — surfacing later as
``no such table: papers`` on academic-source saves.
"""
from local_deep_research.database.alembic_runner import (
_validate_migrations_permissions,
)
mock_migrations = tmp_path / "migrations"
mock_versions = mock_migrations / "versions"
mock_versions.mkdir(parents=True)
# File itself is fine — the only insecure thing is the directory.
secure_migration = mock_versions / "001_migration.py"
secure_migration.write_text("# content")
os.chmod(secure_migration, 0o644)
os.chmod(mock_versions, 0o777) # noqa: S103 — intentionally testing rejection
with pytest.raises(ValueError) as exc_info:
_validate_migrations_permissions(mock_migrations)
msg = str(exc_info.value)
assert "world-writable" in msg
assert "versions" in msg
@pytest.mark.skipif(
os.name == "nt", reason="File permissions work differently on Windows"
)
def test_shipped_versions_dir_not_world_writable(self):
"""The actual shipped versions/ directory must not be world-writable.
Companion to ``test_migration_files_not_world_writable`` (which only
covers the files). The runtime check rejects either; both need to
be guarded against packaging regressions.
"""
migrations_dir = get_migrations_dir()
versions_dir = migrations_dir / "versions"
if not versions_dir.exists():
pytest.skip("No versions directory exists yet")
st = versions_dir.stat()
assert not (st.st_mode & 0o002), (
f"versions/ directory is world-writable (mode={oct(st.st_mode)}). "
"If this fires in CI after a packaging change, normalise the "
"perms in Dockerfile/build, not by weakening this test."
)
# =============================================================================
# Backward Compatibility Tests
# =============================================================================
class TestPreAlembicDatabaseUpgrade:
"""
Simulate real-world upgrade scenarios where databases were created
with the old Base.metadata.create_all() + _run_migrations() approach
and must be seamlessly upgraded to Alembic-managed schema.
"""
@pytest.fixture
def pre_alembic_db_full(self, tmp_path):
"""
Simulate a pre-Alembic database: all tables created via
Base.metadata.create_all(), progress columns already added
by the old _run_migrations() path. No alembic_version table.
"""
db_path = tmp_path / "pre_alembic_full.db"
engine = create_engine(f"sqlite:///{db_path}")
# This is exactly what the old initialize_database() did
Base.metadata.create_all(engine, checkfirst=True)
yield engine
engine.dispose()
@pytest.fixture
def pre_alembic_db_old(self, tmp_path):
"""
Simulate a very old pre-Alembic database: all tables created via
Base.metadata.create_all(), but progress columns NOT yet added
(simulates a database from before _run_migrations existed).
"""
db_path = tmp_path / "pre_alembic_old.db"
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(engine, checkfirst=True)
# Remove progress columns to simulate old schema
with engine.begin() as conn:
inspector = inspect(conn)
if inspector.has_table("task_metadata"):
cols = {
c["name"] for c in inspector.get_columns("task_metadata")
}
for col in [
"progress_current",
"progress_total",
"progress_message",
"metadata_json",
]:
if col in cols:
# SQLite doesn't support DROP COLUMN before 3.35,
# so recreate the table without those columns
pass
# Simpler approach: create task_metadata manually without progress cols
with engine.begin() as conn:
conn.execute(text("DROP TABLE IF EXISTS task_metadata"))
conn.execute(
text("""
CREATE TABLE task_metadata (
id INTEGER PRIMARY KEY,
task_id VARCHAR,
status VARCHAR,
task_type VARCHAR,
created_at DATETIME,
completed_at DATETIME,
duration_seconds FLOAT,
error_message VARCHAR
)
""")
)
yield engine
engine.dispose()
@pytest.fixture
def pre_alembic_db_with_data(self, tmp_path):
"""
Simulate a pre-Alembic database with realistic user data that
must survive the migration.
"""
db_path = tmp_path / "pre_alembic_data.db"
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(engine, checkfirst=True)
# Insert realistic data using ORM (handles all NOT NULL defaults)
from local_deep_research.database.models import Setting
from local_deep_research.database.models.settings import SettingType
Session = sessionmaker(bind=engine)
with Session() as session:
session.add(
Setting(
key="llm.provider",
value="ollama",
type=SettingType.LLM,
name="LLM Provider",
category="llm",
)
)
session.add(
Setting(
key="search.engine",
value="duckduckgo",
type=SettingType.SEARCH,
name="Search Engine",
category="search",
)
)
session.commit()
# Use raw SQL for simpler tables without complex defaults
with engine.begin() as conn:
# Research history
conn.execute(
text("""
INSERT INTO research_history
(id, query, mode, status, created_at, research_meta)
VALUES
('res-001', 'quantum computing', 'deep', 'completed',
datetime('now'), '{"iterations": 5}'),
('res-002', 'machine learning', 'quick', 'completed',
datetime('now'), '{"iterations": 1}')
""")
)
# Task metadata with progress columns (old _run_migrations added these)
conn.execute(
text("""
INSERT INTO task_metadata
(task_id, status, task_type, progress_current, progress_total,
progress_message)
VALUES
('task-001', 'completed', 'research', 5, 5,
'Done'),
('task-002', 'running', 'research', 2, 10,
'Iteration 2/10')
""")
)
yield engine
engine.dispose()
def test_full_pre_alembic_db_upgrades_cleanly(self, pre_alembic_db_full):
"""A complete pre-Alembic database upgrades without errors."""
engine = pre_alembic_db_full
# Verify no alembic_version table yet
inspector = inspect(engine)
assert "alembic_version" not in inspector.get_table_names()
# Run the new initialize_database (which calls run_migrations)
initialize_database(engine)
# Verify Alembic now tracks the database
new_inspector = inspect(engine)
assert "alembic_version" in new_inspector.get_table_names()
# Verify at head revision
assert get_current_revision(engine) == get_head_revision()
assert not needs_migration(engine)
def test_old_db_missing_progress_cols_gets_them_added(
self, pre_alembic_db_old
):
"""
A very old database missing progress columns gets them added
via Alembic migration 0002.
"""
engine = pre_alembic_db_old
# Verify columns are missing
inspector = inspect(engine)
cols = {c["name"] for c in inspector.get_columns("task_metadata")}
assert "progress_current" not in cols
# Run migrations
run_migrations(engine) # raises on failure
# Verify progress columns now exist
new_inspector = inspect(engine)
new_cols = {
c["name"] for c in new_inspector.get_columns("task_metadata")
}
assert "progress_current" in new_cols
assert "progress_total" in new_cols
assert "progress_message" in new_cols
assert "metadata_json" in new_cols
def test_data_survives_full_upgrade_path(self, pre_alembic_db_with_data):
"""
All user data (settings, research history, task metadata)
survives the upgrade from pre-Alembic to Alembic-managed.
"""
engine = pre_alembic_db_with_data
# Run the full upgrade
initialize_database(engine)
# Verify settings preserved
with engine.connect() as conn:
settings = conn.execute(
text("SELECT key, value FROM settings ORDER BY key")
).fetchall()
settings_dict = {row[0]: row[1] for row in settings}
assert settings_dict["llm.provider"] in ("ollama", '"ollama"')
assert settings_dict["search.engine"] in (
"duckduckgo",
'"duckduckgo"',
)
# Verify research history preserved
with engine.connect() as conn:
research = conn.execute(
text(
"SELECT id, query, status FROM research_history ORDER BY id"
)
).fetchall()
assert len(research) == 2
assert research[0][1] == "quantum computing"
assert research[1][1] == "machine learning"
# Verify task metadata preserved (including progress columns)
with engine.connect() as conn:
tasks = conn.execute(
text("""
SELECT task_id, status, progress_current, progress_total,
progress_message
FROM task_metadata ORDER BY task_id
""")
).fetchall()
assert len(tasks) == 2
assert tasks[0][0] == "task-001"
assert tasks[0][2] == 5 # progress_current
assert tasks[0][3] == 5 # progress_total
assert tasks[0][4] == "Done"
assert tasks[1][0] == "task-002"
assert tasks[1][2] == 2
assert tasks[1][4] == "Iteration 2/10"
def test_upgrade_then_reinitialize_is_idempotent(
self, pre_alembic_db_with_data
):
"""
After upgrading a pre-Alembic DB, calling initialize_database
again should be a no-op (idempotent).
"""
engine = pre_alembic_db_with_data
# First upgrade
initialize_database(engine)
rev1 = get_current_revision(engine)
inspector1 = inspect(engine)
tables1 = set(inspector1.get_table_names())
# Count data
with engine.connect() as conn:
settings_count = conn.execute(
text("SELECT COUNT(*) FROM settings")
).fetchone()[0]
research_count = conn.execute(
text("SELECT COUNT(*) FROM research_history")
).fetchone()[0]
# Second initialization (should be no-op)
initialize_database(engine)
rev2 = get_current_revision(engine)
inspector2 = inspect(engine)
tables2 = set(inspector2.get_table_names())
# Verify nothing changed
assert rev1 == rev2
assert tables1 == tables2
with engine.connect() as conn:
assert (
conn.execute(text("SELECT COUNT(*) FROM settings")).fetchone()[
0
]
== settings_count
)
assert (
conn.execute(
text("SELECT COUNT(*) FROM research_history")
).fetchone()[0]
== research_count
)
def test_schema_matches_after_upgrade_vs_fresh(self, tmp_path):
"""
The schema of an upgraded pre-Alembic database should match
the schema of a freshly created Alembic database (minus the
users table which pre-Alembic databases had).
"""
# Create a fresh Alembic database
fresh_path = tmp_path / "fresh.db"
fresh_engine = create_engine(f"sqlite:///{fresh_path}")
run_migrations(fresh_engine)
# Create a pre-Alembic database and upgrade it
legacy_path = tmp_path / "legacy.db"
legacy_engine = create_engine(f"sqlite:///{legacy_path}")
Base.metadata.create_all(legacy_engine, checkfirst=True)
run_migrations(legacy_engine)
try:
fresh_inspector = inspect(fresh_engine)
legacy_inspector = inspect(legacy_engine)
fresh_tables = set(fresh_inspector.get_table_names())
legacy_tables = set(legacy_inspector.get_table_names())
# Legacy DB may have 'users' table (pre-Alembic created all tables)
# Fresh Alembic DB skips 'users' (auth-only)
legacy_tables.discard("users")
assert fresh_tables == legacy_tables, (
f"Table mismatch.\n"
f" Only in fresh: {fresh_tables - legacy_tables}\n"
f" Only in legacy: {legacy_tables - fresh_tables}"
)
# Compare columns for each shared table (excluding alembic_version)
for table_name in fresh_tables - {"alembic_version"}:
fresh_cols = {
c["name"] for c in fresh_inspector.get_columns(table_name)
}
legacy_cols = {
c["name"] for c in legacy_inspector.get_columns(table_name)
}
assert fresh_cols == legacy_cols, (
f"Column mismatch in table '{table_name}'.\n"
f" Only in fresh: {fresh_cols - legacy_cols}\n"
f" Only in legacy: {legacy_cols - fresh_cols}"
)
finally:
fresh_engine.dispose()
legacy_engine.dispose()
def test_users_table_not_in_fresh_alembic_db(self, tmp_path):
"""
Verify that a fresh Alembic-managed database does NOT contain
the 'users' table (it's auth-only and created separately).
"""
db_path = tmp_path / "fresh_no_users.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
run_migrations(engine)
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "users" not in tables
finally:
engine.dispose()
def test_pre_alembic_db_with_users_table_keeps_it(self, tmp_path):
"""
A pre-Alembic database that has a 'users' table (from the old
create_all) should keep it after migration - Alembic doesn't
drop tables it doesn't manage.
"""
db_path = tmp_path / "legacy_with_users.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Old behavior: create_all creates everything including users
Base.metadata.create_all(engine, checkfirst=True)
inspector = inspect(engine)
assert "users" in inspector.get_table_names()
# Upgrade with Alembic
run_migrations(engine)
# users table should still be there (not dropped)
new_inspector = inspect(engine)
assert "users" in new_inspector.get_table_names()
finally:
engine.dispose()
def test_multiple_sequential_upgrades(self, tmp_path):
"""
Simulate the lifecycle: create DB → upgrade to 0001 → insert data
→ upgrade to 0002 → verify data + new columns.
"""
db_path = tmp_path / "sequential.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Step 1: Migrate to 0001 only
run_migrations(engine, target="0001") # raises on failure
assert get_current_revision(engine) == "0001"
# Step 2: Insert data
with engine.begin() as conn:
conn.execute(
text("""
INSERT INTO task_metadata (task_id, status, task_type)
VALUES ('seq-001', 'running', 'research'),
('seq-002', 'completed', 'research')
""")
)
# Use ORM for settings (many NOT NULL columns with Python defaults)
from local_deep_research.database.models import Setting
from local_deep_research.database.models.settings import SettingType
Session = sessionmaker(bind=engine)
with Session() as session:
session.add(
Setting(
key="test.key",
value="test_value",
type=SettingType.APP,
name="Test Key",
category="test",
)
)
session.commit()
# Step 3: Upgrade to head (0005)
run_migrations(engine, target="head") # raises on failure
assert get_current_revision(engine) == get_head_revision()
# Step 4: Verify data survived
with engine.connect() as conn:
tasks = conn.execute(
text("SELECT task_id FROM task_metadata ORDER BY task_id")
).fetchall()
assert len(tasks) == 2
assert tasks[0][0] == "seq-001"
settings = conn.execute(
text("SELECT value FROM settings WHERE key = 'test.key'")
).fetchone()
assert settings[0] in ("test_value", '"test_value"')
# Step 5: Verify new columns exist
# Note: existing rows get NULL for new columns (SQLite batch alter
# adds columns but doesn't backfill; server_default only applies
# to future inserts)
with engine.connect() as conn:
row = conn.execute(
text("""
SELECT progress_current, progress_total
FROM task_metadata WHERE task_id = 'seq-001'
""")
).fetchone()
assert row is not None # Columns exist and are queryable
# Verify new inserts can use the columns
with engine.begin() as conn:
conn.execute(
text("""
INSERT INTO task_metadata
(task_id, status, task_type, progress_current, progress_total)
VALUES ('seq-003', 'pending', 'research', 0, 5)
""")
)
with engine.connect() as conn:
row = conn.execute(
text("""
SELECT progress_current, progress_total
FROM task_metadata WHERE task_id = 'seq-003'
""")
).fetchone()
assert row[0] == 0
assert row[1] == 5
# Step 6: Another run_migrations should be no-op
run_migrations(engine) # raises on failure
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
class TestUpgradeFromBuggyV16xUserDatabase:
"""Regression for #3697.
Pre-fix ``encrypted_db.create_user_database`` only emitted ``CreateTable``,
never ``CreateIndex``. Combined with v1.6.0 enabling ``PRAGMA
foreign_keys = ON``, every existing user DB has a mismatched FK
(``download_attempts.url_hash`` → ``download_tracker.url_hash`` with no
UNIQUE backing). On any DML touching those tables SQLite raises
``foreign key mismatch``, which would abort migration 0007 itself before
it could repair the schema. Migration 0007 must therefore disable FK
enforcement around its scrub + UNIQUE-index creation.
"""
@pytest.fixture
def buggy_v16x_engine(self, tmp_path):
"""Build a DB that mirrors the pre-fix state of an existing user:
tables exist, but ``download_tracker.url_hash`` has no UNIQUE backing,
the alembic_version is at 0006, and FK enforcement is on for every
connection (mirroring ``apply_performance_pragmas``)."""
import sqlite3
from sqlalchemy import event
db_path = tmp_path / "buggy_v16x.db"
raw = sqlite3.connect(db_path)
raw.execute("PRAGMA foreign_keys = OFF")
raw.executescript(
"""
CREATE TABLE alembic_version (version_num VARCHAR(32) PRIMARY KEY);
INSERT INTO alembic_version VALUES ('0006');
CREATE TABLE download_tracker (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
url_hash VARCHAR(64) NOT NULL,
first_resource_id INTEGER,
is_downloaded BOOLEAN,
file_hash VARCHAR(64),
file_path TEXT,
file_name VARCHAR(255),
file_size INTEGER,
is_accessible BOOLEAN,
first_seen TIMESTAMP,
downloaded_at TIMESTAMP,
last_checked TIMESTAMP,
library_document_id INTEGER
);
CREATE TABLE download_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_hash VARCHAR(64) NOT NULL,
attempt_number INTEGER,
succeeded BOOLEAN,
attempted_at TIMESTAMP,
FOREIGN KEY (url_hash) REFERENCES download_tracker(url_hash)
);
CREATE TABLE download_duplicates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_hash VARCHAR(64) NOT NULL,
resource_id INTEGER,
research_id VARCHAR(36),
added_at TIMESTAMP,
FOREIGN KEY (url_hash) REFERENCES download_tracker(url_hash)
);
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,
completed_at TEXT,
duration_seconds INTEGER,
report_path TEXT,
report_content TEXT,
research_meta TEXT,
progress_log TEXT,
progress INTEGER,
title TEXT
);
"""
)
# Seed: duplicate parent rows (HASH1) + valid child + orphan child.
raw.execute(
"INSERT INTO download_tracker (id, url, url_hash, first_resource_id) "
"VALUES (1, 'a', 'HASH1', 1), (2, 'a-dup', 'HASH1', 2), (3, 'b', 'HASH2', 3)"
)
raw.execute(
"INSERT INTO download_attempts (url_hash, attempt_number) "
"VALUES ('HASH1', 1), ('HASH_ORPHAN', 1)"
)
raw.execute(
"INSERT INTO download_duplicates (url_hash, resource_id, research_id) "
"VALUES ('HASH_ORPHAN', 99, 'r1')"
)
raw.commit()
raw.close()
engine = create_engine(f"sqlite:///{db_path}")
@event.listens_for(engine, "connect")
def _enable_fk(dbapi_conn, _):
dbapi_conn.execute("PRAGMA foreign_keys = ON")
yield engine
engine.dispose()
def test_migration_0007_succeeds_against_pre_fix_user_db(
self, buggy_v16x_engine
):
"""Without the FK toggle in 0007's upgrade(), the duplicate scrub
raises ``foreign key mismatch`` and the migration aborts at 0006."""
run_migrations(buggy_v16x_engine)
# 0008 rides along on the same chain — assert head, not a literal.
assert get_current_revision(buggy_v16x_engine) == get_head_revision()
with buggy_v16x_engine.connect() as conn:
assert conn.execute(text("PRAGMA foreign_keys")).scalar() == 1
assert (
conn.execute(text("PRAGMA foreign_key_check")).fetchall() == []
)
indexes = {
row[1]
for row in conn.execute(
text("PRAGMA index_list(download_tracker)")
).fetchall()
}
assert "uq_download_tracker_url_hash" in indexes
tracker_ids = sorted(
r[0]
for r in conn.execute(
text("SELECT id FROM download_tracker")
).fetchall()
)
# Survivor of HASH1 group is min(id)=1; HASH2 unaffected.
assert tracker_ids == [1, 3]
attempt_hashes = {
r[0]
for r in conn.execute(
text("SELECT url_hash FROM download_attempts")
).fetchall()
}
assert attempt_hashes == {"HASH1"} # orphan removed
duplicate_count = conn.execute(
text("SELECT COUNT(*) FROM download_duplicates")
).scalar()
assert duplicate_count == 0 # orphan removed
def test_fk_enforcement_active_after_migration(self, buggy_v16x_engine):
"""Once 0007 finishes, the repaired FK must actually reject inserts
with a non-existent ``url_hash`` — proving FK was re-enabled and the
UNIQUE backing is recognized."""
from sqlalchemy.exc import IntegrityError
run_migrations(buggy_v16x_engine)
with buggy_v16x_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO download_attempts (url_hash, attempt_number) "
"VALUES ('HASH1', 2)"
)
)
with pytest.raises(IntegrityError):
with buggy_v16x_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO download_attempts (url_hash, attempt_number) "
"VALUES ('NEW_ORPHAN', 1)"
)
)
class TestUpgradeFromBuggyV16xUserDbProductionEngine:
"""Regression for #3990 — multi-migration upgrade fails with FK mismatch.
The existing ``TestUpgradeFromBuggyV16xUserDatabase`` covers the same
schema corruption but stamps at revision 0006, so migration 0007 is the
first to run any DML in the upgrade transaction — which lets 0007's
own ``PRAGMA foreign_keys = OFF`` actually take effect (no auto-begun
transaction yet).
Real production users (issue #3990) upgrade from 0001 to head. Migrations
00020006 run DML before 0007, auto-beginning the sqlite3 driver
transaction. ``PRAGMA foreign_keys`` is silently a no-op once a
transaction is active (per sqlite.org/pragma.html#pragma_foreign_keys),
so 0007's defensive PRAGMA never lands and the orphan-scrub DELETE
fails with ``foreign key mismatch``.
The fix (in ``alembic_runner.run_migrations``) issues PRAGMA OFF
*before* opening the migration transaction. This test reproduces the
production failure exactly: ``isolation_level=""`` (matching the
sqlcipher3 engine in ``encrypted_db.py``) + FK ON at connect via the
same event handler ``apply_performance_pragmas`` installs.
"""
@pytest.fixture
def buggy_v16x_production_engine(self, tmp_path):
"""Mirror the production engine: isolation_level="" + FK ON at
connect, with the buggy v1.6.x schema stamped at revision 0005.
Stamping at 0005 (not 0006) means migration 0006's data backfill
(``UPDATE journals SET name_lower = ...``) runs DML before 0007,
auto-beginning the driver transaction and freezing FK in the
connect-time ON state for the rest of the upgrade.
"""
import sqlite3
from sqlalchemy import event
db_path = tmp_path / "buggy_v16x_prod.db"
# Seed the buggy v1.6.x schema with FK off (raw connection, no
# FK target validation needed — the schema deliberately reflects
# the pre-fix shape with no UNIQUE backing on download_tracker.url_hash).
raw = sqlite3.connect(db_path)
raw.execute("PRAGMA foreign_keys = OFF")
raw.executescript(
"""
CREATE TABLE alembic_version (version_num VARCHAR(32) PRIMARY KEY);
INSERT INTO alembic_version VALUES ('0005');
CREATE TABLE download_tracker (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
url_hash VARCHAR(64) NOT NULL,
first_resource_id INTEGER,
is_downloaded BOOLEAN,
file_hash VARCHAR(64),
file_path TEXT,
file_name VARCHAR(255),
file_size INTEGER,
is_accessible BOOLEAN,
first_seen TIMESTAMP,
downloaded_at TIMESTAMP,
last_checked TIMESTAMP,
library_document_id INTEGER
);
CREATE TABLE download_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_hash VARCHAR(64) NOT NULL,
attempt_number INTEGER,
succeeded BOOLEAN,
attempted_at TIMESTAMP,
FOREIGN KEY (url_hash) REFERENCES download_tracker(url_hash)
);
CREATE TABLE download_duplicates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_hash VARCHAR(64) NOT NULL,
resource_id INTEGER,
research_id VARCHAR(36),
added_at TIMESTAMP,
FOREIGN KEY (url_hash) REFERENCES download_tracker(url_hash)
);
CREATE TABLE journals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(500) NOT NULL,
quality INTEGER,
quality_analysis_time TIMESTAMP
);
INSERT INTO journals (name, quality) VALUES ('Nature', 100), ('NATURE', 95);
"""
)
raw.execute(
"INSERT INTO download_tracker (id, url, url_hash, first_resource_id) "
"VALUES (1, 'a', 'HASH1', 1), (2, 'a-dup', 'HASH1', 2), (3, 'b', 'HASH2', 3)"
)
raw.execute(
"INSERT INTO download_attempts (url_hash, attempt_number) "
"VALUES ('HASH1', 1), ('HASH_ORPHAN', 1)"
)
raw.execute(
"INSERT INTO download_duplicates (url_hash, resource_id, research_id) "
"VALUES ('HASH_ORPHAN', 99, 'r1')"
)
raw.commit()
raw.close()
# Production-shape engine: deferred isolation_level + FK ON at connect.
def _create_conn():
conn = sqlite3.connect(
str(db_path),
isolation_level="",
check_same_thread=False,
)
conn.execute("PRAGMA foreign_keys = ON")
return conn
engine = create_engine("sqlite://", creator=_create_conn)
@event.listens_for(engine, "connect")
def _enable_fk(dbapi_conn, _):
# Defensive: also fire on any pooled re-checkouts that bypass
# the creator (matches encrypted_db's apply_performance_pragmas).
dbapi_conn.execute("PRAGMA foreign_keys = ON")
yield engine
engine.dispose()
def test_run_migrations_succeeds_through_full_chain(
self, buggy_v16x_production_engine
):
"""Without the runner-level FK toggle, this fails with
``foreign key mismatch`` at 0007's orphan scrub."""
run_migrations(buggy_v16x_production_engine)
assert (
get_current_revision(buggy_v16x_production_engine)
== get_head_revision()
)
with buggy_v16x_production_engine.connect() as conn:
# FK is back ON for the next checkout (engine was disposed).
assert conn.execute(text("PRAGMA foreign_keys")).scalar() == 1
# No FK violations remain in the repaired DB.
assert (
conn.execute(text("PRAGMA foreign_key_check")).fetchall() == []
)
# Orphan rows were scrubbed.
attempt_hashes = {
r[0]
for r in conn.execute(
text("SELECT url_hash FROM download_attempts")
).fetchall()
}
assert attempt_hashes == {"HASH1"}
duplicate_count = conn.execute(
text("SELECT COUNT(*) FROM download_duplicates")
).scalar()
assert duplicate_count == 0
class TestOrphanAlembicTempTableCleanup:
"""Regression for #3817 — ``table _alembic_tmp_journals already exists``.
``op.batch_alter_table`` rebuilds a table by creating
``_alembic_tmp_<table>``, copying data, dropping the original, and
renaming. On a clean run alembic drops the temp table automatically.
If a previous attempt failed in a way that bypassed transaction
rollback (e.g., an older runner that auto-committed each migration),
the temp table persists. The next ``batch_alter_table`` on the same
parent fails with ``table _alembic_tmp_* already exists`` — even if
the broader transaction would roll it back, alembic checks for
pre-existing temp tables before creating its own.
The fix drops orphan ``_alembic_tmp_*`` tables in
``alembic_runner.run_migrations`` before opening the migration
transaction.
"""
@pytest.fixture
def db_with_orphan_temp_table(self, tmp_path):
"""Build a buggy v1.6.x DB at revision 0005 with an orphan
``_alembic_tmp_journals`` table left over from a prior crash."""
import sqlite3
db_path = tmp_path / "with_orphan_tmp.db"
raw = sqlite3.connect(db_path)
raw.execute("PRAGMA foreign_keys = OFF")
raw.executescript(
"""
CREATE TABLE alembic_version (version_num VARCHAR(32) PRIMARY KEY);
INSERT INTO alembic_version VALUES ('0005');
CREATE TABLE download_tracker (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT, url_hash VARCHAR(64) NOT NULL,
first_resource_id INTEGER, is_downloaded BOOLEAN,
file_hash VARCHAR(64), file_path TEXT, file_name VARCHAR(255),
file_size INTEGER, is_accessible BOOLEAN,
first_seen TIMESTAMP, downloaded_at TIMESTAMP,
last_checked TIMESTAMP, library_document_id INTEGER
);
CREATE TABLE download_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_hash VARCHAR(64) NOT NULL,
attempt_number INTEGER, succeeded BOOLEAN, attempted_at TIMESTAMP,
FOREIGN KEY (url_hash) REFERENCES download_tracker(url_hash)
);
CREATE TABLE download_duplicates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_hash VARCHAR(64) NOT NULL,
resource_id INTEGER, research_id VARCHAR(36), added_at TIMESTAMP,
FOREIGN KEY (url_hash) REFERENCES download_tracker(url_hash)
);
CREATE TABLE journals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(500) NOT NULL,
quality INTEGER,
quality_analysis_time TIMESTAMP
);
-- The smoking gun: a leftover batch_alter_table temp table
-- from a prior crashed migration attempt. The schema can be
-- arbitrary — alembic only checks the name.
CREATE TABLE _alembic_tmp_journals (
id INTEGER PRIMARY KEY,
stale_marker TEXT
);
"""
)
raw.commit()
raw.close()
def _create_conn():
conn = sqlite3.connect(
str(db_path), isolation_level="", check_same_thread=False
)
conn.execute("PRAGMA foreign_keys = ON")
return conn
engine = create_engine("sqlite://", creator=_create_conn)
yield engine
engine.dispose()
def test_run_migrations_drops_orphan_temp_table(
self, db_with_orphan_temp_table
):
"""Without the cleanup, migration 0006's batch_alter_table fails
with ``table _alembic_tmp_journals already exists``."""
# Sanity check: the orphan is present before the run.
with db_with_orphan_temp_table.connect() as conn:
tables = {
r[0]
for r in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
}
assert "_alembic_tmp_journals" in tables
run_migrations(db_with_orphan_temp_table)
assert (
get_current_revision(db_with_orphan_temp_table)
== get_head_revision()
)
# The orphan was dropped; alembic's own temp tables (if any from
# this run) were also cleaned up by alembic itself.
with db_with_orphan_temp_table.connect() as conn:
tables_after = {
r[0]
for r in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
}
assert not any(t.startswith("_alembic_tmp_") for t in tables_after)
@pytest.fixture
def db_with_multiple_orphan_temp_tables(self, tmp_path):
"""Buggy v1.6.x DB at revision 0005 with THREE orphan
``_alembic_tmp_*`` tables — exercises the loop in
``_drop_orphan_alembic_temp_tables`` past index 0. A regression
that replaces the iteration body with a single ``break`` (or
otherwise short-circuits) would leave the trailing orphans
behind and fail this test, while ``test_run_migrations_drops_orphan_temp_table``
with its single orphan would still pass."""
import sqlite3
db_path = tmp_path / "with_multi_orphans.db"
raw = sqlite3.connect(db_path)
raw.execute("PRAGMA foreign_keys = OFF")
raw.executescript(
"""
CREATE TABLE alembic_version (version_num VARCHAR(32) PRIMARY KEY);
INSERT INTO alembic_version VALUES ('0005');
CREATE TABLE download_tracker (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT, url_hash VARCHAR(64) NOT NULL,
first_resource_id INTEGER, is_downloaded BOOLEAN,
file_hash VARCHAR(64), file_path TEXT, file_name VARCHAR(255),
file_size INTEGER, is_accessible BOOLEAN,
first_seen TIMESTAMP, downloaded_at TIMESTAMP,
last_checked TIMESTAMP, library_document_id INTEGER
);
CREATE TABLE download_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_hash VARCHAR(64) NOT NULL,
attempt_number INTEGER, succeeded BOOLEAN, attempted_at TIMESTAMP,
FOREIGN KEY (url_hash) REFERENCES download_tracker(url_hash)
);
CREATE TABLE download_duplicates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_hash VARCHAR(64) NOT NULL,
resource_id INTEGER, research_id VARCHAR(36), added_at TIMESTAMP,
FOREIGN KEY (url_hash) REFERENCES download_tracker(url_hash)
);
CREATE TABLE journals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(500) NOT NULL,
quality INTEGER,
quality_analysis_time TIMESTAMP
);
CREATE TABLE _alembic_tmp_journals (
id INTEGER PRIMARY KEY, stale_marker TEXT
);
CREATE TABLE _alembic_tmp_research_history (
id INTEGER PRIMARY KEY, stale_marker TEXT
);
CREATE TABLE _alembic_tmp_settings (
id INTEGER PRIMARY KEY, stale_marker TEXT
);
"""
)
raw.commit()
raw.close()
def _create_conn():
conn = sqlite3.connect(
str(db_path), isolation_level="", check_same_thread=False
)
conn.execute("PRAGMA foreign_keys = ON")
return conn
engine = create_engine("sqlite://", creator=_create_conn)
yield engine
engine.dispose()
def test_run_migrations_drops_multiple_orphan_temp_tables(
self, db_with_multiple_orphan_temp_tables
):
"""The cleanup must process every match — not just the first."""
with db_with_multiple_orphan_temp_tables.connect() as conn:
seeded = {
r[0]
for r in conn.execute(
text(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name LIKE '_alembic_tmp_%'"
)
).fetchall()
}
assert seeded == {
"_alembic_tmp_journals",
"_alembic_tmp_research_history",
"_alembic_tmp_settings",
}
run_migrations(db_with_multiple_orphan_temp_tables)
assert (
get_current_revision(db_with_multiple_orphan_temp_tables)
== get_head_revision()
)
with db_with_multiple_orphan_temp_tables.connect() as conn:
remaining = [
r[0]
for r in conn.execute(
text(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name LIKE '_alembic_tmp_%'"
)
).fetchall()
]
assert remaining == []
def test_drop_orphan_temp_tables_no_op_when_none_present(
self, tmp_path, loguru_caplog
):
"""Unit test on the cleanup helper itself: when the DB has no
``_alembic_tmp_*`` tables, the function must return without
DDL and without emitting the ``Found N orphan alembic temp
table(s)`` warning. This pins the early-return guard at the
top of ``_drop_orphan_alembic_temp_tables`` so a future
refactor that drops the guard (e.g. unconditional logging)
would be caught immediately."""
from local_deep_research.database.alembic_runner import (
_drop_orphan_alembic_temp_tables,
)
db_path = tmp_path / "clean.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
with engine.connect() as conn:
conn.exec_driver_sql(
"CREATE TABLE real_table (id INTEGER PRIMARY KEY)"
)
with loguru_caplog.at_level("WARNING"):
loguru_caplog.clear()
_drop_orphan_alembic_temp_tables(conn)
assert (
"orphan alembic temp table"
not in loguru_caplog.text.lower()
)
# The real table is still there — we didn't touch anything.
tables = {
r[0]
for r in conn.execute(
text(
"SELECT name FROM sqlite_master WHERE type='table'"
)
).fetchall()
}
assert "real_table" in tables
finally:
engine.dispose()
class TestPreAlembicDatabaseHotfix:
"""Regression tests for bug #3747 — pre-Alembic DB login failure.
Real users whose database was created before v1.4.0 (2026-03-25, when
Alembic was introduced) have schema tables but no alembic_version row.
Before this hotfix, run_migrations() called command.upgrade() from
scratch, exposing legacy column shapes to migration 0007's index
backfill and `download_tracker` scrub.
The fixture uses raw-SQL legacy schema (NOT Base.metadata.create_all)
so it reflects what a real pre-2026-03-21 user database actually looks
like: `settings` omits the modern `category` column, `download_tracker`
has the legacy shape without a UNIQUE constraint on `url_hash`, etc.
"""
@pytest.fixture
def pre_alembic_engine(self, tmp_path):
engine = create_engine(f"sqlite:///{tmp_path}/pre_alembic.db")
with engine.begin() as conn:
# Legacy `settings` shape (no `category`, no `description`,
# no `ui_element`, no enum extras — just the bare columns
# that existed at project inception, 2025-06-29).
conn.execute(
text(
"""
CREATE TABLE settings (
id INTEGER PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
value JSON,
type VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
visible BOOLEAN DEFAULT 1 NOT NULL,
editable BOOLEAN DEFAULT 1 NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE research_history (
id VARCHAR(36) PRIMARY KEY,
query TEXT NOT NULL,
mode VARCHAR(50),
status VARCHAR(50),
created_at DATETIME
)
"""
)
)
# Legacy `download_tracker` shape: url_hash without the
# UNIQUE backing that migration 0007 expects to find. This
# is the table that the unfixed migration path actually
# trips over via 0007's index backfill / orphan scrub.
conn.execute(
text(
"""
CREATE TABLE download_tracker (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL,
url_hash VARCHAR(64) NOT NULL,
first_resource_id INTEGER,
is_downloaded BOOLEAN DEFAULT 0
)
"""
)
)
# Sanity: fixture really is pre-Alembic-shaped.
inspector = inspect(engine)
assert "alembic_version" not in inspector.get_table_names()
assert "category" not in {
c["name"] for c in inspector.get_columns("settings")
}
yield engine
engine.dispose()
def test_pre_alembic_db_reaches_head_and_stamp_branch_engaged(
self, pre_alembic_engine, loguru_caplog
):
"""Core regression: pre-Alembic DB → run_migrations() → head, AND
the BUG-3747 stamp branch is what got it there (not a coincidence)."""
with loguru_caplog.at_level("WARNING"):
run_migrations(pre_alembic_engine)
assert get_current_revision(pre_alembic_engine) == get_head_revision()
assert (
"alembic_version" in inspect(pre_alembic_engine).get_table_names()
)
# Without the hotfix this log line never appears — its presence
# proves we got to head via the stamp path, not by accident.
assert "BUG-3747: pre-Alembic database detected" in loguru_caplog.text
def test_pre_alembic_migration_is_idempotent(self, pre_alembic_engine):
"""Re-running run_migrations() after the hotfix is a no-op."""
run_migrations(pre_alembic_engine)
rev1 = get_current_revision(pre_alembic_engine)
run_migrations(pre_alembic_engine)
assert get_current_revision(pre_alembic_engine) == rev1
def test_fresh_db_does_not_enter_stamp_branch(
self, tmp_path, loguru_caplog
):
"""An empty DB must run upgrade from 0001, not the stamp branch."""
engine = create_engine(f"sqlite:///{tmp_path}/fresh.db")
try:
with loguru_caplog.at_level("WARNING"):
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
assert "BUG-3747" not in loguru_caplog.text
finally:
engine.dispose()
def test_concurrent_stamp_is_neutralized(self, pre_alembic_engine):
"""stamp_database() must be safe to call twice (race-tolerant)."""
stamp_database(pre_alembic_engine, "0001")
# Second call simulates a racing concurrent caller — must NOT raise.
stamp_database(pre_alembic_engine, "0001")
assert get_current_revision(pre_alembic_engine) == "0001"
def test_unrelated_operational_error_still_propagates(self, tmp_path):
"""The race-tolerance guard must NOT swallow non-race errors."""
from alembic import command
from sqlalchemy.exc import OperationalError
engine = create_engine(f"sqlite:///{tmp_path}/will_error.db")
original_stamp = command.stamp
def _raise_unrelated(*_args, **_kwargs):
raise OperationalError(
"SELECT something", {}, Exception("disk I/O error")
)
try:
command.stamp = _raise_unrelated
with pytest.raises(OperationalError, match="disk I/O error"):
stamp_database(engine, "0001")
finally:
command.stamp = original_stamp
engine.dispose()
def test_auth_db_shape_users_only_is_refused(self, tmp_path):
"""Engine with ONLY a `users` table must be refused (auth DB shape)."""
engine = create_engine(f"sqlite:///{tmp_path}/auth.db")
try:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE users "
"(id INTEGER PRIMARY KEY, username VARCHAR)"
)
)
with pytest.raises(RuntimeError, match="auth database"):
run_migrations(engine)
finally:
engine.dispose()
def test_auth_db_shape_users_plus_alembic_version_is_refused(
self, tmp_path
):
"""Auth DB that's been (mis-)stamped must still be refused."""
engine = create_engine(f"sqlite:///{tmp_path}/auth_stamped.db")
try:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE users "
"(id INTEGER PRIMARY KEY, username VARCHAR)"
)
)
conn.execute(
text(
"CREATE TABLE alembic_version "
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
)
)
with pytest.raises(RuntimeError, match="auth database"):
run_migrations(engine)
finally:
engine.dispose()
def test_pre_alembic_user_db_with_users_table_is_NOT_refused(
self, tmp_path
):
"""Pre-Alembic user DBs contain `users` (created by the old
`Base.metadata.create_all()` path before migration 0001 added
the explicit skip). They must be allowed through, not refused
as auth DBs.
Uses `Base.metadata.create_all()` to simulate the legacy code
path exactly — same approach as the existing pre-Alembic tests
(see `test_pre_alembic_db_with_users_table_keeps_it`).
"""
engine = create_engine(f"sqlite:///{tmp_path}/pre_alembic_user.db")
try:
Base.metadata.create_all(engine, checkfirst=True)
# Sanity: the simulated pre-Alembic DB really does contain
# `users` (the modern 0001 migration would have skipped it).
assert "users" in inspect(engine).get_table_names()
# Must NOT raise as an auth-DB false positive.
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
def test_only_alembic_version_table_is_treated_as_fresh(self, tmp_path):
"""A bare alembic_version (no schema, no users) is OK — runs upgrade."""
engine = create_engine(f"sqlite:///{tmp_path}/bare.db")
try:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE alembic_version "
"(version_num VARCHAR(32) NOT NULL "
"PRIMARY KEY)"
)
)
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
finally:
engine.dispose()
class TestMigrationAppliesSchemaChanges:
"""Tests proving the migration machinery actually changes schema and ORM works.
These tests go beyond table/column name checks to verify:
- Alembic Operations API works on our engine
- Column properties (nullable, defaults) are correct after migration
- ORM CRUD works after the production entry point
- Downgrade/upgrade roundtrip preserves column properties
"""
@pytest.fixture
def migrated_to_head(self, tmp_path):
"""Engine with all migrations applied."""
engine = create_engine(f"sqlite:///{tmp_path / 'head.db'}")
run_migrations(engine, target="head")
yield engine
engine.dispose()
def test_operations_api_can_modify_schema(self, migrated_to_head):
"""Alembic Operations API can create and drop a table on our engine."""
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import Column, Integer, String
with migrated_to_head.connect() as conn:
ctx = MigrationContext.configure(conn)
op = Operations(ctx)
# Before: test table does NOT exist
inspector = inspect(migrated_to_head)
assert "_test_migration_probe" not in inspector.get_table_names()
# Apply: create test table
op.create_table(
"_test_migration_probe",
Column("id", Integer, primary_key=True),
Column("name", String(50)),
)
# After: test table EXISTS with correct columns
inspector = inspect(migrated_to_head)
assert "_test_migration_probe" in inspector.get_table_names()
cols = {
c["name"]
for c in inspector.get_columns("_test_migration_probe")
}
assert cols == {"id", "name"}
# Cleanup: drop it
op.drop_table("_test_migration_probe")
inspector = inspect(migrated_to_head)
assert "_test_migration_probe" not in inspector.get_table_names()
def test_0011_adds_is_public_to_existing_collections(self, tmp_path):
"""Upgrade path: a DB created BEFORE 0011 (collections without
is_public) gains the column on upgrade, and a pre-existing collection
row is backfilled to private (False) via the server_default — existing
users' collections don't become NULL/garbage or accidentally public.
"""
from alembic import command
engine = create_engine(f"sqlite:///{tmp_path / 'pre_0011.db'}")
try:
cfg = get_alembic_config(engine)
# Simulate a pre-0011 DB: a collections table created by an older
# version of the code (WITHOUT is_public), with the alembic
# version stamped at 0010 so only 0011 runs. (The migration chain
# can't reproduce this — 0001 builds from the *current* model,
# which already has is_public — so we hand-create the old schema,
# exactly as the 0002 test does.)
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE collections (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
collection_type VARCHAR(50),
is_default BOOLEAN,
created_at DATETIME,
updated_at DATETIME
)
"""
)
)
conn.execute(
text(
"INSERT INTO collections (id, name) "
"VALUES ('legacy-c1', 'Legacy')"
)
)
with engine.begin() as conn:
cfg.attributes["connection"] = conn
command.stamp(cfg, "0010")
cols = {
c["name"] for c in inspect(engine).get_columns("collections")
}
assert "is_public" not in cols
# Upgrade to head — 0011 adds is_public.
with engine.begin() as conn:
cfg.attributes["connection"] = conn
command.upgrade(cfg, "head")
col_map = {
c["name"]: c for c in inspect(engine).get_columns("collections")
}
assert "is_public" in col_map
assert col_map["is_public"]["nullable"] is True
# The pre-existing row is backfilled to private (falsy).
with engine.connect() as conn:
val = conn.execute(
text(
"SELECT is_public FROM collections WHERE id='legacy-c1'"
)
).scalar()
assert not val, (
f"existing row not private after upgrade: {val!r}"
)
finally:
engine.dispose()
def test_0002_adds_columns_with_properties_to_old_schema(self, tmp_path):
"""Migration 0002 adds progress columns with correct properties to a pre-Alembic schema.
The 0001 migration creates all tables from Base.metadata (which includes
progress columns). The 0002 migration is designed for pre-Alembic databases
where task_metadata was created WITHOUT progress columns. This test
simulates that scenario: stamp at 0001, manually create the old schema
without progress columns, then run 0002 and verify column properties.
"""
engine = create_engine(f"sqlite:///{tmp_path / 'old_schema_props.db'}")
try:
# Create task_metadata WITHOUT progress columns (simulating old schema)
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE task_metadata (
task_id VARCHAR PRIMARY KEY,
status VARCHAR NOT NULL,
task_type VARCHAR NOT NULL,
created_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
error_message VARCHAR,
priority INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3
)
"""
)
)
# Run the full migration — 0001 will create remaining tables,
# and 0002 will add the missing progress columns
run_migrations(engine)
# AFTER: verify columns exist with correct properties
inspector = inspect(engine)
col_map = {
c["name"]: c for c in inspector.get_columns("task_metadata")
}
assert "progress_current" in col_map
assert "progress_total" in col_map
assert "progress_message" in col_map
assert "metadata_json" in col_map
# Verify nullable constraints
assert col_map["progress_message"]["nullable"] is True
assert col_map["metadata_json"]["nullable"] is True
# Verify defaults work via INSERT without specifying progress columns
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO task_metadata (task_id, status, task_type) "
"VALUES ('default-test', 'queued', 'research')"
)
)
row = conn.execute(
text(
"SELECT progress_current, progress_total FROM task_metadata "
"WHERE task_id='default-test'"
)
).fetchone()
assert row[0] == 0 # default
assert row[1] == 0 # default
finally:
engine.dispose()
def test_orm_crud_after_initialize_database(self, tmp_path):
"""ORM CRUD works after initialize_database() — the production entry point."""
from local_deep_research.database.models import TaskMetadata, Setting
from local_deep_research.database.models.settings import SettingType
engine = create_engine(f"sqlite:///{tmp_path / 'crud.db'}")
try:
Session = sessionmaker(bind=engine)
# Production entry point
with Session() as session:
initialize_database(engine, session)
# Verify migrations ran
assert get_current_revision(engine) == get_head_revision()
with Session() as session:
# CREATE — TaskMetadata with 0002 progress columns
task = TaskMetadata(
task_id="orm-test-001",
status="queued",
task_type="research",
progress_current=5,
progress_total=100,
progress_message="Searching...",
metadata_json={"source": "test"},
)
session.add(task)
session.commit()
# READ — verify all fields round-trip
loaded = (
session.query(TaskMetadata)
.filter_by(task_id="orm-test-001")
.first()
)
assert loaded is not None
assert loaded.progress_current == 5
assert loaded.progress_total == 100
assert loaded.progress_message == "Searching..."
assert loaded.metadata_json == {"source": "test"}
# UPDATE
loaded.progress_current = 50
loaded.progress_message = "Halfway"
session.commit()
reloaded = (
session.query(TaskMetadata)
.filter_by(task_id="orm-test-001")
.first()
)
assert reloaded.progress_current == 50
assert reloaded.progress_message == "Halfway"
# CREATE — Setting model (value is JSON type, key must be unique)
setting = Setting(
key="test.migration.orm",
value="works",
type=SettingType.APP,
name="Test Setting",
category="test",
)
session.add(setting)
session.commit()
loaded_setting = (
session.query(Setting)
.filter_by(key="test.migration.orm")
.first()
)
assert loaded_setting.value == "works"
# DELETE
session.delete(reloaded)
session.commit()
assert (
session.query(TaskMetadata)
.filter_by(task_id="orm-test-001")
.first()
is None
)
finally:
engine.dispose()
def test_downgrade_upgrade_roundtrip_verifies_column_properties(
self, tmp_path
):
"""Roundtrip downgrade/upgrade preserves column properties and ORM works.
Starts at 0008 (last fully-reversible) rather than head; 0010
is intentionally non-reversible (SQLite ALTER TABLE limitations).
Re-upgrade to head still verifies forward path through 0010.
"""
from alembic import command
from local_deep_research.database.models import TaskMetadata
migrated_to_head = create_engine(
f"sqlite:///{tmp_path / 'roundtrip_head.db'}"
)
try:
# Upgrade to 0008 (last reversible)
config = get_alembic_config(migrated_to_head)
with migrated_to_head.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
# Insert data at 0008
with migrated_to_head.begin() as conn:
conn.execute(
text(
"INSERT INTO task_metadata (task_id, status, task_type, progress_current) "
"VALUES ('roundtrip', 'queued', 'research', 42)"
)
)
# DOWNGRADE to 0001
with migrated_to_head.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "0001")
# After downgrade: progress columns gone, core data kept
inspector = inspect(migrated_to_head)
cols = {c["name"] for c in inspector.get_columns("task_metadata")}
assert "progress_current" not in cols
assert "task_id" in cols
with migrated_to_head.connect() as conn:
row = conn.execute(
text(
"SELECT task_id FROM task_metadata WHERE task_id='roundtrip'"
)
).fetchone()
assert row is not None
# RE-UPGRADE to head (passes through 0010 in forward direction)
run_migrations(migrated_to_head, target="head")
# After re-upgrade: verify column PROPERTIES (not just names)
inspector = inspect(migrated_to_head)
col_map = {
c["name"]: c for c in inspector.get_columns("task_metadata")
}
assert col_map["progress_message"]["nullable"] is True
assert col_map["metadata_json"]["nullable"] is True
# Verify defaults still work after roundtrip
with migrated_to_head.begin() as conn:
conn.execute(
text(
"INSERT INTO task_metadata (task_id, status, task_type) "
"VALUES ('after-roundtrip', 'queued', 'research')"
)
)
row = conn.execute(
text(
"SELECT progress_current, progress_total FROM task_metadata "
"WHERE task_id='after-roundtrip'"
)
).fetchone()
assert row[0] == 0
assert row[1] == 0
# ORM CRUD works after roundtrip
Session = sessionmaker(bind=migrated_to_head)
with Session() as session:
task = TaskMetadata(
task_id="post-roundtrip",
status="queued",
task_type="research",
progress_current=10,
progress_total=50,
)
session.add(task)
session.commit()
loaded = (
session.query(TaskMetadata)
.filter_by(task_id="post-roundtrip")
.first()
)
assert loaded.progress_current == 10
finally:
migrated_to_head.dispose()
# ---------------------------------------------------------------------------
# Helper for parametrized safety-guard tests
# ---------------------------------------------------------------------------
def _get_revision_chain():
"""Return [(revision_id, down_revision), ...] ordered base → head.
Resolved at import time so ``@pytest.mark.parametrize`` can consume it
during test collection.
"""
from alembic.config import Config
from alembic.script import ScriptDirectory
from local_deep_research.database.alembic_runner import get_migrations_dir
cfg = Config()
cfg.set_main_option("script_location", str(get_migrations_dir()))
script = ScriptDirectory.from_config(cfg)
chain = []
for rev in script.walk_revisions():
down = rev.down_revision if rev.down_revision else "base"
chain.append((rev.revision, down))
chain.reverse() # walk_revisions yields head-first
return chain
_REVISION_CHAIN = _get_revision_chain()
def _get_migration_stems():
"""Return migration file stems for parametrized import tests.
Resolved at import time so ``@pytest.mark.parametrize`` can consume it.
"""
from local_deep_research.database.alembic_runner import get_migrations_dir
versions_dir = get_migrations_dir() / "versions"
return [p.stem for p in sorted(versions_dir.glob("[0-9]*.py"))]
_MIGRATION_STEMS = _get_migration_stems()
class TestNoCircularFkSawarning:
"""Regression: the documents ↔ research_resources circular FK used to
emit a SAWarning from Base.metadata.sorted_tables on every cold start.
Fixed by adding `use_alter=True` to ResearchResource.document_id so
SQLAlchemy emits that one FK as a post-CREATE ALTER TABLE (breaking
the dependency cycle for sorting purposes) while still creating the
constraint at the database level.
"""
def test_create_all_emits_no_circular_fk_warning(self, tmp_path):
import warnings as _warnings
engine = create_engine(f"sqlite:///{tmp_path}/test.db")
try:
with _warnings.catch_warnings(record=True) as caught:
_warnings.simplefilter("always")
Base.metadata.create_all(engine)
offending = [
w
for w in caught
if "unresolvable cycles" in str(w.message)
and "documents" in str(w.message)
and "research_resources" in str(w.message)
]
assert not offending, (
f"create_all() emitted the circular-FK SAWarning: "
f"{[str(w.message) for w in offending]}"
)
finally:
engine.dispose()
class TestMigrationSafetyGuards:
"""Guards that catch common migration pitfalls.
These are *structural* checks — they don't test application logic, they
prevent forgotten migrations, broken downgrades, branch conflicts, and
orphaned models.
"""
# -- fixtures -----------------------------------------------------------
@pytest.fixture
def fresh_engine(self, tmp_path):
"""Disposable in-memory style SQLite engine (file-backed for inspect)."""
db_path = tmp_path / "guard_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
@pytest.fixture
def alembic_cfg(self, fresh_engine):
"""Alembic Config wired to *fresh_engine*."""
return get_alembic_config(fresh_engine)
# -- 1. schema drift ----------------------------------------------------
def test_migrations_produce_schema_matching_models(
self, fresh_engine, alembic_cfg
):
"""After upgrading to head the DB schema must match ORM metadata.
Diffs that are *expected* and filtered out:
* ``add_table('users')`` — the ``users`` table lives in Base.metadata
but 0001 deliberately skips it (auth-only DB).
* ``remove_index`` for the 9 performance indexes added by 0003 —
those indexes are migration-only and intentionally absent from ORM
model declarations.
"""
from alembic import command
from alembic.autogenerate import compare_metadata
from alembic.runtime.migration import MigrationContext
# Run all migrations on a clean DB
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
command.upgrade(alembic_cfg, "head")
# Diff the resulting schema against ORM metadata
with fresh_engine.connect() as conn:
mc = MigrationContext.configure(conn)
raw_diffs = compare_metadata(mc, Base.metadata)
# Filter expected diffs
unexpected = []
for diff in raw_diffs:
op_type = diff[0]
# users table is intentionally excluded from migrations
if op_type == "add_table" and diff[1].name == "users":
continue
# Index on users table (comes with the table definition)
if op_type == "add_index" and diff[1].table.name == "users":
continue
# 0003 migration-only indexes show as "remove_index"
if op_type == "remove_index":
continue
unexpected.append(diff)
assert unexpected == [], (
f"Schema drift detected — {len(unexpected)} diff(s) between "
f"migrations and models:\n" + "\n".join(str(d) for d in unexpected)
)
# -- 2. single head -----------------------------------------------------
def test_single_head_revision(self):
"""There must be exactly one head revision (no branch conflicts)."""
from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
cfg = AlembicConfig()
cfg.set_main_option("script_location", str(get_migrations_dir()))
script = ScriptDirectory.from_config(cfg)
heads = script.get_heads()
assert len(heads) == 1, (
f"Expected exactly 1 head revision, found {len(heads)}: {heads}. "
"This means two migrations share the same parent — "
"resolve with `alembic merge`."
)
# -- 3. stairway (up-down-up per revision) ------------------------------
@pytest.mark.parametrize(
"revision,down_rev",
_REVISION_CHAIN,
ids=[r[0] for r in _REVISION_CHAIN],
)
def test_stairway_up_down_up_per_revision(
self, fresh_engine, alembic_cfg, revision, down_rev
):
"""Each revision must survive: parent → up → down → up."""
from alembic import command
if revision in self.NON_REVERSIBLE_REVISIONS:
pytest.skip(
f"Revision {revision} is intentionally non-reversible "
"(SQLite ALTER TABLE limitations); see NON_REVERSIBLE_REVISIONS."
)
target_down = down_rev if down_rev != "base" else "base"
expected_after_down = None if down_rev == "base" else down_rev
# Upgrade to parent first (unless this is the base migration)
if down_rev != "base":
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
command.upgrade(alembic_cfg, down_rev)
# Up
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
command.upgrade(alembic_cfg, revision)
assert get_current_revision(fresh_engine) == revision
# Down
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
command.downgrade(alembic_cfg, target_down)
assert get_current_revision(fresh_engine) == expected_after_down
# Up again
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
command.upgrade(alembic_cfg, revision)
assert get_current_revision(fresh_engine) == revision
# -- 4. substantive downgrades ------------------------------------------
# Migrations whose downgrade is intentionally a no-op (e.g. one-time
# data migrations that delete stale keys with no consumers).
INTENTIONAL_NOOP_DOWNGRADES = {
"0004_migrate_legacy_app_settings.py",
# 0013: restoring 'auto'/'parallel' references would recreate
# broken state — those engines no longer exist in the codebase.
"0013_remove_meta_search_engines.py",
# 0016: the dropped cache tables were orphaned dead code holding no
# data, and their models are removed — nothing to recreate.
"0016_drop_orphaned_cache_tables.py",
# 0018: restoring 'mcp'/'agentic' strategy references would recreate
# broken state — that strategy no longer exists in the codebase.
"0018_remove_mcp_strategy.py",
# 0019: the 'both' -> 'adaptive' coercion is lossy — after upgrade a
# row is indistinguishable from an originally-'adaptive' one, so a
# downgrade cannot know which rows to restore. Rewriting 'adaptive'
# back to 'both' would clobber the legitimate default. No-op by design.
"0019_retire_both_egress_scope.py",
}
# Migrations whose downgrade is intentionally NotImplementedError
# (e.g. SQLite ALTER TABLE limitations against legacy unnamed
# constraints + FK-target columns). These revisions are exempt from
# parametrized stairway/residual tests and tested separately.
NON_REVERSIBLE_REVISIONS = {
"0010", # 0010_add_chat_tables.py — chat schema, dev-stage rollback path: recreate DB
}
def test_all_downgrades_are_substantive(self):
"""Every migration's ``downgrade()`` must contain real operations.
A ``pass``-only or empty downgrade silently blocks rollback.
Migrations listed in INTENTIONAL_NOOP_DOWNGRADES are exempt.
"""
import ast
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
versions_dir = get_migrations_dir() / "versions"
for py_file in sorted(versions_dir.glob("[0-9]*.py")):
if py_file.name in self.INTENTIONAL_NOOP_DOWNGRADES:
continue
tree = ast.parse(py_file.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
if node.name != "downgrade":
continue
# Strip docstrings — a single Expr(Constant(str)) at position 0
body = list(node.body)
if (
body
and isinstance(body[0], ast.Expr)
and isinstance(body[0].value, ast.Constant)
and isinstance(body[0].value.value, str)
):
body = body[1:]
# Must have at least one non-pass statement
non_pass = [s for s in body if not isinstance(s, ast.Pass)]
assert non_pass, (
f"{py_file.name}: downgrade() is empty or pass-only — "
"this migration cannot be rolled back."
)
# -- 5. all models registered on metadata -------------------------------
def test_all_models_registered_on_metadata(self):
"""Every model submodule must be imported so Alembic can see its tables."""
import importlib
import pkgutil
import local_deep_research.database.models as models_pkg
# Force-import every submodule under the models package
for _importer, modname, _ispkg in pkgutil.walk_packages(
models_pkg.__path__,
prefix=models_pkg.__name__ + ".",
):
importlib.import_module(modname)
# Critical tables that must exist in metadata
critical_tables = {
"settings",
"research_tasks",
"research_history",
"task_metadata",
"queue_status",
"benchmark_runs",
"benchmark_results",
"benchmark_configs",
"benchmark_progress",
"token_usage",
"search_calls",
"reports",
"report_sections",
"app_logs",
"provider_models",
"documents",
"collections",
}
registered = set(Base.metadata.tables.keys())
missing = critical_tables - registered
assert not missing, (
f"Tables missing from Base.metadata (model not imported?): "
f"{sorted(missing)}"
)
# -- 6. downgrade leaves no residual tables ----------------------------
@pytest.mark.parametrize(
"revision,down_rev",
_REVISION_CHAIN,
ids=[r[0] for r in _REVISION_CHAIN],
)
def test_downgrade_leaves_no_residual_tables(
self, fresh_engine, alembic_cfg, revision, down_rev
):
"""Downgrading a revision must not leave behind tables it created."""
from alembic import command
if revision in self.NON_REVERSIBLE_REVISIONS:
pytest.skip(
f"Revision {revision} is intentionally non-reversible "
"(SQLite ALTER TABLE limitations); see NON_REVERSIBLE_REVISIONS."
)
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
# Upgrade to parent first
if down_rev != "base":
command.upgrade(alembic_cfg, down_rev)
tables_before = set(inspect(fresh_engine).get_table_names())
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
# Upgrade to this revision
command.upgrade(alembic_cfg, revision)
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
# Downgrade back
target_down = down_rev if down_rev != "base" else "base"
command.downgrade(alembic_cfg, target_down)
tables_after = set(inspect(fresh_engine).get_table_names())
# alembic_version may appear/disappear — that's fine
residual = (tables_after - tables_before) - {"alembic_version"}
assert not residual, (
f"Revision {revision}: downgrade left residual tables: "
f"{sorted(residual)}"
)
# -- 7. deterministic schema --------------------------------------------
def test_deterministic_schema(self, tmp_path):
"""Two independent fresh databases must produce identical schemas.
Catches migrations that use timestamps, random values, or
environment-dependent conditional logic.
"""
from alembic import command
schemas = []
for i in range(2):
db_path = tmp_path / f"deterministic_{i}.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
cfg = get_alembic_config(engine)
with engine.begin() as conn:
cfg.attributes["connection"] = conn
command.upgrade(cfg, "head")
insp = inspect(engine)
schema = {}
for table_name in sorted(insp.get_table_names()):
cols = [
(c["name"], str(c["type"]))
for c in insp.get_columns(table_name)
]
idxs = sorted(
[
(idx["name"], tuple(idx["column_names"]))
for idx in insp.get_indexes(table_name)
]
)
schema[table_name] = {"columns": cols, "indexes": idxs}
schemas.append(schema)
finally:
engine.dispose()
assert schemas[0] == schemas[1], (
"Two fresh databases produced different schemas — "
"a migration is non-deterministic."
)
# -- 8. each migration file is importable -------------------------------
@pytest.mark.parametrize("stem", _MIGRATION_STEMS)
def test_each_migration_revision_is_importable(self, stem):
"""Every migration file must import without SyntaxError or ImportError."""
import importlib
mod = importlib.import_module(
f"local_deep_research.database.migrations.versions.{stem}"
)
# Sanity: the module must expose upgrade/downgrade callables
assert callable(getattr(mod, "upgrade", None)), (
f"{stem}: missing upgrade()"
)
assert callable(getattr(mod, "downgrade", None)), (
f"{stem}: missing downgrade()"
)
# -- 9. downgrade data loss is explicit (0002) --------------------------
def test_downgrade_data_loss_is_explicit_0002(
self, fresh_engine, alembic_cfg
):
"""0002's downgrade drops progress columns; re-upgrade gets defaults, not old data."""
from alembic import command
# Migrate to 0002
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
command.upgrade(alembic_cfg, "0002")
# Insert data into the progress columns
with fresh_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO task_metadata "
"(task_id, task_type, status, progress_current, progress_total, progress_message) "
"VALUES (:tid, :tt, :st, :pc, :pt, :pm)"
),
{
"tid": "test-task-42",
"tt": "research",
"st": "running",
"pc": 7,
"pt": 10,
"pm": "Step 7 of 10",
},
)
# Downgrade to 0001
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
command.downgrade(alembic_cfg, "0001")
# Verify progress columns are gone
cols = {
c["name"]
for c in inspect(fresh_engine).get_columns("task_metadata")
}
assert "progress_current" not in cols
assert "progress_total" not in cols
assert "progress_message" not in cols
# Re-upgrade to 0002
with fresh_engine.begin() as conn:
alembic_cfg.attributes["connection"] = conn
command.upgrade(alembic_cfg, "0002")
# Columns are back with defaults, old data is gone
with fresh_engine.connect() as conn:
row = conn.execute(
text(
"SELECT progress_current, progress_total, progress_message "
"FROM task_metadata WHERE task_id = :tid"
),
{"tid": "test-task-42"},
).fetchone()
assert row is not None, (
"Core row should survive the downgrade/upgrade cycle"
)
# Defaults: 0, 0, NULL — the old values (7, 10, "Step 7 of 10") are lost
assert row[0] == 0, (
f"progress_current should be default 0, got {row[0]}"
)
assert row[1] == 0, f"progress_total should be default 0, got {row[1]}"
assert row[2] is None, (
f"progress_message should be default NULL, got {row[2]}"
)
# -- 10. env.py offline mode raises -------------------------------------
def test_env_offline_mode_raises(self):
"""env.py's run_migrations_offline() must raise NotImplementedError.
Tested via AST to avoid import side-effects (env.py runs
run_migrations_online() at module level).
"""
import ast
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
env_path = get_migrations_dir() / "env.py"
tree = ast.parse(env_path.read_text(), filename=str(env_path))
func = None
for node in ast.walk(tree):
if (
isinstance(node, ast.FunctionDef)
and node.name == "run_migrations_offline"
):
func = node
break
assert func is not None, "run_migrations_offline not found in env.py"
# Strip docstring
body = list(func.body)
if (
body
and isinstance(body[0], ast.Expr)
and isinstance(body[0].value, ast.Constant)
and isinstance(body[0].value.value, str)
):
body = body[1:]
assert len(body) == 1, (
f"run_migrations_offline should contain only a Raise, "
f"got {len(body)} statement(s)"
)
stmt = body[0]
assert isinstance(stmt, ast.Raise), (
f"Expected Raise statement, got {type(stmt).__name__}"
)
# Verify it raises NotImplementedError specifically
assert isinstance(stmt.exc, ast.Call), (
"Expected Raise with a Call (e.g. NotImplementedError(...))"
)
assert isinstance(stmt.exc.func, ast.Name), (
"Expected exception to be a named type"
)
assert stmt.exc.func.id == "NotImplementedError", (
f"Expected NotImplementedError, got {stmt.exc.func.id}"
)
# -- 10b. env.py online mode requires connection -------------------------
def test_env_online_mode_requires_connection(self):
"""env.py's run_migrations_online() must raise RuntimeError when
config.attributes['connection'] is None.
Tested via AST to avoid import side-effects (env.py runs
run_migrations_online() at module level).
"""
import ast
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
env_path = get_migrations_dir() / "env.py"
tree = ast.parse(env_path.read_text(), filename=str(env_path))
func = None
for node in ast.walk(tree):
if (
isinstance(node, ast.FunctionDef)
and node.name == "run_migrations_online"
):
func = node
break
assert func is not None, "run_migrations_online not found in env.py"
# Find the RuntimeError raise in the function body
has_runtime_error = False
for node in ast.walk(func):
if isinstance(node, ast.Raise) and isinstance(node.exc, ast.Call):
if (
isinstance(node.exc.func, ast.Name)
and node.exc.func.id == "RuntimeError"
):
has_runtime_error = True
break
assert has_runtime_error, (
"run_migrations_online must raise RuntimeError when no "
"connection is provided"
)
# -- 10c. migration 0004 guards on missing settings table ---------------
def test_migration_0004_skips_without_settings_table(self, tmp_path):
"""Migration 0004 should succeed silently on a database without
a settings table (e.g., if only partial tables exist).
Target is 0008 (last revision that doesn't require a fully-formed
research_history schema). 0010 ADDs a column to research_history
which the partial-DB shape doesn't have; tested separately.
"""
from local_deep_research.database.alembic_runner import (
get_alembic_config,
)
# Create engine with only alembic_version stamped at 0003
db_path = tmp_path / "no_settings.db"
engine = create_engine(f"sqlite:///{db_path}")
# Create alembic_version and stamp at 0003
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 (version_num) VALUES ('0003')"
)
)
# Running migrations should not fail — 0004 should skip gracefully
config = get_alembic_config(engine)
from alembic import command
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, "0008")
# Verify we're at 0008 despite no settings table
with engine.connect() as conn:
row = conn.execute(
text("SELECT version_num FROM alembic_version")
).fetchone()
assert row[0] == "0008"
engine.dispose()
# -- 11. revision IDs match filenames -----------------------------------
def test_migration_revision_ids_match_filenames(self):
"""Each migration file's ``revision`` variable must match its filename prefix.
Catches copy-paste errors where a migration file is duplicated
but the ``revision = "..."`` inside is not updated.
"""
import ast
from local_deep_research.database.alembic_runner import (
get_migrations_dir,
)
versions_dir = get_migrations_dir() / "versions"
for py_file in sorted(versions_dir.glob("[0-9]*.py")):
filename_prefix = py_file.stem.split("_")[0] # e.g. "0003"
tree = ast.parse(py_file.read_text(), filename=str(py_file))
revision_value = None
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if (
isinstance(target, ast.Name)
and target.id == "revision"
):
if isinstance(node.value, ast.Constant):
revision_value = node.value.value
break
assert revision_value is not None, (
f"{py_file.name}: no 'revision = \"...\"' assignment found"
)
assert revision_value == filename_prefix, (
f"{py_file.name}: revision='{revision_value}' does not match "
f"filename prefix '{filename_prefix}'"
)