mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-15 21:01:10 +08:00
880 lines
36 KiB
Python
880 lines
36 KiB
Python
"""
|
||
外部整理登记的请求、只读文件证明和原子历史回执回归。
|
||
"""
|
||
|
||
import asyncio
|
||
import importlib
|
||
import os
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from dataclasses import FrozenInstanceError, replace
|
||
from functools import partial
|
||
from pathlib import Path
|
||
from threading import Barrier
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, Mock
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
from pydantic import ValidationError
|
||
from sqlalchemy import create_engine, select
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
from app.db.models.transferhistory import TransferHistory
|
||
from app.schemas import history as history_schema
|
||
from app.schemas.file import FileItem
|
||
|
||
|
||
def _request(**changes):
|
||
"""
|
||
构造管理员明确提供的媒体和路径声明。
|
||
"""
|
||
request_type = getattr(history_schema, "TransferHistoryRegisterRequest", None)
|
||
assert request_type is not None, "缺少外部整理登记请求"
|
||
payload = {
|
||
"src": "/downloads/a.mkv", "dest": "/library/a.mkv",
|
||
"src_storage": "local", "dest_storage": "local", "mode": "copy",
|
||
"type": "电影", "title": "A", "year": "2026",
|
||
"media_source": "tmdb", "media_id": "42",
|
||
}
|
||
return request_type.model_validate(payload | changes)
|
||
|
||
|
||
@pytest.mark.parametrize("mode", ["copy", "link", "move"])
|
||
def test_registration_request_accepts_only_explicit_metadata(mode):
|
||
"""
|
||
三种登记模式保留规范身份和路径,写入用例另行冻结声明。
|
||
"""
|
||
request = _request(mode=mode, media_id=" 42 ", src="/downloads//a.mkv")
|
||
assert request.media_id == "42"
|
||
assert request.src == "/downloads/a.mkv"
|
||
|
||
|
||
@pytest.mark.parametrize("changes", [
|
||
{"src": "relative.mkv"}, {"src": "/downloads/../a.mkv"},
|
||
{"src": "/bad\x00.mkv"}, {"dest": "/downloads/a.mkv"},
|
||
{"src": "/downloads/a.mkv\n"}, {"src_storage": "\tlocal"},
|
||
{"dest": "/library/\u0085a.mkv"},
|
||
{"src_storage": ""}, {"title": ""}, {"title": "x" * 513},
|
||
{"src": "/" + "x" * 4096}, {"dest_storage": "x" * 65},
|
||
{"media_id": "x" * 129}, {"download_hash": "x" * 129},
|
||
{"mode": "softlink"}, {"type": "音乐"}, {"year": "tomorrow"},
|
||
{"media_id": "0"}, {"media_source": None}, {"seasons": "season 1"},
|
||
{"episodes": "E00"}, {"actor_id": 99}, {"origin": "external"},
|
||
{"status": True}, {"src_fileitem": {}}, {"transfer_task_id": "task"},
|
||
])
|
||
def test_registration_request_rejects_ambiguous_or_injected_fields(changes):
|
||
"""
|
||
无法证明的路径和服务端拥有的身份、状态字段不得由请求注入。
|
||
"""
|
||
with pytest.raises(ValidationError):
|
||
_request(**changes)
|
||
|
||
|
||
@pytest.fixture
|
||
def registration_modules():
|
||
"""
|
||
加载待实现的显式登记用例与适配器。
|
||
"""
|
||
names = [
|
||
"app.application.history.registration",
|
||
"app.db.adapters.history.registration",
|
||
"app.adapters.system.registration",
|
||
]
|
||
for name in names:
|
||
assert importlib.util.find_spec(name) is not None, f"缺少登记模块 {name}"
|
||
return tuple(importlib.import_module(name) for name in names)
|
||
|
||
|
||
@pytest.fixture
|
||
def registration_store(tmp_path, registration_modules):
|
||
"""
|
||
为并发和回滚回归创建真实文件数据库和独立会话工厂。
|
||
"""
|
||
_, database, _ = registration_modules
|
||
engine = create_engine(
|
||
f"sqlite:///{tmp_path / 'registration.db'}",
|
||
connect_args={"check_same_thread": False, "timeout": 10},
|
||
)
|
||
TransferHistory.__table__.create(engine)
|
||
sessions = sessionmaker(engine)
|
||
yield sessions, database.TransferRegistrationScopeFactory(sessions)
|
||
engine.dispose()
|
||
|
||
|
||
def _local_query(storage, path):
|
||
"""
|
||
用真实文件系统模拟严格 provider 查询,不执行任何写操作。
|
||
"""
|
||
try:
|
||
info = path.stat()
|
||
except FileNotFoundError:
|
||
return None
|
||
return FileItem(
|
||
path=str(path), storage=storage, type="file" if path.is_file() else "dir",
|
||
size=info.st_size, modify_time=info.st_mtime,
|
||
fileid=f"{info.st_dev}:{info.st_ino}",
|
||
url="https://private.invalid/?passkey=secret",
|
||
)
|
||
|
||
|
||
def _command(modules, scope, query=_local_query):
|
||
"""
|
||
只注入只读查询和短事务能力,缺少删除、整理、下载和事件能力。
|
||
"""
|
||
application, _, local = modules
|
||
return application.TransferRegistrationCommand(
|
||
scope_factory=scope, query_file=query,
|
||
local_entry=partial(local.probe_local_entry, entry_factory=application.LocalRegistrationEntry),
|
||
)
|
||
|
||
|
||
def _actor(modules, name="admin"):
|
||
"""
|
||
模拟认证依赖返回的服务端管理员身份。
|
||
"""
|
||
return modules[0].RegistrationActor(id=1, name=name)
|
||
|
||
|
||
def test_copy_registers_frozen_facts_and_returns_original_receipt(
|
||
tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
登记只记录存在事实,重复请求保留最初审计人和时间且不重新探测文件。
|
||
"""
|
||
sessions, scope = registration_store
|
||
src, dest = tmp_path / "a.mkv", tmp_path / "b.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
request = _request(src=str(src), dest=str(dest))
|
||
command = _command(registration_modules, scope)
|
||
result = command.execute(request, _actor(registration_modules))
|
||
assert result.created
|
||
assert result.history.origin == "external"
|
||
assert result.history.transfer_task_id is None
|
||
assert result.history.transfer_settlement_revision is None
|
||
assert result.history.registration["verification"] == "matching_sizes"
|
||
assert result.history.registration["actor_name"] == "admin"
|
||
assert result.history.src_fileitem["size"] == 4
|
||
assert "url" not in result.history.src_fileitem
|
||
with pytest.raises(TypeError):
|
||
result.history.registration["actor_name"] = "changed"
|
||
src.unlink()
|
||
dest.unlink()
|
||
repeated = command.execute(request, _actor(registration_modules, "another"))
|
||
assert not repeated.created
|
||
assert repeated.history == result.history
|
||
with sessions() as session:
|
||
assert len(session.scalars(select(TransferHistory)).all()) == 1
|
||
|
||
|
||
def test_move_records_only_destination_facts(tmp_path, registration_modules, registration_store):
|
||
"""
|
||
移动后的源确实不存在时不伪造源大小、标识或内容一致证据。
|
||
"""
|
||
_, scope = registration_store
|
||
dest = tmp_path / "b.mkv"
|
||
dest.write_bytes(b"film")
|
||
src = tmp_path / "missing.mkv"
|
||
result = _command(registration_modules, scope).execute(
|
||
_request(src=str(src), dest=str(dest), mode="move"), _actor(registration_modules),
|
||
)
|
||
assert result.history.src_fileitem == {"path": str(src), "storage": "local"}
|
||
assert result.history.registration["verification"] == "destination_only"
|
||
assert result.history.registration["source_state"] == "absent"
|
||
|
||
|
||
@pytest.mark.parametrize("case", ["missing", "size", "directory", "symlink", "dangling_move", "present_move"])
|
||
def test_invalid_file_facts_cannot_create_history(
|
||
case, tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
缺失、不等大小、目录和符号链接不得变成成功历史。
|
||
"""
|
||
sessions, scope = registration_store
|
||
src, dest = tmp_path / "a.mkv", tmp_path / "b.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
mode = "copy"
|
||
if case == "missing":
|
||
dest.unlink()
|
||
elif case == "size":
|
||
dest.write_bytes(b"different")
|
||
elif case == "directory":
|
||
dest.unlink()
|
||
dest.mkdir()
|
||
elif case == "symlink":
|
||
dest.unlink()
|
||
dest.symlink_to(src)
|
||
elif case == "dangling_move":
|
||
src.unlink()
|
||
src.symlink_to(tmp_path / "missing")
|
||
mode = "move"
|
||
elif case == "present_move":
|
||
mode = "move"
|
||
with pytest.raises(registration_modules[0].RegistrationValidationError):
|
||
_command(registration_modules, scope).execute(
|
||
_request(src=str(src), dest=str(dest), mode=mode), _actor(registration_modules),
|
||
)
|
||
with sessions() as session:
|
||
assert session.scalars(select(TransferHistory)).all() == []
|
||
|
||
|
||
@pytest.mark.parametrize("mode", ["copy", "link"])
|
||
def test_copy_and_link_reject_confirmed_missing_source(
|
||
mode, tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
目标普通文件有效时,复制或硬链接仍须因源确认缺失而拒绝,且不创建文件或历史。
|
||
"""
|
||
sessions, scope = registration_store
|
||
src, dest = tmp_path / "missing.mkv", tmp_path / "dest.mkv"
|
||
dest.write_bytes(b"film")
|
||
before = dest.stat()
|
||
with pytest.raises(
|
||
registration_modules[0].RegistrationValidationError,
|
||
match="^源文件不存在,不能核验复制或硬链接$",
|
||
):
|
||
_command(registration_modules, scope).execute(
|
||
_request(src=str(src), dest=str(dest), mode=mode), _actor(registration_modules),
|
||
)
|
||
assert not src.exists()
|
||
assert dest.read_bytes() == b"film"
|
||
after = dest.stat()
|
||
assert (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns) == (
|
||
before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns,
|
||
)
|
||
with sessions() as session:
|
||
assert session.scalars(select(TransferHistory)).all() == []
|
||
|
||
|
||
def test_local_link_rejects_equal_size_files_with_distinct_inodes(
|
||
tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
两个等长普通文件具有不同 inode 时必须进入原生硬链接拒绝分支,保留原文件且不写历史。
|
||
"""
|
||
sessions, scope = registration_store
|
||
src, dest = tmp_path / "source.mkv", tmp_path / "dest.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
before = [path.stat() for path in (src, dest)]
|
||
assert before[0].st_size == before[1].st_size
|
||
assert (before[0].st_dev, before[0].st_ino) != (before[1].st_dev, before[1].st_ino)
|
||
with pytest.raises(
|
||
registration_modules[0].RegistrationValidationError,
|
||
match="^源文件与目标文件不是同一原生硬链接$",
|
||
):
|
||
_command(registration_modules, scope).execute(
|
||
_request(src=str(src), dest=str(dest), mode="link"), _actor(registration_modules),
|
||
)
|
||
for path, original in zip((src, dest), before):
|
||
assert path.read_bytes() == b"film"
|
||
after = path.stat()
|
||
assert (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns) == (
|
||
original.st_dev, original.st_ino, original.st_size, original.st_mtime_ns, original.st_ctime_ns,
|
||
)
|
||
with sessions() as session:
|
||
assert session.scalars(select(TransferHistory)).all() == []
|
||
|
||
|
||
@pytest.mark.parametrize("enabled", [False, True])
|
||
def test_local_entry_proof_uses_lstat_in_both_proxy_modes(
|
||
enabled, tmp_path, monkeypatch, registration_modules,
|
||
):
|
||
"""
|
||
开启和关闭代理都能识别最终符号链接及不存在状态。
|
||
"""
|
||
from app.adapters.system.fsproxy import FileSystemProxy
|
||
|
||
monkeypatch.setattr(FileSystemProxy, "_enabled", staticmethod(lambda: enabled))
|
||
src, alias = tmp_path / "a.mkv", tmp_path / "alias.mkv"
|
||
src.write_bytes(b"film")
|
||
alias.symlink_to(src)
|
||
probe = partial(
|
||
registration_modules[2].probe_local_entry, entry_factory=registration_modules[0].LocalRegistrationEntry,
|
||
)
|
||
assert probe(str(src)).is_file
|
||
assert probe(str(alias)).is_symlink
|
||
assert probe(str(tmp_path / "missing")) is None
|
||
|
||
|
||
def test_real_hardlink_is_accepted_but_aliases_are_rejected(
|
||
tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
硬链接须为不同目录项,父目录别名和仅大小写不同的同项均拒绝。
|
||
"""
|
||
_, scope = registration_store
|
||
folder = tmp_path / "real"
|
||
folder.mkdir()
|
||
src = folder / "Entry.mkv"
|
||
dest = folder / "other.mkv"
|
||
src.write_bytes(b"film")
|
||
os.link(src, dest)
|
||
command = _command(registration_modules, scope)
|
||
result = command.execute(
|
||
_request(src=str(src), dest=str(dest), mode="link"), _actor(registration_modules),
|
||
)
|
||
assert result.history.registration["verification"] == "native_hardlink"
|
||
alias = tmp_path / "alias"
|
||
alias.symlink_to(folder, target_is_directory=True)
|
||
for path in (alias / src.name, folder / "entry.mkv"):
|
||
with pytest.raises(registration_modules[0].RegistrationValidationError):
|
||
# 换一个独立 source identity,避免已登记记录提前返回冲突。
|
||
command._verify_files(_request(src=str(src), dest=str(path), mode="link"))
|
||
|
||
|
||
@pytest.mark.parametrize("mode", ["copy", "link"])
|
||
def test_unicode_equivalent_names_cannot_register_the_same_entry(
|
||
mode, tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
NFC/NFD 地址在支持规范等价的文件系统上必须作为同一目录项拒绝。
|
||
"""
|
||
application, _, local = registration_modules
|
||
sessions, scope = registration_store
|
||
src, alias = tmp_path / "caf\u00e9.mkv", tmp_path / "cafe\u0301.mkv"
|
||
src.write_bytes(b"film")
|
||
supports_alias = alias.exists()
|
||
if supports_alias:
|
||
assert src.samefile(alias)
|
||
probe = partial(local.probe_local_entry, entry_factory=application.LocalRegistrationEntry)
|
||
|
||
def local_entry(path):
|
||
"""
|
||
优先读取真实路径;区分规范形式的平台模拟同一目录项的第二种拼写。
|
||
"""
|
||
if supports_alias or path == str(src):
|
||
return probe(path)
|
||
return replace(probe(str(src)), name=Path(path).name)
|
||
|
||
def query(storage, path):
|
||
"""
|
||
让不支持规范等价的平台也提供同文件、同父目录的可复现查询事实。
|
||
"""
|
||
if supports_alias or path == src:
|
||
return _local_query(storage, path)
|
||
return _local_query(storage, src).model_copy(update={"path": str(path)})
|
||
|
||
command = application.TransferRegistrationCommand(
|
||
scope_factory=scope, query_file=query, local_entry=local_entry,
|
||
)
|
||
with pytest.raises(application.RegistrationValidationError):
|
||
command.execute(_request(src=str(src), dest=str(alias), mode=mode), _actor(registration_modules))
|
||
with sessions() as session:
|
||
assert session.scalars(select(TransferHistory)).all() == []
|
||
|
||
|
||
def test_concurrent_registration_returns_one_created_receipt(
|
||
tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
两线程独立 Session 必须由数据库唯一约束仲裁为同一回执。
|
||
"""
|
||
sessions, scope = registration_store
|
||
src, dest = tmp_path / "a.mkv", tmp_path / "b.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
request = _request(src=str(src), dest=str(dest))
|
||
barrier = Barrier(2)
|
||
|
||
def query(storage, path):
|
||
"""
|
||
两个独立读会话都确认无历史后才开始文件核验,强制覆盖原子插入竞争。
|
||
"""
|
||
if path == src:
|
||
barrier.wait(timeout=10)
|
||
return _local_query(storage, path)
|
||
|
||
command = _command(registration_modules, scope, query)
|
||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||
results = list(executor.map(
|
||
lambda _: command.execute(request, _actor(registration_modules)), range(2),
|
||
))
|
||
assert sorted(result.created for result in results) == [False, True]
|
||
assert results[0].history.id == results[1].history.id
|
||
with sessions() as session:
|
||
assert len(session.scalars(select(TransferHistory)).all()) == 1
|
||
|
||
|
||
@pytest.mark.parametrize("change", [
|
||
{"dest": "/other.mkv"}, {"title": "Other"}, {"mode": "move"},
|
||
])
|
||
def test_existing_source_is_never_replaced(
|
||
change, tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
同源的不同目标或媒体声明必须冲突,不能覆盖原审计。
|
||
"""
|
||
sessions, scope = registration_store
|
||
src, dest = tmp_path / "a.mkv", tmp_path / "b.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
request = _request(src=str(src), dest=str(dest))
|
||
command = _command(registration_modules, scope)
|
||
first = command.execute(request, _actor(registration_modules))
|
||
with pytest.raises(registration_modules[0].RegistrationConflictError):
|
||
command.execute(request.model_copy(update=change), _actor(registration_modules))
|
||
with sessions() as session:
|
||
assert session.get(TransferHistory, first.history.id).title == "A"
|
||
|
||
|
||
@pytest.mark.parametrize("active,superuser,manage,expected", [
|
||
(True, True, False, 200), (True, False, False, 403),
|
||
(True, False, True, 403), (False, True, True, 401),
|
||
])
|
||
def test_registration_route_requires_active_superuser(
|
||
active, superuser, manage, expected, tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
路由调用真实认证依赖;仅 manage 用户和未激活管理员均不能登记。
|
||
"""
|
||
from app.api.dependencies import auth
|
||
from app.api.dependencies import history as dependencies
|
||
from app.api.endpoints import history
|
||
|
||
dependency = getattr(dependencies, "get_transfer_registration_command", None)
|
||
assert dependency is not None, "缺少登记依赖"
|
||
_, scope = registration_store
|
||
src, dest = tmp_path / "a.mkv", tmp_path / "b.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
application = FastAPI()
|
||
application.include_router(history.router, prefix="/api/v1/history")
|
||
application.dependency_overrides[auth.get_current_user] = lambda: SimpleNamespace(
|
||
id=7, name="server-admin", is_active=active, is_superuser=superuser, permissions={"manage": manage},
|
||
)
|
||
application.dependency_overrides[dependency] = lambda: _command(registration_modules, scope)
|
||
with TestClient(application) as client:
|
||
response = client.post(
|
||
"/api/v1/history/transfer",
|
||
json=_request(src=str(src), dest=str(dest)).model_dump(mode="json"),
|
||
)
|
||
assert response.status_code == expected
|
||
if expected == 200:
|
||
body = response.json()["data"]
|
||
assert body["created"]
|
||
assert body["history"]["registration"]["actor_id"] == 7
|
||
assert body["history"]["registration"]["actor_name"] == "server-admin"
|
||
|
||
|
||
@pytest.mark.parametrize("name,display", [
|
||
("n" * 128, "n" * 128), ("名" * 129, "名" * 127 + "…"),
|
||
("", "#7"), ("admin\n", "admin\n"),
|
||
], ids=["maximum", "long", "empty", "control"])
|
||
def test_registration_command_preserves_identity_with_bounded_actor_display(
|
||
name, display, tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
既有合法用户名用有界显示快照登记,真实身份和重试回执均保持最初值。
|
||
"""
|
||
from app.schemas.user import UserCreate
|
||
|
||
user = UserCreate(name=name, is_active=True, is_superuser=True)
|
||
_, scope = registration_store
|
||
src, dest = tmp_path / "source.mkv", tmp_path / "dest.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
request = _request(src=str(src), dest=str(dest))
|
||
command = _command(registration_modules, scope)
|
||
first = command.execute(request, registration_modules[0].RegistrationActor(id=7, name=user.name))
|
||
assert first.created
|
||
assert first.history.registration["actor_id"] == 7
|
||
assert first.history.registration["actor_name"] == display
|
||
assert 1 <= len(first.history.registration["actor_name"]) <= 128
|
||
src.unlink()
|
||
dest.unlink()
|
||
repeated = command.execute(request, registration_modules[0].RegistrationActor(id=8, name="another"))
|
||
assert not repeated.created
|
||
assert repeated.history == first.history
|
||
|
||
|
||
@pytest.mark.parametrize("name,display", [
|
||
("n" * 128, "n" * 128), ("名" * 129, "名" * 127 + "…"),
|
||
("", "#7"), ("admin\n", "admin\n"),
|
||
], ids=["maximum", "long", "empty", "control"])
|
||
def test_registration_http_accepts_existing_admin_name_contract(
|
||
name, display, tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
真实认证依赖接受的长名和空名不得导致 500,HTTP 重试保留首次身份和显示快照。
|
||
"""
|
||
from app.api.dependencies import auth
|
||
from app.api.dependencies import history as dependencies
|
||
from app.api.endpoints import history
|
||
from app.schemas.user import UserCreate
|
||
|
||
user = UserCreate(name=name, is_active=True, is_superuser=True)
|
||
actor = SimpleNamespace(id=7, name=user.name, is_active=user.is_active, is_superuser=user.is_superuser)
|
||
sessions, scope = registration_store
|
||
src, dest = tmp_path / "source.mkv", tmp_path / "dest.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
payload = _request(src=str(src), dest=str(dest)).model_dump(mode="json")
|
||
application = FastAPI()
|
||
application.include_router(history.router, prefix="/api/v1/history")
|
||
application.dependency_overrides[auth.get_current_user] = lambda: actor
|
||
application.dependency_overrides[dependencies.get_transfer_registration_command] = lambda: _command(
|
||
registration_modules, scope,
|
||
)
|
||
with TestClient(application, raise_server_exceptions=False) as client:
|
||
response = client.post("/api/v1/history/transfer", json=payload)
|
||
assert response.status_code == 200
|
||
first = response.json()["data"]
|
||
assert first["created"]
|
||
assert first["history"]["registration"]["actor_id"] == 7
|
||
assert first["history"]["registration"]["actor_name"] == display
|
||
assert 1 <= len(first["history"]["registration"]["actor_name"]) <= 128
|
||
src.unlink()
|
||
dest.unlink()
|
||
actor.id, actor.name = 8, "another"
|
||
response = client.post("/api/v1/history/transfer", json=payload)
|
||
assert response.status_code == 200
|
||
repeated = response.json()["data"]
|
||
assert not repeated["created"]
|
||
assert repeated["history"] == first["history"]
|
||
with sessions() as session:
|
||
rows = session.scalars(select(TransferHistory)).all()
|
||
assert len(rows) == 1
|
||
assert rows[0].registration["actor_id"] == 7
|
||
assert rows[0].registration["actor_name"] == display
|
||
|
||
|
||
@pytest.mark.parametrize("title", [None, "Movie", "*.mkv"])
|
||
@pytest.mark.parametrize("status", [None, True, False])
|
||
def test_external_origin_filter_count_and_page_agree(title, status, registration_store):
|
||
"""
|
||
来源过滤贯穿普通标题、通配标题、状态和分页,旧查询仍包含未记录来源行。
|
||
"""
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
|
||
from app.application.history import HistoryQueryService
|
||
from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository
|
||
|
||
sessions, _ = registration_store
|
||
with sessions() as session:
|
||
for number, (origin, successful) in enumerate([
|
||
(None, True), ("external", True), ("external", True), ("external", False),
|
||
]):
|
||
session.add(TransferHistory(
|
||
src=f"/src/{number}.mkv", src_storage="local", dest=f"/dest/{number}.mkv",
|
||
title="Movie", origin=origin, status=successful, date=f"2026-10-0{number + 1}",
|
||
))
|
||
session.commit()
|
||
engine = create_async_engine(str(sessions.kw["bind"].url).replace("sqlite:", "sqlite+aiosqlite:"))
|
||
async_sessions = async_sessionmaker(engine)
|
||
repository = TransactionalTransferHistoryRepository(sync_session=sessions, async_session=async_sessions)
|
||
service = HistoryQueryService(download_repository=None, transfer_repository=repository)
|
||
|
||
async def check():
|
||
"""
|
||
在同一事件循环验证 count、page 和未过滤查询。
|
||
"""
|
||
try:
|
||
result = await service.list_transfer(title=title, status=status, origin="external", page=1, count=1)
|
||
assert result.total == (3 if status is None else 2 if status else 1)
|
||
assert len(result.list) == 1
|
||
assert result.list[0].origin == "external"
|
||
ordinary = await service.list_transfer(title=title, status=status)
|
||
assert ordinary.total == (4 if status is None else 3 if status else 1)
|
||
finally:
|
||
await engine.dispose()
|
||
|
||
asyncio.run(check())
|
||
|
||
|
||
@pytest.mark.parametrize("origin", [None, "external"])
|
||
def test_history_route_passes_only_explicit_origin_to_query(origin):
|
||
"""
|
||
真实路由保留旧查询的参数省略形态,显式外部来源仍完整传给查询服务。
|
||
"""
|
||
from app.api.dependencies import history as dependencies
|
||
from app.api.endpoints import history
|
||
|
||
query = SimpleNamespace(list_transfer=AsyncMock(return_value={"list": [], "total": 0}))
|
||
application = FastAPI()
|
||
application.include_router(history.router, prefix="/api/v1/history")
|
||
application.dependency_overrides[history.verify_token] = lambda: None
|
||
application.dependency_overrides[dependencies.get_history_query_service] = lambda: query
|
||
parameters = {"title": "Movie*", "page": 2, "count": 5, "status": "true"}
|
||
expected = {"title": "Movie*", "page": 2, "count": 5, "status": True}
|
||
if origin is not None:
|
||
parameters["origin"] = origin
|
||
expected["origin"] = origin
|
||
with TestClient(application) as client:
|
||
response = client.get("/api/v1/history/transfer", params=parameters)
|
||
assert response.status_code == 200
|
||
assert response.json()["data"] == {"list": [], "total": 0}
|
||
query.list_transfer.assert_awaited_once_with(**expected)
|
||
|
||
|
||
def test_registration_mcp_route_is_admin_idempotent_private_write():
|
||
"""
|
||
MCP 只提供登记业务入口,声明私人结果和可重试的管理员数据库写操作。
|
||
"""
|
||
from app.agent.policy.api import API_OPERATION_ROUTES, API_OPERATION_SPECS
|
||
from app.agent.policy.contracts import ActionEffect, PrincipalRole, RecoveryMode
|
||
|
||
route = API_OPERATION_ROUTES.get("transfer.history.register")
|
||
assert route is not None
|
||
assert route.method == "POST"
|
||
assert route.path == "/api/v1/history/transfer"
|
||
spec = next(spec for spec in API_OPERATION_SPECS if spec.operation_id == "transfer.history.register")
|
||
assert spec.effect == ActionEffect.REVERSIBLE_WRITE
|
||
assert spec.recovery == RecoveryMode.IDEMPOTENT
|
||
assert spec.required_role == PrincipalRole.SYSTEM_ADMIN
|
||
|
||
|
||
def test_registration_atomic_insert_rejects_unknown_dialect(monkeypatch, registration_store):
|
||
"""
|
||
未明确支持的数据库不能退化为先查后插或替换写入。
|
||
"""
|
||
from app.db.oper.transferhistory import TransferHistoryOper
|
||
|
||
sessions, _ = registration_store
|
||
with sessions() as session:
|
||
monkeypatch.setattr(session.get_bind().dialect, "name", "unsupported")
|
||
with pytest.raises(RuntimeError, match="不支持原子"):
|
||
TransferHistoryOper(session).stage_insert_if_source_absent({
|
||
"src": "/a.mkv", "src_storage": "local",
|
||
})
|
||
|
||
|
||
@pytest.mark.parametrize("provide_async_session", [False, True])
|
||
def test_registration_stage_requires_explicit_sync_session(provide_async_session, monkeypatch):
|
||
"""
|
||
原子登记拒绝缺失或异步调用方会话,不得自行创建会话、执行语句或提交事务。
|
||
"""
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.oper.transferhistory import TransferHistoryOper
|
||
|
||
supplied_session = AsyncSession() if provide_async_session else None
|
||
database_access = Mock(side_effect=AssertionError("拒绝无同步会话的登记时不得访问数据库"))
|
||
try:
|
||
with monkeypatch.context() as guarded:
|
||
for session_type in (Session, AsyncSession):
|
||
for method in ("__init__", "execute", "flush", "commit"):
|
||
guarded.setattr(session_type, method, database_access)
|
||
oper = TransferHistoryOper(supplied_session)
|
||
with pytest.raises(RuntimeError, match="^登记整理历史需要调用方提供同步 Session$"):
|
||
oper.stage_insert_if_source_absent({"src": "/a.mkv", "src_storage": "local"})
|
||
database_access.assert_not_called()
|
||
finally:
|
||
if supplied_session is not None:
|
||
asyncio.run(supplied_session.close())
|
||
|
||
|
||
def test_registration_accepts_canonical_series_identity():
|
||
"""
|
||
电视剧的季集范围与非 TMDB 原生媒体身份完整保留。
|
||
"""
|
||
request = _request(type="电视剧", media_source="imdb", media_id="tt1234567", seasons="S01", episodes="E01-E12")
|
||
assert request.media_id == "tt1234567"
|
||
assert request.seasons == "S01"
|
||
assert request.episodes == "E01-E12"
|
||
|
||
|
||
def test_move_accepts_missing_source_parent(tmp_path, registration_modules, registration_store):
|
||
"""
|
||
移动后源父目录已清理也属于确认不存在,不要求收集该父目录的身份。
|
||
"""
|
||
_, scope = registration_store
|
||
src, dest = tmp_path / "removed-parent" / "source.mkv", tmp_path / "dest.mkv"
|
||
dest.write_bytes(b"film")
|
||
result = _command(registration_modules, scope).execute(
|
||
_request(src=str(src), dest=str(dest), mode="move"), _actor(registration_modules),
|
||
)
|
||
assert result.history.registration["source_state"] == "absent"
|
||
|
||
|
||
@pytest.mark.parametrize("phase", ["stage", "commit"])
|
||
def test_registration_rolls_back_failed_write(
|
||
phase, tmp_path, monkeypatch, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
写入或提交异常后数据库没有半条成功历史,调用方也拿不到成功回执。
|
||
"""
|
||
from app.db.uow import SqlAlchemyUnitOfWork
|
||
|
||
sessions, scope = registration_store
|
||
src, dest = tmp_path / "a.mkv", tmp_path / "b.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
if phase == "stage":
|
||
repository = registration_modules[1].SessionTransferRegistrationRepository
|
||
original = repository.insert_if_absent
|
||
|
||
def fail_after_insert(self, write):
|
||
"""
|
||
在真实 INSERT 之后模拟后续投影失败。
|
||
"""
|
||
original(self, write)
|
||
raise RuntimeError("stage failed")
|
||
|
||
monkeypatch.setattr(repository, "insert_if_absent", fail_after_insert)
|
||
else:
|
||
def fail_commit(_self):
|
||
"""
|
||
在真实事务提交前模拟失败。
|
||
"""
|
||
raise RuntimeError("commit failed")
|
||
|
||
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
|
||
with pytest.raises(RuntimeError, match=f"{phase} failed"):
|
||
_command(registration_modules, scope).execute(
|
||
_request(src=str(src), dest=str(dest)), _actor(registration_modules),
|
||
)
|
||
with sessions() as session:
|
||
assert session.scalars(select(TransferHistory)).all() == []
|
||
|
||
|
||
@pytest.mark.parametrize("existing", [
|
||
{"origin": None, "status": True},
|
||
{"origin": "external", "status": False},
|
||
{"origin": "external", "status": True, "transfer_task_id": "durable-task"},
|
||
])
|
||
def test_existing_internal_failure_and_task_history_are_protected(
|
||
existing, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
无论目标事实如何,都不得替换内部、失败或持久任务绑定历史。
|
||
"""
|
||
sessions, scope = registration_store
|
||
with sessions() as session:
|
||
record = TransferHistory(src="/downloads/a.mkv", src_storage="local", title="original", **existing)
|
||
session.add(record)
|
||
session.commit()
|
||
original_id = record.id
|
||
with pytest.raises(registration_modules[0].RegistrationConflictError):
|
||
_command(registration_modules, scope).execute(_request(), _actor(registration_modules))
|
||
with sessions() as session:
|
||
rows = session.scalars(select(TransferHistory)).all()
|
||
assert len(rows) == 1
|
||
assert rows[0].id == original_id
|
||
assert rows[0].title == "original"
|
||
|
||
|
||
@pytest.mark.parametrize("case", ["unsupported", "error", "wrong_path", "wrong_storage", "size", "nan", "remote_link"])
|
||
def test_unknown_or_inconsistent_provider_facts_fail_safely(
|
||
case, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
远端 None、异常和错误文件项不能混同,云盘相同 fileid 也不能证明原生硬链接。
|
||
"""
|
||
from app.schemas.exception import StorageQueryError
|
||
|
||
sessions, scope = registration_store
|
||
|
||
def query(storage, path):
|
||
"""
|
||
模拟有明确失败或不一致回执的远端提供方。
|
||
"""
|
||
if case in ("unsupported", "error"):
|
||
raise StorageQueryError("private URL https://secret.invalid/?apikey=secret")
|
||
changes = {
|
||
"wrong_path": {"path": "/other.mkv"}, "wrong_storage": {"storage": "other"},
|
||
"size": {"size": -1}, "nan": {"modify_time": float("nan")},
|
||
}.get(case, {})
|
||
return FileItem.model_validate({
|
||
"path": str(path), "storage": storage, "type": "file", "size": 4, "fileid": "same",
|
||
} | changes)
|
||
|
||
with pytest.raises(registration_modules[0].RegistrationValidationError) as error:
|
||
_command(registration_modules, scope, query).execute(
|
||
_request(src_storage="u115", dest_storage="u115", mode="link" if case == "remote_link" else "copy"),
|
||
_actor(registration_modules),
|
||
)
|
||
assert "secret" not in str(error.value)
|
||
with sessions() as session:
|
||
assert session.scalars(select(TransferHistory)).all() == []
|
||
|
||
|
||
def test_external_declaration_is_frozen_before_file_io(
|
||
tmp_path, registration_modules, registration_store,
|
||
):
|
||
"""
|
||
文件查询回调改变原请求也不能篡改已经冻结的媒体声明。
|
||
"""
|
||
_, scope = registration_store
|
||
src, dest = tmp_path / "a.mkv", tmp_path / "b.mkv"
|
||
src.write_bytes(b"film")
|
||
dest.write_bytes(b"film")
|
||
request = _request(src=str(src), dest=str(dest))
|
||
declaration = registration_modules[0].TransferRegistrationDeclaration.from_request(request)
|
||
with pytest.raises(FrozenInstanceError):
|
||
declaration.title = "changed"
|
||
|
||
def query(storage, path):
|
||
"""
|
||
模拟调用方在 I/O 期间仍持有并改动请求对象。
|
||
"""
|
||
request.title = "changed"
|
||
return _local_query(storage, path)
|
||
|
||
result = _command(registration_modules, scope, query).execute(request, _actor(registration_modules))
|
||
assert result.history.title == "A"
|
||
|
||
|
||
def test_registration_migration_preserves_old_rows_and_unique_source(tmp_path):
|
||
"""
|
||
迁移可重放且保留旧行 NULL 来源、源地址唯一约束,降级不丢历史行。
|
||
"""
|
||
from alembic.migration import MigrationContext
|
||
from alembic.operations import Operations
|
||
from sqlalchemy import Column, Index, Integer, MetaData, String, Table, inspect
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
path = Path("database/versions/b849e613a72f_external_history_registration.py")
|
||
spec = importlib.util.spec_from_file_location("registration_migration", path)
|
||
migration = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(migration)
|
||
engine = create_engine(f"sqlite:///{tmp_path / 'migration.db'}")
|
||
metadata = MetaData()
|
||
table = Table(
|
||
"transferhistory", metadata, Column("id", Integer, primary_key=True),
|
||
Column("src", String), Column("src_storage", String),
|
||
Index("ux_transferhistory_src_storage", "src", "src_storage", unique=True),
|
||
)
|
||
metadata.create_all(engine)
|
||
with engine.begin() as connection:
|
||
connection.execute(table.insert().values(src="/a.mkv", src_storage="local"))
|
||
migration.op = Operations(MigrationContext.configure(connection))
|
||
migration.upgrade()
|
||
migration.upgrade()
|
||
reflected = Table("transferhistory", MetaData(), autoload_with=connection)
|
||
row = connection.execute(select(reflected)).one()
|
||
assert row.origin is None and row.registration is None
|
||
connection.execute(reflected.insert().values(
|
||
src="/b.mkv", src_storage="local", origin="external", registration={"actor_id": 1},
|
||
))
|
||
with pytest.raises(IntegrityError):
|
||
connection.execute(reflected.insert().values(src="/a.mkv", src_storage="local"))
|
||
migration.downgrade()
|
||
assert {column["name"] for column in inspect(connection).get_columns("transferhistory")} == {
|
||
"id", "src", "src_storage",
|
||
}
|
||
assert len(connection.execute(select(table)).all()) == 2
|
||
engine.dispose()
|
||
|
||
|
||
@pytest.mark.parametrize("locale", ["en-US", "zh-TW"])
|
||
def test_registration_errors_have_translations(locale):
|
||
"""
|
||
登记的权限外错误也提供英文和繁体中文,不能向调用方泄漏存储原始异常。
|
||
"""
|
||
from app.runtime.localization import LocaleHelper
|
||
|
||
for message in (
|
||
"该源地址已有内部整理或失败历史,不能覆盖",
|
||
"该源地址的历史不属于可重复的外部登记",
|
||
"该源地址已有不同目标或媒体声明的历史,不能覆盖",
|
||
"无法确认存储文件状态,请检查存储后重试",
|
||
"外部登记只接受普通文件,不接受符号链接或目录",
|
||
"移动登记要求源文件已经不存在",
|
||
"当前仅支持核验本地存储的原生硬链接",
|
||
):
|
||
assert LocaleHelper.translate_text(message, locale=locale) != message
|