Files
MoviePilot/tests/test_plugin_install_transaction.py
T

739 lines
25 KiB
Python

"""插件安装事务记录、SQLite CAS 和 membership 测试。"""
import copy
import importlib
import os
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy.orm import sessionmaker
try:
import psycopg2 as postgres_driver
from psycopg2 import sql
POSTGRESQL_DIALECT = "postgresql+psycopg2"
except ModuleNotFoundError:
import psycopg as postgres_driver
from psycopg import sql
POSTGRESQL_DIALECT = "postgresql+psycopg"
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.transaction import (
PluginInstallationConflictError,
PluginInstallationPhase,
PluginInstallationRecord,
PluginInstallationRecordError,
)
from app.db.adapters.plugininstallation import TransactionalPluginInstallationStore
from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel
from app.db.models.plugininstallation import PluginInstallation
from app.db.models.systemconfig import SystemConfig
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
def _identity(
*,
plugin_id: str = "DemoPlugin",
revision: int = 1,
version: str = "1.0.0",
) -> PluginIdentity:
"""构造一份满足来源身份合同的测试身份。"""
return PluginIdentity(
plugin_id=plugin_id,
normalized_plugin_id=plugin_id.lower(),
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version=version,
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True, "v3t": False},
declaration_version=version,
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=revision,
created_at=NOW,
updated_at=NOW,
bound_at=NOW,
payload_applied_at=NOW,
)
def _record(**overrides) -> PluginInstallationRecord:
"""构造可跨进程恢复的安装事务记录。"""
values = {
"transaction_id": "txn-demo-1",
"plugin_id": "DemoPlugin",
"phase": PluginInstallationPhase.PREPARED,
"membership_before": True,
"membership_target": None,
"identity_before_revision": 1,
"identity_target_revision": None,
"package_existed": True,
"persistent_backup_existed": True,
"created_at": NOW,
"updated_at": NOW,
}
values.update(overrides)
return PluginInstallationRecord(**values)
def test_record_keeps_plugin_level_recovery_contract() -> None:
"""事务只记录目标插件 membership、CAS revision 和备份存在性。"""
record = _record(
phase="committed",
membership_target=True,
identity_target_revision=2,
)
assert record.phase is PluginInstallationPhase.COMMITTED
assert record.membership_before is True
assert record.membership_target is True
assert record.identity_before_revision == 1
assert record.identity_target_revision == 2
assert record.package_existed is True
assert record.persistent_backup_existed is True
@pytest.mark.parametrize(
"overrides",
[
{"transaction_id": "bad id"},
{"plugin_id": " DemoPlugin"},
{"membership_before": 1},
{"membership_target": 1},
{"identity_before_revision": 0},
{"identity_target_revision": True},
{"package_existed": 1},
{"created_at": NOW.replace(tzinfo=None)},
{"updated_at": NOW.replace(year=2025)},
{"phase": "committed"},
],
)
def test_record_rejects_invalid_recovery_invariants(overrides: dict) -> None:
"""事务记录必须拒绝不能用于 CAS 或补偿恢复的状态。"""
with pytest.raises(PluginInstallationRecordError):
_record(**overrides)
def test_committed_record_requires_target_membership() -> None:
"""COMMITTED 不能指向尚未登记的业务目标。"""
with pytest.raises(PluginInstallationRecordError):
_record(phase=PluginInstallationPhase.COMMITTED)
def test_record_schema_version_is_explicit() -> None:
"""恢复读取必须拒绝未知 schema version。"""
with pytest.raises(PluginInstallationRecordError):
_record(schema_version=2)
def test_record_is_immutable() -> None:
"""事务记录提交后不能被调用方原地修改。"""
record = _record()
with pytest.raises(AttributeError):
record.membership_before = False # type: ignore[misc]
assert replace(record, membership_before=False).membership_before is False
class _AtomicSystemConfig:
"""用测试 Session 模拟 SystemConfigOper 的配置锁和原子提交。"""
def __init__(self, factory) -> None:
self._factory = factory
self._lock = threading.RLock()
def update_atomically(self, key, mutation):
"""在测试数据库事务中锁定配置并执行关联写入。"""
with self._lock:
session = self._factory()
try:
with session.begin():
config = session.execute(
sa.select(SystemConfig)
.where(SystemConfig.key == key)
.with_for_update()
).scalar_one_or_none()
current = copy.deepcopy(config.value if config else None)
result, value = mutation(session, current)
if config is None:
session.add(SystemConfig(key=key, value=copy.deepcopy(value)))
else:
config.value = copy.deepcopy(value)
session.flush()
return result
finally:
session.close()
@pytest.fixture
def installation_store(tmp_path):
"""创建带配置、身份和事务表的隔离 SQLite Store。"""
engine = sa.create_engine(
f"sqlite:///{tmp_path / 'plugin-installation.db'}",
connect_args={"check_same_thread": False, "timeout": 5},
)
for model in (SystemConfig, PluginIdentityModel, PluginInstallation):
model.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
system_config = _AtomicSystemConfig(factory)
try:
yield engine, factory, TransactionalPluginInstallationStore(
factory,
system_config.update_atomically,
)
finally:
engine.dispose()
def _store_record(
*,
transaction_id: str,
plugin_id: str = "DemoPlugin",
membership_before: bool = False,
identity_before_revision: int | None = None,
) -> PluginInstallationRecord:
"""构造 Store 测试用的 PREPARED 记录。"""
return PluginInstallationRecord(
transaction_id=transaction_id,
plugin_id=plugin_id,
phase=PluginInstallationPhase.PREPARED,
membership_before=membership_before,
membership_target=None,
identity_before_revision=identity_before_revision,
identity_target_revision=None,
package_existed=membership_before,
persistent_backup_existed=False,
created_at=NOW,
updated_at=NOW,
)
def _identity_model(identity: PluginIdentity) -> PluginIdentityModel:
"""把应用身份转换为测试数据库模型。"""
return PluginIdentityModel(
plugin_id=identity.plugin_id,
normalized_plugin_id=identity.normalized_plugin_id,
trusted_source_type=identity.trusted_source_type.value,
trusted_source_key=identity.trusted_source_key,
binding_basis=identity.binding_basis.value,
payload_source_type=identity.payload_source_type.value,
payload_source_key=identity.payload_source_key,
declared_version=identity.declared_version,
package_generation=identity.package_generation,
declared_metadata=(
identity.declared_metadata.to_json()
if identity.declared_metadata is not None
else None
),
payload_receipt=identity.payload_receipt,
revision=identity.revision,
created_at=identity.created_at.isoformat(),
updated_at=identity.updated_at.isoformat(),
bound_at=identity.bound_at.isoformat() if identity.bound_at else None,
payload_applied_at=(
identity.payload_applied_at.isoformat()
if identity.payload_applied_at
else None
),
)
def _set_config(factory, value: list[str]) -> None:
"""直接准备测试用的安装清单。"""
with factory() as session:
config = session.execute(
sa.select(SystemConfig).where(SystemConfig.key == "UserInstalledPlugins")
).scalar_one_or_none()
if config is None:
session.add(SystemConfig(key="UserInstalledPlugins", value=value))
else:
config.value = value
session.commit()
def _get_config(factory) -> list[str] | None:
"""读取测试用的安装清单。"""
with factory() as session:
config = session.execute(
sa.select(SystemConfig).where(SystemConfig.key == "UserInstalledPlugins")
).scalar_one_or_none()
return copy.deepcopy(config.value) if config else None
def _upgrade_migration(connection, module_name: str) -> None:
"""在当前隔离 schema 中按生产 Alembic 路径执行迁移。"""
migration = importlib.import_module(module_name)
original_op = migration.op
try:
migration.op = Operations(MigrationContext.configure(connection))
migration.upgrade()
finally:
migration.op = original_op
def _set_identity_revision(
factory,
revision: int,
plugin_id: str = "DemoPlugin",
) -> None:
"""模拟事务外的身份 revision 更新。"""
with factory() as session:
identity = session.execute(
sa.select(PluginIdentityModel).where(
PluginIdentityModel.normalized_plugin_id == plugin_id.lower()
)
).scalar_one()
identity.revision = revision
session.commit()
def test_store_round_trips_plugin_level_journal(installation_store) -> None:
"""SQLite 往返只保留插件级 membership、revision 和备份标记。"""
_, _, store = installation_store
record = _store_record(transaction_id="install-roundtrip")
store.create(record)
restored = store.get(record.transaction_id)
assert restored == record
@pytest.mark.parametrize(
"phase",
[PluginInstallationPhase.PREPARED, PluginInstallationPhase.COMMITTED],
)
def test_store_blocks_new_journal_until_previous_phase_is_closed(
installation_store,
phase: PluginInstallationPhase,
) -> None:
"""同一物理插件的未收尾 journal 不得被后续事务覆盖。"""
_, _, store = installation_store
existing = _store_record(transaction_id=f"install-{phase.value}")
if phase is PluginInstallationPhase.COMMITTED:
existing = replace(existing, phase=phase, membership_target=True)
store.create(existing)
with pytest.raises(PluginInstallationConflictError, match="未收尾安装事务"):
store.create(
_store_record(
transaction_id="install-follow-up",
plugin_id="demoplugin",
)
)
assert store.get(existing.transaction_id).phase is phase
assert store.delete(
existing.transaction_id,
expected_phase=phase,
) is True
assert store.create(
_store_record(
transaction_id="install-follow-up",
plugin_id="demoplugin",
)
).transaction_id == "install-follow-up"
def test_store_commits_membership_identity_and_phase_atomically(installation_store) -> None:
"""membership、身份和 journal phase 必须在一个配置原子事务中提交。"""
_, factory, store = installation_store
before = _identity()
target = replace(
before,
declared_version="2.0.0",
revision=2,
updated_at=NOW.replace(second=1),
payload_applied_at=NOW.replace(second=1),
)
_set_config(factory, ["OtherPlugin"])
with factory() as session:
session.add(_identity_model(before))
session.commit()
store.create(
_store_record(
transaction_id="install-atomic",
identity_before_revision=before.revision,
)
)
staged = store.set_target(
"install-atomic",
membership_target=True,
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert staged.identity_target_revision == target.revision
committed = store.commit_target(
"install-atomic",
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert committed.phase is PluginInstallationPhase.COMMITTED
assert _get_config(factory) == ["OtherPlugin", "DemoPlugin"]
with factory() as session:
identity = session.execute(
sa.select(PluginIdentityModel).where(
PluginIdentityModel.normalized_plugin_id == "demoplugin"
)
).scalar_one()
assert identity.revision == 2
def test_store_preserves_other_plugin_membership(installation_store) -> None:
"""目标插件提交不能用旧完整清单覆盖其他插件。"""
_, factory, store = installation_store
_set_config(factory, ["OtherPlugin"])
store.create(_store_record(transaction_id="install-narrow"))
store.set_target(
"install-narrow",
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
_set_config(factory, ["OtherPlugin", "AnotherPlugin"])
committed = store.commit_target(
"install-narrow",
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert committed.phase is PluginInstallationPhase.COMMITTED
assert _get_config(factory) == ["OtherPlugin", "AnotherPlugin", "DemoPlugin"]
def test_store_rejects_target_identity_revision_jump(installation_store) -> None:
"""最终写者必须拒绝跳号 revision,避免绕过后续来源 CAS。"""
_, factory, store = installation_store
before = _identity()
with factory() as session:
session.add(_identity_model(before))
session.commit()
store.create(
_store_record(
transaction_id="install-revision-jump",
identity_before_revision=before.revision,
)
)
jumped = replace(
before,
revision=before.revision + 2,
updated_at=NOW.replace(second=1),
)
with pytest.raises(PluginInstallationConflictError, match="必须为 2"):
store.set_target(
"install-revision-jump",
membership_target=True,
identity_target=jumped,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert store.get("install-revision-jump").identity_target_revision is None
def test_store_rejects_membership_and_identity_cas_drift(installation_store) -> None:
"""同一插件 membership 或 identity revision 漂移时拒绝覆盖。"""
_, factory, store = installation_store
before = _identity()
with factory() as session:
session.add(_identity_model(before))
session.commit()
store.create(
_store_record(
transaction_id="install-drift",
identity_before_revision=before.revision,
)
)
target = replace(before, revision=2, updated_at=NOW.replace(second=1))
store.set_target(
"install-drift",
membership_target=True,
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
_set_config(factory, ["DemoPlugin"])
with pytest.raises(PluginInstallationConflictError, match="membership"):
store.commit_target(
"install-drift",
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
assert store.get("install-drift").phase is PluginInstallationPhase.PREPARED
_set_config(factory, [])
_set_identity_revision(factory, 3)
with pytest.raises(PluginInstallationConflictError, match="revision"):
store.commit_target(
"install-drift",
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
def _commit_or_conflict(store, transaction_id: str) -> str:
"""把 phase CAS 竞争转换为可断言的测试结果。"""
try:
store.commit_target(
transaction_id,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
except PluginInstallationConflictError:
return "conflict"
return "committed"
def test_store_serializes_membership_commits_and_phase_cas(installation_store) -> None:
"""SQLite 下不同插件并发提交应合并,重复提交同一事务只能失败。"""
_, factory, store = installation_store
first = _store_record(transaction_id="install-first")
second = _store_record(transaction_id="install-second", plugin_id="OtherPlugin")
store.create(first)
store.create(second)
store.set_target(
first.transaction_id,
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
store.set_target(
second.transaction_id,
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
def commit(record_id: str):
return store.commit_target(
record_id,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(commit, [first.transaction_id, second.transaction_id])
)
assert {result.phase for result in results} == {
PluginInstallationPhase.COMMITTED,
}
assert set(_get_config(factory) or []) == {"DemoPlugin", "OtherPlugin"}
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(
executor.map(
lambda _: _commit_or_conflict(store, first.transaction_id),
range(2),
)
)
assert outcomes == ["conflict", "conflict"]
def test_store_delete_is_idempotent_after_recovery(installation_store) -> None:
"""恢复处理重复清理同一 journal 时不产生第二次副作用。"""
_, _, store = installation_store
store.create(_store_record(transaction_id="install-delete"))
assert store.delete(
"install-delete",
expected_phase=PluginInstallationPhase.PREPARED,
) is True
assert store.delete(
"install-delete",
expected_phase=PluginInstallationPhase.PREPARED,
) is False
@pytest.fixture
def postgresql_installation_stores():
"""创建两个不共享进程锁的 PostgreSQL Store,验证数据库并发合同。"""
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
host = os.getenv(f"{prefix}HOST")
database = os.getenv(f"{prefix}DATABASE")
username = os.getenv(f"{prefix}USERNAME")
if not host or not database or not username:
pytest.skip("未配置隔离 PostgreSQL transaction 测试库")
port = os.getenv(f"{prefix}PORT", "5432")
password = os.getenv(f"{prefix}PASSWORD", "")
schema = f"plugin_transaction_{uuid.uuid4().hex}"
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
)
engine = sa.create_engine(
sa.URL.create(
POSTGRESQL_DIALECT,
username=username,
password=password,
host=host,
port=int(port),
database=database,
),
connect_args={"options": f"-csearch_path={schema}"},
)
SystemConfig.__table__.create(engine)
with engine.begin() as connection:
_upgrade_migration(
connection,
"database.versions.d2e4f6a8b0c1_3_0_9",
)
_upgrade_migration(
connection,
"database.versions.e4f7a1b2c3d5_3_0_10",
)
_upgrade_migration(
connection,
"database.versions.5f2a9c1e7b4d_3_0_12",
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
_set_config(factory, [])
first = TransactionalPluginInstallationStore(
factory,
_AtomicSystemConfig(factory).update_atomically,
)
second = TransactionalPluginInstallationStore(
factory,
_AtomicSystemConfig(factory).update_atomically,
)
try:
yield factory, first, second
finally:
engine.dispose()
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
sql.Identifier(schema)
)
)
def test_postgresql_store_serializes_membership_phase_and_revision_cas(
postgresql_installation_stores,
) -> None:
"""PostgreSQL 行锁必须合并不同插件写入并拒绝 phase/revision 竞争。"""
factory, first_store, second_store = postgresql_installation_stores
first = _store_record(transaction_id="postgres-first")
second = _store_record(
transaction_id="postgres-second",
plugin_id="OtherPlugin",
)
for store, record in ((first_store, first), (second_store, second)):
store.create(record)
store.set_target(
record.transaction_id,
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(
executor.map(
lambda item: item[0].commit_target(
item[1].transaction_id,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
),
((first_store, first), (second_store, second)),
)
)
assert {result.phase for result in results} == {
PluginInstallationPhase.COMMITTED,
}
assert set(_get_config(factory) or []) == {"DemoPlugin", "OtherPlugin"}
race = _store_record(
transaction_id="postgres-phase-race",
plugin_id="RacePlugin",
)
first_store.create(race)
first_store.set_target(
race.transaction_id,
membership_target=True,
identity_target=None,
expected_phase=PluginInstallationPhase.PREPARED,
)
barrier = threading.Barrier(2)
def commit_race(store) -> str:
barrier.wait()
return _commit_or_conflict(store, race.transaction_id)
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(executor.map(commit_race, (first_store, second_store)))
assert sorted(outcomes) == ["committed", "conflict"]
before = _identity(plugin_id="RevisionPlugin")
with factory() as session:
session.add(_identity_model(before))
session.commit()
revision = _store_record(
transaction_id="postgres-revision",
plugin_id=before.plugin_id,
identity_before_revision=before.revision,
)
first_store.create(revision)
target = replace(
before,
revision=2,
updated_at=NOW.replace(second=1),
)
first_store.set_target(
revision.transaction_id,
membership_target=True,
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)
_set_identity_revision(factory, 3, plugin_id=before.plugin_id)
with pytest.raises(PluginInstallationConflictError, match="revision"):
second_store.commit_target(
revision.transaction_id,
identity_target=target,
expected_phase=PluginInstallationPhase.PREPARED,
)