mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 14:48:40 +08:00
463 lines
16 KiB
Python
463 lines
16 KiB
Python
"""
|
||
旧待整理 Oper 的精确插件兼容与租约 fencing 测试。
|
||
"""
|
||
|
||
import importlib
|
||
import inspect
|
||
from collections.abc import Callable
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from sqlalchemy import create_engine, select, update
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
|
||
from app.db import base as db_base
|
||
from app.db.models.transferexecutionstep import TransferExecutionStep
|
||
from app.db.models.transferhistory import TransferHistory
|
||
from app.db.models.transferpending import TransferPending
|
||
from app.db.oper.transferpending import TransferPendingOper as CanonicalTransferPendingOper
|
||
from app.runtime.compat.manifest import MODULE_ALIASES
|
||
|
||
|
||
@pytest.fixture
|
||
def legacy_session_factory(tmp_path, monkeypatch):
|
||
"""
|
||
为无 Session 兼容 Oper 提供独占事务,并在提交后保留返回快照。
|
||
"""
|
||
engine = create_engine(f"sqlite:///{tmp_path / 'legacy-transferpending.db'}")
|
||
TransferPending.__table__.create(engine)
|
||
TransferExecutionStep.__table__.create(engine)
|
||
TransferHistory.__table__.create(engine)
|
||
factory = sessionmaker(bind=engine)
|
||
|
||
def run_transaction(operation: Callable[[Session], Any]) -> Any:
|
||
"""
|
||
按生产组合根语义执行一次同步兼容事务。
|
||
"""
|
||
with factory() as session:
|
||
session.expire_on_commit = False
|
||
try:
|
||
result = operation(session)
|
||
session.commit()
|
||
return result
|
||
except Exception:
|
||
session.rollback()
|
||
raise
|
||
|
||
monkeypatch.setattr(db_base, "run_sync_transaction", run_transaction)
|
||
yield factory
|
||
engine.dispose()
|
||
|
||
|
||
def test_legacy_import_targets_private_sdk_facade() -> None:
|
||
"""
|
||
旧模块路径只能解析到私有 SDK 门面,不能回退到 canonical Oper。
|
||
"""
|
||
alias = MODULE_ALIASES["app.db.transferpending_oper"]
|
||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||
|
||
assert alias.target == "app.sdk._legacy.transferpending"
|
||
assert alias.owner == "sdk"
|
||
assert alias.replacement == "app.application.transfer.workflow"
|
||
assert legacy is importlib.import_module(alias.target)
|
||
assert legacy.__all__ == ["TransferPendingOper"]
|
||
assert not hasattr(legacy, "TransferPending")
|
||
assert not hasattr(legacy, "TransferExecutionStep")
|
||
assert not hasattr(legacy, "TransferExecutionState")
|
||
assert legacy.TransferPendingOper is not CanonicalTransferPendingOper
|
||
for internal_method in (
|
||
"stage_admit",
|
||
"stage_claim_task",
|
||
"stage_discard_claimed",
|
||
"stage_checkpoint_plan",
|
||
):
|
||
assert not hasattr(legacy.TransferPendingOper, internal_method)
|
||
|
||
|
||
def test_legacy_oper_preserves_exact_public_method_abi() -> None:
|
||
"""
|
||
兼容门面只公开历史八方法,且位置参数与关键字参数边界保持不变。
|
||
"""
|
||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||
oper_type = legacy.TransferPendingOper
|
||
public_methods = {
|
||
name
|
||
for name, value in oper_type.__dict__.items()
|
||
if not name.startswith("_") and callable(value)
|
||
}
|
||
|
||
assert public_methods == {
|
||
"register",
|
||
"list_by_state",
|
||
"list_by_states",
|
||
"get_by_identity",
|
||
"get_by_task_id",
|
||
"discard",
|
||
"list_all",
|
||
"clear",
|
||
}
|
||
assert str(inspect.signature(oper_type.register)) == (
|
||
"(self, storage: str, src_path: str) -> "
|
||
"app.db.models.transferpending.TransferPending | None"
|
||
)
|
||
assert str(inspect.signature(oper_type.list_by_state)) == (
|
||
"(self, *, state: str, limit: int | None = 5000) -> "
|
||
"List[app.db.models.transferpending.TransferPending]"
|
||
)
|
||
assert str(inspect.signature(oper_type.list_by_states)) == (
|
||
"(self, *, states: tuple[str, ...], limit: int | None = 5000) -> "
|
||
"List[app.db.models.transferpending.TransferPending]"
|
||
)
|
||
assert str(inspect.signature(oper_type.get_by_identity)) == (
|
||
"(self, *, storage: str, src_path: str) -> "
|
||
"app.db.models.transferpending.TransferPending | None"
|
||
)
|
||
assert str(inspect.signature(oper_type.get_by_task_id)) == (
|
||
"(self, *, task_id: str) -> "
|
||
"app.db.models.transferpending.TransferPending | None"
|
||
)
|
||
assert str(inspect.signature(oper_type.discard)) == (
|
||
"(self, storage: str, src_path: str) -> int"
|
||
)
|
||
assert str(inspect.signature(oper_type.list_all)) == (
|
||
"(self, limit: int | None = 5000) -> List[Tuple[str, str]]"
|
||
)
|
||
assert str(inspect.signature(oper_type.clear)) == "(self) -> int"
|
||
|
||
|
||
def test_legacy_no_session_queries_preserve_historical_shapes(
|
||
legacy_session_factory,
|
||
) -> None:
|
||
"""
|
||
旧插件无需 Session 即可登记和查询,返回形态与原 ABI 一致。
|
||
"""
|
||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||
oper = legacy.TransferPendingOper()
|
||
|
||
first = oper.register("local", "/downloads/first.mkv")
|
||
repeated = oper.register("local", "/downloads/first.mkv")
|
||
second = oper.register("remote", "/downloads/second.mkv")
|
||
|
||
assert first is not None
|
||
assert repeated is not None
|
||
assert second is not None
|
||
assert repeated.task_id == first.task_id
|
||
assert oper.list_all() == [
|
||
("local", "/downloads/first.mkv"),
|
||
("remote", "/downloads/second.mkv"),
|
||
]
|
||
assert [item.task_id for item in oper.list_by_state(state="accepted")] == [
|
||
first.task_id,
|
||
second.task_id,
|
||
]
|
||
assert [
|
||
item.task_id
|
||
for item in oper.list_by_states(states=("accepted", "planned"), limit=1)
|
||
] == [first.task_id]
|
||
assert oper.get_by_identity(
|
||
storage="local",
|
||
src_path="/downloads/first.mkv",
|
||
).task_id == first.task_id
|
||
assert oper.get_by_task_id(task_id=second.task_id).src_path == second.src_path
|
||
assert oper.register("", "/downloads/invalid.mkv") is None
|
||
assert oper.list_by_state(state="") == []
|
||
assert oper.list_by_states(states=()) == []
|
||
|
||
|
||
def test_legacy_register_allows_new_task_for_previously_settled_source(
|
||
legacy_session_factory,
|
||
) -> None:
|
||
"""
|
||
旧插件登记入口允许同源文件形成新的合法任务世代。
|
||
"""
|
||
with legacy_session_factory() as session:
|
||
session.add(TransferHistory(
|
||
transfer_task_id="settled-task",
|
||
transfer_settlement_revision=1,
|
||
src="/downloads/settled.mkv",
|
||
src_storage="local",
|
||
status=True,
|
||
))
|
||
session.commit()
|
||
|
||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||
pending = legacy.TransferPendingOper().register(
|
||
"local",
|
||
"/downloads/settled.mkv",
|
||
)
|
||
assert pending is not None
|
||
assert pending.task_id != "settled-task"
|
||
with legacy_session_factory() as session:
|
||
assert session.execute(select(TransferPending)).scalar_one().task_id == pending.task_id
|
||
|
||
|
||
def test_legacy_mutations_never_delete_claimed_rows(
|
||
legacy_session_factory,
|
||
) -> None:
|
||
"""
|
||
旧 discard/clear 只处理新鲜行,有效或过期 token 均受保护。
|
||
"""
|
||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||
oper = legacy.TransferPendingOper()
|
||
active = oper.register("local", "/downloads/active.mkv")
|
||
expired = oper.register("local", "/downloads/expired.mkv")
|
||
free = oper.register("local", "/downloads/free.mkv")
|
||
assert active is not None
|
||
assert expired is not None
|
||
assert free is not None
|
||
|
||
with legacy_session_factory() as session:
|
||
assert TransferPending.claim_task(
|
||
session,
|
||
task_id=active.task_id,
|
||
states=("accepted",),
|
||
owner_id="active-worker",
|
||
lease_token="active-token",
|
||
now_time="2026-08-27 10:00:00.000000",
|
||
lease_expires_at="2026-08-27 10:01:00.000000",
|
||
updated_at="2026-08-27 18:00:00",
|
||
) == 1
|
||
assert TransferPending.claim_task(
|
||
session,
|
||
task_id=expired.task_id,
|
||
states=("accepted",),
|
||
owner_id="expired-worker",
|
||
lease_token="expired-token",
|
||
now_time="2026-08-27 10:00:00.000000",
|
||
lease_expires_at="2026-08-27 09:59:00.000000",
|
||
updated_at="2026-08-27 18:00:00",
|
||
) == 1
|
||
session.commit()
|
||
|
||
repeated = oper.register("local", "/downloads/active.mkv")
|
||
assert repeated is not None
|
||
assert repeated.task_id == active.task_id
|
||
assert repeated.lease_token == "active-token"
|
||
assert oper.discard("local", "/downloads/active.mkv") == 0
|
||
assert oper.discard("local", "/downloads/expired.mkv") == 0
|
||
assert oper.discard("local", "/downloads/free.mkv") == 1
|
||
|
||
removable = oper.register("local", "/downloads/removable.mkv")
|
||
assert removable is not None
|
||
assert oper.clear() == 1
|
||
assert oper.list_all() == [
|
||
("local", "/downloads/active.mkv"),
|
||
("local", "/downloads/expired.mkv"),
|
||
]
|
||
|
||
with legacy_session_factory() as session:
|
||
rows = session.execute(
|
||
select(TransferPending).order_by(TransferPending.id.asc())
|
||
).scalars().all()
|
||
assert [(row.task_id, row.lease_token) for row in rows] == [
|
||
(active.task_id, "active-token"),
|
||
(expired.task_id, "expired-token"),
|
||
]
|
||
|
||
|
||
def test_legacy_mutations_protect_every_execution_and_terminal_state(
|
||
legacy_session_factory,
|
||
) -> None:
|
||
"""
|
||
无租约的运行、等待、结算、失败和人工判定任务仍归状态机所有。
|
||
"""
|
||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||
oper = legacy.TransferPendingOper()
|
||
protected = {
|
||
state: oper.register("local", f"/downloads/{state}.mkv")
|
||
for state in (
|
||
"running",
|
||
"retry_wait",
|
||
"settling",
|
||
"failed",
|
||
"manual_review",
|
||
)
|
||
}
|
||
expired = oper.register("local", "/downloads/expired-running.mkv")
|
||
safe_discard = oper.register("local", "/downloads/safe-discard.mkv")
|
||
safe_clear = oper.register("local", "/downloads/safe-clear.mkv")
|
||
assert all(protected.values())
|
||
assert expired is not None
|
||
assert safe_discard is not None
|
||
assert safe_clear is not None
|
||
|
||
with legacy_session_factory() as session:
|
||
for state, row in protected.items():
|
||
assert row is not None
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == row.task_id)
|
||
.values(execution_state=state)
|
||
)
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == expired.task_id)
|
||
.values(
|
||
execution_state="running",
|
||
lease_owner="expired-worker",
|
||
lease_token="expired-token",
|
||
lease_expires_at="2026-08-27 09:59:00.000000",
|
||
heartbeat_at="2026-08-27 09:58:00.000000",
|
||
attempt_count=1,
|
||
)
|
||
)
|
||
session.commit()
|
||
|
||
assert oper.discard("local", safe_discard.src_path) == 1
|
||
for state, row in protected.items():
|
||
assert row is not None
|
||
assert oper.discard("local", row.src_path) == 0, state
|
||
assert oper.discard("local", expired.src_path) == 0
|
||
assert oper.clear() == 1
|
||
assert set(oper.list_all()) == {
|
||
("local", row.src_path)
|
||
for row in (*protected.values(), expired)
|
||
if row is not None
|
||
}
|
||
|
||
|
||
def test_legacy_mutations_protect_execution_evidence_even_if_not_started(
|
||
legacy_session_factory,
|
||
) -> None:
|
||
"""
|
||
状态字段异常回退时,claim、步骤、重试和结算证据仍阻止旧接口删除。
|
||
"""
|
||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||
oper = legacy.TransferPendingOper()
|
||
evidence_rows = {
|
||
name: oper.register("local", f"/downloads/evidence-{name}.mkv")
|
||
for name in (
|
||
"claim",
|
||
"checkpoint",
|
||
"retry",
|
||
"settlement",
|
||
"error",
|
||
"step",
|
||
"history",
|
||
)
|
||
}
|
||
safe = oper.register("local", "/downloads/evidence-free.mkv")
|
||
assert all(evidence_rows.values())
|
||
assert safe is not None
|
||
|
||
with legacy_session_factory() as session:
|
||
rows = {
|
||
name: row
|
||
for name, row in evidence_rows.items()
|
||
if row is not None
|
||
}
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == rows["claim"].task_id)
|
||
.values(attempt_count=1)
|
||
)
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == rows["checkpoint"].task_id)
|
||
.values(
|
||
execution_version=1,
|
||
execution_payload={"operation_ids": ["op-checkpoint"]},
|
||
execution_fingerprint="f" * 64,
|
||
)
|
||
)
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == rows["retry"].task_id)
|
||
.values(
|
||
retry_generation=1,
|
||
retry_count=1,
|
||
retry_due_at="2026-08-27 12:00:00.000000",
|
||
)
|
||
)
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == rows["settlement"].task_id)
|
||
.values(settlement_revision=1, terminal_history_id=42)
|
||
)
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == rows["error"].task_id)
|
||
.values(last_error="execution outcome unknown")
|
||
)
|
||
session.add(
|
||
TransferExecutionStep(
|
||
task_id=rows["step"].task_id,
|
||
operation_id="op-step-evidence",
|
||
checkpoint_fingerprint="c" * 64,
|
||
ordinal=0,
|
||
phase="materialize",
|
||
kind="copy",
|
||
state="prepared",
|
||
attempt_count=0,
|
||
intent_version=1,
|
||
intent_payload={"src": rows["step"].src_path},
|
||
prepared_at="2026-08-27 10:00:00.000000",
|
||
updated_at="2026-08-27 10:00:00.000000",
|
||
)
|
||
)
|
||
session.add(
|
||
TransferHistory(
|
||
transfer_task_id=rows["history"].task_id,
|
||
transfer_settlement_revision=1,
|
||
src=rows["history"].src_path,
|
||
src_storage="local",
|
||
status=True,
|
||
)
|
||
)
|
||
session.commit()
|
||
|
||
for name, row in evidence_rows.items():
|
||
assert row is not None
|
||
assert oper.discard("local", row.src_path) == 0, name
|
||
assert oper.clear() == 1
|
||
assert set(oper.list_all()) == {
|
||
("local", row.src_path)
|
||
for row in evidence_rows.values()
|
||
if row is not None
|
||
}
|
||
|
||
|
||
def test_legacy_register_returns_existing_terminal_or_manual_review_task(
|
||
legacy_session_factory,
|
||
) -> None:
|
||
"""
|
||
重复登记不得绕过失败或人工判定任务创建平行执行身份。
|
||
"""
|
||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||
oper = legacy.TransferPendingOper()
|
||
failed = oper.register("local", "/downloads/retry-failed.mkv")
|
||
manual = oper.register("local", "/downloads/retry-manual.mkv")
|
||
assert failed is not None
|
||
assert manual is not None
|
||
|
||
with legacy_session_factory() as session:
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == failed.task_id)
|
||
.values(
|
||
execution_state="failed",
|
||
settlement_revision=1,
|
||
terminal_history_id=81,
|
||
)
|
||
)
|
||
session.execute(
|
||
update(TransferPending)
|
||
.where(TransferPending.task_id == manual.task_id)
|
||
.values(
|
||
execution_state="manual_review",
|
||
last_error="provider outcome unknown",
|
||
)
|
||
)
|
||
session.commit()
|
||
|
||
repeated_failed = oper.register("local", failed.src_path)
|
||
repeated_manual = oper.register("local", manual.src_path)
|
||
assert repeated_failed is not None
|
||
assert repeated_failed.task_id == failed.task_id
|
||
assert repeated_failed.execution_state == "failed"
|
||
assert repeated_failed.terminal_history_id == 81
|
||
assert repeated_manual is not None
|
||
assert repeated_manual.task_id == manual.task_id
|
||
assert repeated_manual.execution_state == "manual_review"
|
||
assert repeated_manual.last_error == "provider outcome unknown"
|