Files
learningcircuit--local-deep…/tests/database/test_migration_0005_resource_document_id.py
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

603 lines
22 KiB
Python

"""
Tests for migration 0005: Add document_id column to research_resources.
Tests cover:
- Column creation with correct type and nullability
- Index creation (ix_research_resources_document_id)
- Full migration chain from empty database
- Idempotency (running migrations multiple times)
- Downgrade behavior (column and index removal)
- Data preservation during upgrade and downgrade
- Edge cases (missing table, pre-existing column, in-memory database)
"""
from alembic import command
import pytest
from sqlalchemy import create_engine, inspect, text
from local_deep_research.database.alembic_runner import (
get_alembic_config,
get_current_revision,
get_head_revision,
needs_migration,
run_migrations,
)
def _run_upgrade_to(engine, revision):
"""Run migrations up to a specific revision."""
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.upgrade(config, revision)
def _run_downgrade_to(engine, revision):
"""Run downgrade to a specific revision."""
config = get_alembic_config(engine)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, revision)
def _get_columns(engine, table_name):
"""Get a dict of {column_name: column_info} for a table."""
insp = inspect(engine)
if not insp.has_table(table_name):
return {}
return {col["name"]: col for col in insp.get_columns(table_name)}
def _get_indexes_by_name(engine, table_name):
"""Get a dict of {index_name: [column_names]} for a table."""
insp = inspect(engine)
if not insp.has_table(table_name):
return {}
return {
idx["name"]: idx["column_names"]
for idx in insp.get_indexes(table_name)
if idx["name"] is not None
}
@pytest.fixture
def fresh_engine(tmp_path):
"""Create a fresh SQLite engine (empty database, no tables)."""
db_path = tmp_path / "fresh_0005_test.db"
engine = create_engine(f"sqlite:///{db_path}")
yield engine
engine.dispose()
@pytest.fixture
def migrated_to_0004_engine(tmp_path):
"""Create a database migrated to revision 0004 (before document_id)."""
db_path = tmp_path / "migrated_0004_test.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0004")
yield engine
engine.dispose()
@pytest.fixture
def fully_migrated_engine(tmp_path):
"""Create a database migrated up to revision 0005 (this file's target).
Stops at 0005 instead of head — the downgrade tests below would
otherwise have to roll back through migration 0010, which is
documented as non-reversible (raises NotImplementedError). Every
test in this file is scoped to 0005 behaviour.
"""
db_path = tmp_path / "fully_migrated_0005_test.db"
engine = create_engine(f"sqlite:///{db_path}")
_run_upgrade_to(engine, "0005")
yield engine
engine.dispose()
class TestMigration0005UpgradeColumn:
"""Tests that verify document_id column creation on upgrade."""
def test_document_id_column_exists(self, fully_migrated_engine):
"""document_id column should exist on research_resources after migration."""
columns = _get_columns(fully_migrated_engine, "research_resources")
assert "document_id" in columns
def test_document_id_column_is_nullable(self, fully_migrated_engine):
"""document_id should be nullable (existing rows have no value)."""
columns = _get_columns(fully_migrated_engine, "research_resources")
assert columns["document_id"]["nullable"] is True
def test_document_id_column_type_is_varchar(self, fully_migrated_engine):
"""document_id should be VARCHAR(36) to hold UUIDs."""
columns = _get_columns(fully_migrated_engine, "research_resources")
col_type = str(columns["document_id"]["type"])
assert "VARCHAR" in col_type or "CHAR" in col_type
def test_document_id_index_exists(self, fully_migrated_engine):
"""Index ix_research_resources_document_id should exist."""
indexes = _get_indexes_by_name(
fully_migrated_engine, "research_resources"
)
assert "ix_research_resources_document_id" in indexes
assert indexes["ix_research_resources_document_id"] == ["document_id"]
def test_document_id_defaults_to_null(self, fully_migrated_engine):
"""Inserting a row without document_id should default to NULL."""
engine = fully_migrated_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at) "
"VALUES ('test-rh-1', 'Test Resource', "
"'https://example.com', '2026-01-01')"
)
)
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT document_id FROM research_resources "
"WHERE research_id = 'test-rh-1'"
)
).fetchone()
assert result[0] is None
def test_document_id_can_store_uuid(self, fully_migrated_engine):
"""document_id should accept a UUID string value."""
engine = fully_migrated_engine
test_uuid = "abcdef01-2345-6789-abcd-ef0123456789"
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at, document_id) "
"VALUES ('test-rh-2', 'Linked Resource', "
"'https://example.com', '2026-01-01', :doc_id)"
),
{"doc_id": test_uuid},
)
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT document_id FROM research_resources "
"WHERE research_id = 'test-rh-2'"
)
).fetchone()
assert result[0] == test_uuid
class TestMigration0005UpgradeFromPrior:
"""Tests that verify the upgrade path for databases missing document_id.
Migration 0001 uses Base.metadata.create_all() which includes document_id
from the current model. To test the real-world scenario (database created
before document_id was added to the model), we manually create the table
WITHOUT document_id and stamp at 0004.
"""
@pytest.fixture
def legacy_engine(self, tmp_path):
"""Create a database with research_resources missing document_id.
This simulates a database created before commit 2033f977e added
document_id to the ResearchResource model.
"""
db_path = tmp_path / "legacy_no_docid.db"
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
# Create research_history (needed for FK)
conn.execute(
text(
"CREATE TABLE research_history ("
"id VARCHAR(36) PRIMARY KEY, "
"query TEXT NOT NULL, "
"mode TEXT NOT NULL, "
"status TEXT NOT NULL, "
"created_at TEXT NOT NULL"
")"
)
)
# Create research_resources WITHOUT document_id
conn.execute(
text(
"CREATE TABLE research_resources ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"research_id VARCHAR(36) NOT NULL "
" REFERENCES research_history(id) ON DELETE CASCADE, "
"title TEXT, "
"url TEXT, "
"content_preview TEXT, "
"source_type TEXT, "
"metadata JSON, "
"created_at VARCHAR NOT NULL"
")"
)
)
# Stamp at 0004 so migration 0005 runs
conn.execute(
text(
"CREATE TABLE alembic_version "
"(version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(text("INSERT INTO alembic_version VALUES ('0004')"))
yield engine
engine.dispose()
def test_upgrade_adds_column_to_legacy_table(self, legacy_engine):
"""Upgrading a database missing document_id should add the column."""
columns = _get_columns(legacy_engine, "research_resources")
assert "document_id" not in columns
_run_upgrade_to(legacy_engine, "0005")
columns = _get_columns(legacy_engine, "research_resources")
assert "document_id" in columns
def test_upgrade_adds_index_to_legacy_table(self, legacy_engine):
"""Upgrading should create the document_id index."""
_run_upgrade_to(legacy_engine, "0005")
indexes = _get_indexes_by_name(legacy_engine, "research_resources")
assert "ix_research_resources_document_id" in indexes
def test_revision_is_0005_after_upgrade(self, legacy_engine):
"""Current revision should be 0005 after targeted upgrade."""
_run_upgrade_to(legacy_engine, "0005")
assert get_current_revision(legacy_engine) == "0005"
def test_existing_data_preserved_after_upgrade(self, legacy_engine):
"""Rows inserted before upgrade should survive with NULL document_id."""
with legacy_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES ('rh-legacy', 'old query', 'quick', "
"'completed', '2025-06-01')"
)
)
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at) "
"VALUES ('rh-legacy', 'Old Resource', "
"'https://old.com', '2025-06-01')"
)
)
_run_upgrade_to(legacy_engine, "0005")
with legacy_engine.connect() as conn:
result = conn.execute(
text(
"SELECT title, url, document_id "
"FROM research_resources "
"WHERE research_id = 'rh-legacy'"
)
).fetchone()
assert result[0] == "Old Resource"
assert result[1] == "https://old.com"
assert result[2] is None
def test_idempotent_on_fresh_db(self, migrated_to_0004_engine):
"""On a fresh DB (0001 already created document_id), 0005 is a no-op."""
engine = migrated_to_0004_engine
# 0001's create_all already added document_id from current model
columns_before = _get_columns(engine, "research_resources")
assert "document_id" in columns_before
_run_upgrade_to(engine, "0005")
columns_after = _get_columns(engine, "research_resources")
assert "document_id" in columns_after
assert get_current_revision(engine) == "0005"
class TestMigration0005FromFreshDatabase:
"""Tests that verify the full migration chain on a fresh database."""
def test_fresh_db_full_migration_creates_column(self, fresh_engine):
"""Running all migrations on empty DB should create document_id."""
run_migrations(fresh_engine)
columns = _get_columns(fresh_engine, "research_resources")
assert "document_id" in columns
def test_head_revision_is_real_id(self):
"""get_head_revision() returns a real 4-digit revision id."""
head = get_head_revision()
assert head is not None and head.isdigit() and len(head) == 4
def test_current_revision_is_head_after_migrate(self, fresh_engine):
"""After full migration, current revision should match head."""
run_migrations(fresh_engine)
assert get_current_revision(fresh_engine) == get_head_revision()
def test_needs_migration_false_after_full_upgrade(self, fresh_engine):
"""After full migration, needs_migration() should return False."""
run_migrations(fresh_engine)
assert needs_migration(fresh_engine) is False
class TestMigration0005Idempotency:
"""Tests that verify safe re-runs of migration."""
def test_run_migrations_twice_no_error(self, fresh_engine):
"""Calling run_migrations() twice should not raise."""
run_migrations(fresh_engine)
run_migrations(fresh_engine)
def test_column_unchanged_after_double_migration(self, fresh_engine):
"""Column should be identical after running migrations twice."""
run_migrations(fresh_engine)
columns_first = _get_columns(fresh_engine, "research_resources")
run_migrations(fresh_engine)
columns_second = _get_columns(fresh_engine, "research_resources")
assert ("document_id" in columns_first) == (
"document_id" in columns_second
)
def test_pre_existing_column_not_duplicated(self, tmp_path):
"""If document_id already exists (fresh DB), migration is a no-op."""
db_path = tmp_path / "pre_existing_col.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
# Full migration creates the column via 0001's create_all
run_migrations(engine)
columns_before = _get_columns(engine, "research_resources")
assert "document_id" in columns_before
# Downgrade to 0004 and re-upgrade to test the migration path
# when the column was already added by create_all
# (This tests the idempotency guard)
col_count_before = len(columns_before)
# Running migrations again should be a no-op
run_migrations(engine)
columns_after = _get_columns(engine, "research_resources")
assert len(columns_after) == col_count_before
finally:
engine.dispose()
class TestMigration0005Downgrade:
"""Tests for rollback behavior."""
def test_downgrade_to_0004_removes_column(self, fully_migrated_engine):
"""Downgrade from 0005 to 0004 should remove document_id."""
_run_downgrade_to(fully_migrated_engine, "0004")
columns = _get_columns(fully_migrated_engine, "research_resources")
assert "document_id" not in columns
def test_downgrade_to_0004_removes_index(self, fully_migrated_engine):
"""Downgrade should remove ix_research_resources_document_id."""
_run_downgrade_to(fully_migrated_engine, "0004")
indexes = _get_indexes_by_name(
fully_migrated_engine, "research_resources"
)
assert "ix_research_resources_document_id" not in indexes
def test_downgrade_preserves_table(self, fully_migrated_engine):
"""research_resources table should still exist after downgrade."""
_run_downgrade_to(fully_migrated_engine, "0004")
insp = inspect(fully_migrated_engine)
assert insp.has_table("research_resources")
def test_downgrade_preserves_other_columns(self, fully_migrated_engine):
"""Other columns (title, url, etc.) should survive downgrade."""
_run_downgrade_to(fully_migrated_engine, "0004")
columns = _get_columns(fully_migrated_engine, "research_resources")
for col in ("id", "research_id", "title", "url", "created_at"):
assert col in columns, f"Column {col} lost during downgrade"
def test_downgrade_then_upgrade_roundtrip(self, fully_migrated_engine):
"""Downgrade to 0004 then upgrade back to 0005 should restore column."""
engine = fully_migrated_engine
_run_downgrade_to(engine, "0004")
assert get_current_revision(engine) == "0004"
_run_upgrade_to(engine, "0005")
assert get_current_revision(engine) == "0005"
columns = _get_columns(engine, "research_resources")
assert "document_id" in columns
indexes = _get_indexes_by_name(engine, "research_resources")
assert "ix_research_resources_document_id" in indexes
class TestMigration0005DataPreservation:
"""Tests that verify migration is non-destructive."""
def test_existing_rows_preserved_after_upgrade(
self, migrated_to_0004_engine
):
"""Data in research_resources should survive the migration."""
engine = migrated_to_0004_engine
# Insert a research_history row first (FK constraint)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES ('rh-preserve', 'test query', 'quick', "
"'completed', '2026-01-01')"
)
)
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at) "
"VALUES ('rh-preserve', 'Preserved Resource', "
"'https://example.com', '2026-01-01')"
)
)
_run_upgrade_to(engine, "0005")
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT title, url, document_id "
"FROM research_resources "
"WHERE research_id = 'rh-preserve'"
)
).fetchone()
assert result is not None
assert result[0] == "Preserved Resource"
assert result[1] == "https://example.com"
assert result[2] is None # New column defaults to NULL
def test_data_preserved_after_downgrade(self, fully_migrated_engine):
"""Non-document_id data should survive downgrade."""
engine = fully_migrated_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES ('rh-down', 'downgrade query', 'detailed', "
"'completed', '2026-01-01')"
)
)
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at, document_id) "
"VALUES ('rh-down', 'Will Lose DocID', "
"'https://example.com', '2026-01-01', 'some-uuid')"
)
)
_run_downgrade_to(engine, "0004")
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT title, url FROM research_resources "
"WHERE research_id = 'rh-down'"
)
).fetchone()
assert result is not None
assert result[0] == "Will Lose DocID"
assert result[1] == "https://example.com"
class TestMigration0005EdgeCases:
"""Tests for edge cases and robustness."""
def test_missing_research_resources_table(self, tmp_path):
"""Migration should be a no-op when research_resources doesn't exist."""
db_path = tmp_path / "no_resources_table.db"
engine = create_engine(f"sqlite:///{db_path}")
try:
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE alembic_version "
"(version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(
text("INSERT INTO alembic_version VALUES ('0004')")
)
_run_upgrade_to(engine, "0005")
assert get_current_revision(engine) == "0005"
insp = inspect(engine)
assert not insp.has_table("research_resources")
finally:
engine.dispose()
def test_in_memory_database(self):
"""Migration should work on in-memory SQLite database."""
engine = create_engine("sqlite:///:memory:")
try:
run_migrations(engine)
assert get_current_revision(engine) == get_head_revision()
columns = _get_columns(engine, "research_resources")
assert "document_id" in columns
indexes = _get_indexes_by_name(engine, "research_resources")
assert "ix_research_resources_document_id" in indexes
finally:
engine.dispose()
def test_document_id_queryable_after_upgrade(self, migrated_to_0004_engine):
"""Queries filtering on document_id should work after upgrade."""
engine = migrated_to_0004_engine
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO research_history "
"(id, query, mode, status, created_at) "
"VALUES ('rh-query', 'query test', 'quick', "
"'completed', '2026-01-01')"
)
)
conn.execute(
text(
"INSERT INTO research_resources "
"(research_id, title, url, created_at) "
"VALUES ('rh-query', 'Resource A', "
"'https://a.com', '2026-01-01')"
)
)
_run_upgrade_to(engine, "0005")
# Set document_id on the row
with engine.begin() as conn:
conn.execute(
text(
"UPDATE research_resources SET document_id = 'doc-uuid-1' "
"WHERE research_id = 'rh-query'"
)
)
# Query using the new column (mimics library_service.py join condition)
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT title FROM research_resources "
"WHERE document_id = 'doc-uuid-1'"
)
).fetchall()
assert len(result) == 1
assert result[0][0] == "Resource A"
# NULL document_id query
result = conn.execute(
text(
"SELECT COUNT(*) FROM research_resources "
"WHERE document_id IS NULL"
)
).scalar()
assert result == 0 # We updated the only row