Files
2026-07-13 13:08:41 +08:00

627 lines
22 KiB
Python

from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
import migrations
def _alembic_config(database_url: str) -> Config:
config = Config()
config.set_main_option(
"script_location", str(Path(__file__).resolve().parents[2] / "alembic")
)
config.set_main_option("sqlalchemy.url", database_url)
return config
def test_legacy_database_with_theme_is_stamped_past_theme_migration(
tmp_path, monkeypatch
):
database_url = f"sqlite:///{tmp_path / 'legacy.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text("CREATE TABLE presentations (id TEXT PRIMARY KEY, theme JSON)")
)
finally:
engine.dispose()
stamped_revisions = []
monkeypatch.setattr(
migrations.command,
"stamp",
lambda _config, revision: stamped_revisions.append(revision),
)
migrations._stamp_legacy_database_if_needed(
_alembic_config(database_url), database_url
)
assert stamped_revisions == [migrations.REVISION_BEFORE_TEMPLATE_CREATE_INFO]
def test_upgrade_from_baseline_stamp_skips_existing_theme_column(tmp_path):
database_url = f"sqlite:///{tmp_path / 'baseline-stamped.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text("CREATE TABLE presentations (id TEXT PRIMARY KEY, theme JSON)")
)
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:revision)"),
{"revision": migrations.LEGACY_BASELINE_REVISION},
)
command.upgrade(_alembic_config(database_url), "head")
with engine.connect() as connection:
version = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
columns = {
row[1]
for row in connection.execute(text("PRAGMA table_info(presentations)"))
}
tables = {
row[0]
for row in connection.execute(
text("SELECT name FROM sqlite_master WHERE type = 'table'")
)
}
assert version == migrations.REVISION_ASYNC_TASKS
assert "theme" in columns
assert "fonts" in columns
assert "async_tasks" in tables
finally:
engine.dispose()
def test_upgrade_from_theme_stamp_skips_existing_template_create_infos_table(tmp_path):
database_url = f"sqlite:///{tmp_path / 'template-table-exists.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text("CREATE TABLE presentations (id TEXT PRIMARY KEY, theme JSON)")
)
connection.execute(
text(
"""
CREATE TABLE template_create_infos (
id CHAR(32) NOT NULL,
fonts JSON,
pptx_url VARCHAR,
slide_htmls JSON NOT NULL,
slide_image_urls JSON NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id)
)
"""
)
)
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:revision)"),
{"revision": migrations.REVISION_BEFORE_TEMPLATE_CREATE_INFO},
)
command.upgrade(_alembic_config(database_url), "head")
with engine.connect() as connection:
version = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
tables = {
row[0]
for row in connection.execute(
text("SELECT name FROM sqlite_master WHERE type = 'table'")
)
}
assert version == migrations.REVISION_ASYNC_TASKS
assert "template_create_infos" in tables
finally:
engine.dispose()
def test_upgrade_from_template_stamp_skips_existing_chat_history_table(tmp_path):
database_url = f"sqlite:///{tmp_path / 'chat-table-exists.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(text("CREATE TABLE presentations (id TEXT PRIMARY KEY)"))
connection.execute(
text(
"""
CREATE TABLE chat_history_messages (
id CHAR(32) NOT NULL,
presentation_id CHAR(32) NOT NULL,
conversation_id CHAR(32) NOT NULL,
position INTEGER NOT NULL,
role VARCHAR NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL,
tool_calls JSON,
PRIMARY KEY (id)
)
"""
)
)
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:revision)"),
{"revision": migrations.REVISION_TEMPLATE_CREATE_INFO},
)
command.upgrade(_alembic_config(database_url), "head")
with engine.connect() as connection:
version = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
indexes = {
row[1]
for row in connection.execute(
text("PRAGMA index_list(chat_history_messages)")
)
}
tables = {
row[0]
for row in connection.execute(
text("SELECT name FROM sqlite_master WHERE type = 'table'")
)
}
template_columns = {
row[1]
for row in connection.execute(text("PRAGMA table_info(template_v2)"))
}
assert version == migrations.REVISION_ASYNC_TASKS
assert {
"ix_chat_history_messages_conversation_id",
"ix_chat_history_messages_position",
"ix_chat_history_messages_presentation_id",
"ix_chat_history_messages_template_v2_id",
}.issubset(indexes)
assert "template_v2" in tables
assert "components" in template_columns
assert "cluster_candidates" not in template_columns
assert "clusters" not in template_columns
finally:
engine.dispose()
def test_consolidated_migration_adds_presentation_version(tmp_path):
database_url = f"sqlite:///{tmp_path / 'presentation-version.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE presentations (
id TEXT PRIMARY KEY,
content VARCHAR NOT NULL,
n_slides INTEGER NOT NULL,
language VARCHAR NOT NULL
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO presentations (id, content, n_slides, language)
VALUES ('p1', 'content', 1, 'English')
"""
)
)
connection.execute(text("CREATE TABLE slides (id TEXT PRIMARY KEY)"))
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:revision)"),
{"revision": migrations.REVISION_CHAT_HISTORY},
)
command.upgrade(_alembic_config(database_url), "head")
with engine.connect() as connection:
version = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
presentation_version = connection.execute(
text("SELECT version FROM presentations WHERE id = 'p1'")
).scalar_one()
version_column = next(
row
for row in connection.execute(text("PRAGMA table_info(presentations)"))
if row[1] == "version"
)
slide_columns = {
row[1]
for row in connection.execute(text("PRAGMA table_info(slides)"))
}
assert version == migrations.REVISION_ASYNC_TASKS
assert presentation_version == "v1-standard"
assert version_column[3] == 1
assert version_column[4] is None
assert "ui" in slide_columns
finally:
engine.dispose()
def test_unversioned_database_with_chat_history_stamps_before_template_v2(
tmp_path, monkeypatch
):
database_url = f"sqlite:///{tmp_path / 'legacy-chat.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(text("CREATE TABLE presentations (id TEXT PRIMARY KEY)"))
connection.execute(
text(
"""
CREATE TABLE chat_history_messages (
id CHAR(32) NOT NULL,
presentation_id CHAR(32) NOT NULL,
conversation_id CHAR(32) NOT NULL,
position INTEGER NOT NULL,
role VARCHAR NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id)
)
"""
)
)
finally:
engine.dispose()
stamped_revisions = []
monkeypatch.setattr(
migrations.command,
"stamp",
lambda _config, revision: stamped_revisions.append(revision),
)
migrations._stamp_legacy_database_if_needed(
_alembic_config(database_url), database_url
)
assert stamped_revisions == [migrations.REVISION_CHAT_HISTORY]
def test_upgrade_from_template_v2_revision_adds_slide_ui(tmp_path):
database_url = f"sqlite:///{tmp_path / 'slide-ui.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(text("CREATE TABLE slides (id TEXT PRIMARY KEY)"))
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:revision)"),
{"revision": migrations.REVISION_TEMPLATE_V2},
)
command.upgrade(_alembic_config(database_url), "head")
with engine.connect() as connection:
version = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
slide_columns = {
row[1]
for row in connection.execute(text("PRAGMA table_info(slides)"))
}
assert version == migrations.REVISION_ASYNC_TASKS
assert "ui" in slide_columns
finally:
engine.dispose()
def test_upgrade_from_font_uploads_revision_converts_template_v2_ids_to_strings(
tmp_path,
):
database_url = f"sqlite:///{tmp_path / 'template-v2-string-ids.db'}"
template_id = "12345678123456781234567812345678"
expected_template_id = "12345678-1234-5678-1234-567812345678"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE template_v2 (
id CHAR(32) NOT NULL,
name VARCHAR NOT NULL,
description VARCHAR,
raw_layouts JSON,
components JSON,
merged_components JSON,
layouts JSON,
assets JSON,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id)
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO template_v2 (
id, name, layouts, created_at, updated_at
)
VALUES (
:template_id,
'Legacy V2',
'{"layouts": []}',
'2026-07-09 00:00:00',
'2026-07-09 00:00:00'
)
"""
),
{"template_id": template_id},
)
connection.execute(
text(
"""
CREATE TABLE chat_history_messages (
id CHAR(32) NOT NULL,
presentation_id CHAR(32),
template_v2_id CHAR(32),
conversation_id CHAR(32) NOT NULL,
position INTEGER NOT NULL,
role VARCHAR NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL,
tool_calls JSON,
PRIMARY KEY (id)
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO chat_history_messages (
id,
template_v2_id,
conversation_id,
position,
role,
content,
created_at
)
VALUES (
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
:template_id,
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
1,
'user',
'hello',
'2026-07-09 00:00:00'
)
"""
),
{"template_id": template_id},
)
connection.execute(
text(
"""
CREATE INDEX ix_chat_history_messages_template_v2_id
ON chat_history_messages (template_v2_id)
"""
)
)
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:revision)"),
{"revision": migrations.REVISION_FONT_UPLOADS},
)
command.upgrade(_alembic_config(database_url), "head")
with engine.connect() as connection:
version = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
stored_template_id = connection.execute(
text("SELECT id FROM template_v2")
).scalar_one()
stored_chat_template_id = connection.execute(
text("SELECT template_v2_id FROM chat_history_messages")
).scalar_one()
template_id_type = next(
row[2]
for row in connection.execute(text("PRAGMA table_info(template_v2)"))
if row[1] == "id"
)
chat_template_id_type = next(
row[2]
for row in connection.execute(
text("PRAGMA table_info(chat_history_messages)")
)
if row[1] == "template_v2_id"
)
template_columns = {
row[1]
for row in connection.execute(text("PRAGMA table_info(template_v2)"))
}
assert version == migrations.REVISION_ASYNC_TASKS
assert stored_template_id == expected_template_id
assert stored_chat_template_id == expected_template_id
assert template_id_type == "VARCHAR"
assert chat_template_id_type == "VARCHAR"
assert "is_default" in template_columns
finally:
engine.dispose()
def test_unversioned_database_with_old_template_v2_stamps_before_consolidated(
tmp_path, monkeypatch
):
database_url = f"sqlite:///{tmp_path / 'legacy-template-v2.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE template_v2 (
id CHAR(32) NOT NULL,
name VARCHAR NOT NULL,
raw_layouts JSON,
layouts JSON NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id)
)
"""
)
)
finally:
engine.dispose()
stamped_revisions = []
monkeypatch.setattr(
migrations.command,
"stamp",
lambda _config, revision: stamped_revisions.append(revision),
)
migrations._stamp_legacy_database_if_needed(
_alembic_config(database_url), database_url
)
assert stamped_revisions == [migrations.REVISION_CHAT_HISTORY]
def test_unversioned_database_with_template_v2_artifacts_stamps_before_consolidated(
tmp_path, monkeypatch
):
database_url = f"sqlite:///{tmp_path / 'legacy-template-v2-artifacts.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(text("CREATE TABLE presentations (id TEXT PRIMARY KEY)"))
connection.execute(
text(
"""
CREATE TABLE template_v2 (
id CHAR(32) NOT NULL,
name VARCHAR NOT NULL,
raw_layouts JSON,
layouts JSON NOT NULL,
cluster_candidates JSON,
clusters JSON,
components JSON,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id)
)
"""
)
)
finally:
engine.dispose()
stamped_revisions = []
monkeypatch.setattr(
migrations.command,
"stamp",
lambda _config, revision: stamped_revisions.append(revision),
)
migrations._stamp_legacy_database_if_needed(
_alembic_config(database_url), database_url
)
assert stamped_revisions == [migrations.REVISION_CHAT_HISTORY]
def test_removed_intermediate_revision_upgrades_through_consolidated_migration(
tmp_path,
):
database_url = f"sqlite:///{tmp_path / 'removed-template-v2-revision.db'}"
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE presentations (
id TEXT PRIMARY KEY,
version VARCHAR NOT NULL
)
"""
)
)
connection.execute(
text(
"""
CREATE TABLE template_v2 (
id CHAR(32) NOT NULL,
name VARCHAR NOT NULL,
raw_layouts JSON,
layouts JSON NOT NULL,
cluster_candidates JSON,
clusters JSON,
components JSON,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id)
)
"""
)
)
connection.execute(
text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")
)
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES ('2d7c8f9a0b1c')")
)
config = _alembic_config(database_url)
migrations._repair_orphan_alembic_revision(config, database_url)
command.upgrade(config, "head")
with engine.connect() as connection:
version = connection.execute(
text("SELECT version_num FROM alembic_version")
).scalar_one()
template_columns = {
row[1]
for row in connection.execute(text("PRAGMA table_info(template_v2)"))
}
assert version == migrations.REVISION_ASYNC_TASKS
assert {"description", "components", "assets"}.issubset(template_columns)
assert "is_default" in template_columns
assert "cluster_candidates" not in template_columns
assert "clusters" not in template_columns
finally:
engine.dispose()