mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 15:09:46 +08:00
441 lines
16 KiB
Python
441 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"
|