Files
MoviePilot/tests/test_smb_storage.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

655 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
SMB 保存重连、服务端整理与远程快照恢复的回归测试。
"""
import errno
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import smbclient
from app.application.storage import StorageHelper
from app.modules.filemanager.module import FileManagerModule
from app.modules.filemanager.storages import smb as smb_module
from app.modules.filemanager.transhandler import TransHandler
from app.monitor.poller import RemotePoller
from app.monitor.snapshot import SnapshotStore
from app.schemas.file import FileItem
@pytest.fixture
def smb_storage(monkeypatch):
"""
隔离配置持久化与 SMB 网络边界,保留真实保存和初始化流程。
"""
state = {"conf": {}}
client = Mock()
client.listdir.return_value = []
for name in ("ClientConfig", "register_session", "listdir", "reset_connection_cache"):
monkeypatch.setattr(smbclient, name, getattr(client, name))
def get_conf(_self):
"""
读取最近一次保存的配置。
"""
return state["conf"].copy()
def set_storage(_self, storage, conf):
"""
记录配置,确保重连发生在新配置保存之后。
"""
assert storage == "smb"
state["conf"] = conf.copy()
# 存储发现会重载实现模块,按当前模块取类,避免保留收集阶段的旧类型身份。
monkeypatch.setattr(smb_module.SMB, "get_conf", get_conf)
monkeypatch.setattr(StorageHelper, "set_storage", set_storage)
storage = object.__new__(smb_module.SMB)
smb_module.SMB.__init__(storage)
module = object.__new__(FileManagerModule)
module._support_storages = ["smb"]
monkeypatch.setattr(module, "_FileManagerModule__get_storage_oper", Mock(return_value=storage))
yield SimpleNamespace(storage=storage, module=module, client=client, state=state)
storage._connected = False
@pytest.mark.parametrize("failure_stage", ["register_session", "listdir"])
def test_save_retries_unchanged_config_until_connection_recovers(smb_storage, failure_stage):
"""
连接或共享访问失败后,每次保存相同配置都重连,网络恢复即可使用。
"""
conf = {"host": "smb.example", "share": "video", "username": "user", "password": "secret"}
getattr(smb_storage.client, failure_stage).side_effect = [OSError("unreachable"), OSError("unreachable"), []]
for connected in (False, False, True):
smb_storage.client.reset_mock()
result = smb_storage.module.storage_manage(storage="smb", action="save_config", conf=conf)
assert result["success"] is True
assert smb_storage.state["conf"] == conf
assert smb_storage.storage._connected is connected
calls = [call[0] for call in smb_storage.client.mock_calls]
assert calls[:3] == ["reset_connection_cache", "ClientConfig", "register_session"]
smb_storage.client.register_session.assert_called_once_with(
"smb.example", username="user", password="secret", port=445, encrypt=False, connection_timeout=60,
)
if connected:
smb_storage.storage._check_connection()
smb_storage.client.listdir.assert_called_once_with(r"\\smb.example\video")
else:
with pytest.raises(smb_module.SMBConnectionError, match="连接未建立"):
smb_storage.storage._check_connection()
@pytest.mark.parametrize("conf", [{}, {"host": "smb.example"}, {"share": "video"}])
def test_save_incomplete_config_clears_previous_connection(smb_storage, conf):
"""
清空或保存不完整配置后,不得继续把旧共享标记为已连接。
"""
smb_storage.module.storage_manage(
storage="smb", action="save_config", conf={"host": "smb.example", "share": "video"},
)
assert smb_storage.storage._connected is True
smb_storage.client.reset_mock()
smb_storage.module.storage_manage(storage="smb", action="save_config", conf=conf)
assert smb_storage.storage._connected is False
assert smb_storage.storage._server_path is None
smb_storage.client.reset_connection_cache.assert_called_once_with()
smb_storage.client.register_session.assert_not_called()
with pytest.raises(smb_module.SMBConnectionError, match="连接未建立"):
smb_storage.storage._check_connection()
@pytest.fixture
def server_transfer(smb_storage, monkeypatch):
"""
拦截 SMB 命令并禁止本地中转,保留真实存储传输分派。
"""
storage = smb_storage.storage
smb_storage.state["conf"] = {"host": "10.10.10.11", "share": "data"}
storage.init_storage()
client = Mock()
for name in ("copyfile", "rename", "link", "makedirs"):
monkeypatch.setattr(smbclient, name, getattr(client, name))
monkeypatch.setattr(smbclient.path, "samefile", Mock(return_value=False))
monkeypatch.setattr(smbclient.path, "exists", Mock(return_value=True))
source = FileItem(
storage="smb", path="/downloads/Movie/movie.mkv", name="movie.mkv",
type="file", size=1024, extension="mkv",
)
target = source.model_copy(update={"path": "/media/Movie/renamed.mkv", "name": "renamed.mkv"})
monkeypatch.setattr(storage, "get_folder", Mock(return_value=FileItem(
storage="smb", path="/media/Movie/", type="dir",
)))
monkeypatch.setattr(storage, "get_item", Mock(return_value=target))
for name in ("download", "upload", "delete"):
monkeypatch.setattr(storage, name, Mock(side_effect=AssertionError("禁止中转或删除源文件")))
return SimpleNamespace(storage=storage, client=client, source=source, target=target)
@pytest.mark.parametrize("mode, command", [("copy", "copyfile"), ("move", "rename"), ("link", "link")])
def test_same_smb_storage_transfers_on_server(server_transfer, mode, command):
"""
真实整理执行器分派到 SMB 服务端命令,保留源、目标存储身份。
"""
state = server_transfer
result, error = TransHandler._TransHandler__transfer_command(
fileitem=state.source, target_storage="smb", source_oper=state.storage,
target_oper=state.storage, target_file=Path(state.target.path), transfer_type=mode,
)
assert result == state.target
assert error == ""
getattr(state.client, command).assert_called_once_with(
r"\\10.10.10.11\data\downloads\Movie\movie.mkv",
r"\\10.10.10.11\data\media\Movie\renamed.mkv",
)
state.storage.download.assert_not_called()
state.storage.upload.assert_not_called()
state.storage.delete.assert_not_called()
@pytest.mark.parametrize("mode", ["copy", "move", "link"])
@pytest.mark.parametrize("fails", [False, True])
def test_safe_overwrite_preserves_old_smb_file_until_new_version_is_complete(
server_transfer, monkeypatch, mode, fails,
):
"""
真实 SMB 复制和 rename 分派必须兼容目标已存在且拒绝覆盖的服务端。
"""
storage = server_transfer.storage
source = server_transfer.source
target = Path(server_transfer.target.path)
normalize = storage._normalize_path
files = {normalize(source.path): b"new", normalize(target): b"old"}
modified_times = {normalize(source.path): 200, normalize(target): 100}
def stat_file(path):
"""
按 SMB SDK 契约提供真实长度和修改时间,缺失路径抛 ENOENT。
"""
if path not in files:
raise FileNotFoundError(errno.ENOENT, "missing")
return SimpleNamespace(st_size=len(files[path]), st_mtime=modified_times[path])
def materialize(src, dst):
"""
在写入新版本时旧版本必须仍完整存在,失败也只留下暂存片段。
"""
assert files[normalize(target)] == b"old"
assert dst not in files
files[dst] = b"partial" if fails else files[src]
modified_times[dst] = modified_times[src] if mode == "link" else 300
if fails:
raise OSError(errno.ENOSPC, "server full")
def rename(src, dst):
"""
模拟 SMB rename 拒绝任何已存在的目的路径。
"""
assert dst not in files
files[dst] = files.pop(src)
modified_times[dst] = modified_times.pop(src)
def remove(path):
"""
只在最终新版本可见后允许清理旧备份或移动源文件。
"""
assert files[normalize(target)] == b"new"
files.pop(path)
modified_times.pop(path)
# 保留 SMB 文件投影和删除实现,避免 source 快照与 SDK 状态大小/时间互相矛盾。
monkeypatch.setattr(storage, "get_item", smb_module.SMB.get_item.__get__(storage))
monkeypatch.setattr(storage, "delete", smb_module.SMB.delete.__get__(storage))
monkeypatch.setattr(smbclient, "stat", stat_file)
monkeypatch.setattr(smbclient.path, "isdir", Mock(return_value=False))
monkeypatch.setattr(smbclient.path, "exists", Mock(side_effect=lambda path: path in files))
monkeypatch.setattr(smbclient, "remove", remove)
monkeypatch.setattr(smbclient, "copyfile", materialize)
monkeypatch.setattr(smbclient, "link", materialize)
monkeypatch.setattr(smbclient, "rename", rename)
source = storage.get_item_strict(Path(source.path))
assert source.size == len(b"new")
assert source.modify_time == 200
# 真实 SMB FileItem 当前不提供远端文件 ID。
assert source.fileid is None
kwargs = dict(
step_runner=None, source_fileitem=source.model_dump(mode="json"),
target_storage="smb", source_oper=storage, target_oper=storage,
target_file=target, transfer_type=mode,
)
if fails:
with pytest.raises(RuntimeError):
TransHandler._TransHandler__replace_target_with_steps(**kwargs)
assert files[normalize(target)] == b"old"
assert files[normalize(source.path)] == b"new"
else:
result, error = TransHandler._TransHandler__replace_target_with_steps(**kwargs)
assert result.path == target.as_posix()
assert not error
assert files[normalize(target)] == b"new"
assert (normalize(source.path) in files) is (mode != "move")
assert all(".mp-" not in path for path in files)
@pytest.mark.parametrize("mode, command", [("copy", "copyfile"), ("move", "rename"), ("link", "link")])
@pytest.mark.parametrize("error_number", [errno.ENOTSUP, errno.EACCES, errno.ECONNRESET, errno.EXDEV, errno.EEXIST])
def test_server_transfer_failure_never_falls_back_or_deletes_source(server_transfer, mode, command, error_number):
"""
不支持、拒绝访问及连接失败均保留源文件,不伪装成中转成功。
"""
state = server_transfer
getattr(state.client, command).side_effect = OSError(error_number, "SMB operation failed")
result, error = TransHandler._TransHandler__transfer_command(
fileitem=state.source, target_storage="smb", source_oper=state.storage,
target_oper=state.storage, target_file=Path(state.target.path), transfer_type=mode,
)
assert result is None
assert error
state.storage.download.assert_not_called()
state.storage.upload.assert_not_called()
state.storage.delete.assert_not_called()
def test_server_copy_rejects_same_file_without_truncation(server_transfer, monkeypatch):
"""
源目标实际为同一文件或硬链接时,不能让 CopyChunk 的写入打开截断源。
"""
monkeypatch.setattr(smbclient.path, "samefile", Mock(return_value=True))
state = server_transfer
assert state.storage.copy(state.source, Path("/media/Movie"), "renamed.mkv") is False
state.client.copyfile.assert_not_called()
def test_server_copy_accepts_new_target(server_transfer, monkeypatch):
"""
目标尚不存在是正常复制场景,不把它当成查询故障。
"""
monkeypatch.setattr(smbclient.path, "samefile", Mock(side_effect=OSError(errno.ENOENT, "not found")))
state = server_transfer
assert state.storage.copy(state.source, Path("/media/Movie"), "renamed.mkv") is True
state.client.copyfile.assert_called_once()
def test_server_copy_query_failure_does_not_overwrite_target(server_transfer, monkeypatch):
"""
无法确认目标身份时不启动可能覆盖文件的复制。
"""
monkeypatch.setattr(smbclient.path, "samefile", Mock(side_effect=PermissionError("denied")))
state = server_transfer
assert state.storage.copy(state.source, Path("/media/Movie"), "renamed.mkv") is False
state.client.copyfile.assert_not_called()
@pytest.fixture
def multi_share(server_transfer):
"""
在同一个 SMB 服务中挂载两个共享,拦截所有文件副作用。
"""
state = server_transfer
state.storage._configure_shares({"shares": ["video", "downloads"]})
state.source.path = "/downloads/Movie/movie.mkv"
state.target.path = "/video/Movie/renamed.mkv"
return state
def test_multiple_shares_share_one_session_and_virtual_root(smb_storage):
"""
保存多共享仅建立一次会话,根目录返回所有已配置共享。
"""
smb_storage.module.storage_manage(storage="smb", action="save_config", conf={
"host": "10.10.10.11", "shares": ["video", "downloads"],
})
storage = smb_storage.storage
smb_storage.client.register_session.assert_called_once()
assert [call.args[0] for call in smb_storage.client.listdir.call_args_list] == [
r"\\10.10.10.11\video", r"\\10.10.10.11\downloads",
]
smb_storage.client.listdir.reset_mock()
items = storage.list(FileItem(path="/", type="dir", storage="smb"))
assert [(item.name, item.path, item.storage) for item in items] == [
("video", "/video/", "smb"), ("downloads", "/downloads/", "smb"),
]
smb_storage.client.listdir.assert_not_called()
assert storage.get_item_strict(Path("/")).type == "dir"
@pytest.mark.parametrize("shares", [[], "video,downloads", ["../video"], ["video", "VIDEO"], [None], [""]])
def test_invalid_share_config_clears_old_mounts(smb_storage, shares):
"""
非法多共享配置不能继续访问之前的共享,也不能退回旧 share 字段。
"""
smb_storage.module.storage_manage(storage="smb", action="save_config", conf={"host": "host", "share": "old"})
smb_storage.module.storage_manage(storage="smb", action="save_config", conf={"host": "host", "share": "old", "shares": shares})
assert smb_storage.storage._connected is False
assert smb_storage.storage._server_path is None
assert smb_storage.storage._shares == {}
def test_single_legacy_share_keeps_paths_and_multi_mode_survives_one_share(smb_storage):
"""
旧配置路径不变,多共享减至一项也不会重新解释已保存路径。
"""
storage = smb_storage.storage
smb_storage.module.storage_manage(storage="smb", action="save_config", conf={"host": "host", "share": "video"})
assert storage._normalize_path("/Movies/a.mkv") == r"\\host\video\Movies\a.mkv"
smb_storage.module.storage_manage(storage="smb", action="save_config", conf={"host": "host", "shares": ["video"]})
assert storage._normalize_path("/video/Movies/a.mkv") == r"\\host\video\Movies\a.mkv"
assert storage._relative_path(r"\\host\video\Movies\a.mkv") == "/video/Movies/a.mkv"
@pytest.mark.parametrize("path", ["/unknown/a.mkv", "/downloads/../video/a.mkv", r"\downloads\..\video\a.mkv", "smb:/video/a"])
def test_multi_share_rejects_unknown_or_escaping_paths(multi_share, path):
"""
共享选择受配置约束,不能通过路径跳转到未配置的共享。
"""
with pytest.raises(ValueError):
multi_share.storage._normalize_path(path)
@pytest.mark.parametrize("path", ["/", "/video/", "/downloads/"])
def test_multi_share_roots_cannot_be_deleted_or_renamed(multi_share, path, monkeypatch):
"""
共享根是挂载入口,禁止把删除目录变成清空整个共享。
"""
storage = multi_share.storage
remove = Mock()
monkeypatch.setattr(smbclient, "rmdir", remove)
item = FileItem(storage="smb", path=path, type="dir")
assert smb_module.SMB.delete(storage, item) is False
assert storage.rename(item, "renamed") is False
remove.assert_not_called()
multi_share.client.rename.assert_not_called()
def test_multi_share_listing_and_strict_query_preserve_share_name(multi_share, monkeypatch):
"""
浏览和整理查询都返回包含共享名称的路径,不混淆同名文件。
"""
monkeypatch.setattr(smbclient, "listdir", Mock(return_value=["movie.mkv"]))
monkeypatch.setattr(smbclient, "stat", Mock(return_value=SimpleNamespace(st_size=1024, st_mtime=1)))
monkeypatch.setattr(smbclient.path, "isdir", Mock(return_value=False))
items = multi_share.storage.list(FileItem(storage="smb", path="/downloads/", type="dir"))
assert items[0].path == "/downloads/movie.mkv"
assert multi_share.storage.get_item_strict(Path("/video/movie.mkv")).path == "/video/movie.mkv"
@pytest.mark.parametrize("mode", ["copy", "move"])
def test_cross_share_transfer_uses_copychunk_then_optional_delete(multi_share, monkeypatch, mode):
"""
跨共享只调用服务端 CopyChunk,移动严格在复制完成之后删除源。
"""
state = multi_share
monkeypatch.setattr(smbclient, "stat", Mock(side_effect=FileNotFoundError(errno.ENOENT, "missing")))
monkeypatch.setattr(smbclient, "remove", state.client.remove)
state.client.reset_mock()
assert getattr(state.storage, mode)(state.source, Path("/video/Movie"), "renamed.mkv") is True
state.client.copyfile.assert_called_once_with(
r"\\10.10.10.11\downloads\Movie\movie.mkv", r"\\10.10.10.11\video\Movie\renamed.mkv",
)
assert [call[0] for call in state.client.mock_calls] == (["copyfile", "remove"] if mode == "move" else ["copyfile"])
state.storage.download.assert_not_called()
state.storage.upload.assert_not_called()
@pytest.mark.parametrize("failure", ["target_exists", "target_denied", "copy", "remove"])
def test_cross_share_move_failure_is_conservative(multi_share, monkeypatch, failure):
"""
目标冲突、查询失败和复制失败不删源;删源失败报错并保留已复制结果。
"""
state = multi_share
stat = Mock(side_effect=FileNotFoundError(errno.ENOENT, "missing"))
if failure == "target_exists":
stat.side_effect = None
elif failure == "target_denied":
stat.side_effect = PermissionError(errno.EACCES, "denied")
monkeypatch.setattr(smbclient, "stat", stat)
monkeypatch.setattr(smbclient, "remove", state.client.remove)
if failure in ("copy", "remove"):
getattr(state.client, "copyfile" if failure == "copy" else "remove").side_effect = OSError("failed")
assert state.storage.move(state.source, Path("/video/Movie"), "renamed.mkv") is False
assert state.client.remove.call_count == (1 if failure == "remove" else 0)
assert state.client.copyfile.call_count == (1 if failure in ("copy", "remove") else 0)
state.storage.download.assert_not_called()
state.storage.upload.assert_not_called()
def test_cross_share_hardlink_fails_without_side_effects(multi_share):
"""
标准 SMB 不支持跨共享硬链接,不能偷偷改为复制或中转。
"""
assert multi_share.storage.link(multi_share.source, Path(multi_share.target.path)) is False
multi_share.client.link.assert_not_called()
multi_share.client.copyfile.assert_not_called()
multi_share.client.makedirs.assert_not_called()
@pytest.mark.parametrize("conf, share", [
({"share": "video"}, "video"),
({"shares": ["video"]}, "video"),
({"shares": ["video", "downloads"]}, "video"),
({"shares": ["downloads", "video"]}, "downloads"),
])
def test_storage_usage_reports_first_share_without_double_counting(multi_share, monkeypatch, conf, share):
"""
单共享兼容旧容量合同,多共享按首项返回可用容量,不能重复累计卷空间。
"""
multi_share.storage._configure_shares(conf)
stat_volume = Mock(return_value=SimpleNamespace(
total_size=4096, caller_available_size=1024, actual_available_size=2048,
))
monkeypatch.setattr(smbclient, "stat_volume", stat_volume)
result = multi_share.storage.usage()
assert result.total == 4096
assert result.available == 1024
stat_volume.assert_called_once_with(f"\\\\10.10.10.11\\{share}")
def test_multi_share_usage_query_failure_remains_unavailable(multi_share, monkeypatch):
"""
首项不可查询时保留失败语义,不能悄悄改用另一共享的容量。
"""
stat_volume = Mock(side_effect=PermissionError("denied"))
monkeypatch.setattr(smbclient, "stat_volume", stat_volume)
assert multi_share.storage.usage() is None
stat_volume.assert_called_once_with(r"\\10.10.10.11\video")
@pytest.fixture
def snapshot_server(smb_storage, monkeypatch):
"""
仅替换 SMB SDK 的文件系统边界,保留真实目录映射、文件项构造和快照遍历。
"""
storage = smb_storage.storage
smb_storage.state["conf"] = {"host": "smb.example", "share": "media"}
storage.init_storage()
tree = {
"/downloads": {"directory": True, "mtime": 50},
"/downloads/Series": {"directory": True, "mtime": 50},
"/downloads/Series/Season01": {"directory": True, "mtime": 50},
"/downloads/Series/Season01/old.mkv": {"directory": False, "mtime": 100, "size": 10},
}
fault = {"operation": None, "path": None}
def relative(path):
return storage._relative_path(path).rstrip("/")
def read_node(operation, path):
key = relative(path)
if fault == {"operation": operation, "path": key}:
raise OSError(errno.EIO, "temporary SMB read failure")
if key not in tree:
raise FileNotFoundError(errno.ENOENT, "missing")
return key, tree[key]
def list_directory(path):
key, _node = read_node("list", path)
return [Path(item).name for item in tree if Path(item).parent.as_posix() == key]
def stat_item(path):
_key, node = read_node("stat", path)
return SimpleNamespace(st_mtime=node["mtime"], st_size=node.get("size", 0))
def is_directory(path):
_key, node = read_node("type", path)
return node["directory"]
smb_storage.client.listdir.side_effect = list_directory
monkeypatch.setattr(smbclient, "stat", Mock(side_effect=stat_item))
monkeypatch.setattr(smbclient.path, "isdir", Mock(side_effect=is_directory))
monkeypatch.setattr(smbclient.path, "exists", Mock(side_effect=lambda path: relative(path) in tree))
return SimpleNamespace(
storage=storage, module=smb_storage.module, client=smb_storage.client,
tree=tree, fault=fault, root=Path("/downloads"),
)
@pytest.mark.parametrize("multiple_shares", [False, True])
@pytest.mark.parametrize("change", ["added", "modified", "moved_old", "deleted"])
def test_snapshot_detects_descendant_changes_without_parent_mtime(snapshot_server, multiple_shares, change):
"""
SMB 祖先目录时间不变时,单共享与多共享仍能发现后代新增、修改、旧文件移入和删除。
"""
state = snapshot_server
if multiple_shares:
state.storage._configure_shares({"shares": ["downloads", "library"]})
old_path = "/downloads/Series/Season01/old.mkv"
new_path = "/downloads/Series/Season01/new.mkv"
baseline = state.storage.snapshot(state.root)
assert set(baseline) == {old_path}
if change in {"added", "moved_old"}:
state.tree[new_path] = {
"directory": False, "mtime": 200 if change == "added" else 1, "size": 20,
}
elif change == "modified":
state.tree[old_path].update(size=20, mtime=200)
else:
del state.tree[old_path]
snapshot = state.module.snapshot_storage(
"smb", state.root, last_snapshot_time=100, previous_snapshot=baseline,
)
assert snapshot is not None
changes = SnapshotStore.compare(baseline, snapshot)
expected = {"added": [], "modified": []}
if change == "deleted":
assert old_path not in snapshot
else:
expected["added" if change == "moved_old" else change] = [
new_path if change in {"added", "moved_old"} else old_path
]
assert changes == expected
assert baseline[old_path]["size"] == 10
@pytest.mark.parametrize(
("operation", "path"),
[
("stat", "/downloads"),
("list", "/downloads"),
("list", "/downloads/Series/Season01"),
("stat", "/downloads/Series/Season01/old.mkv"),
("type", "/downloads/Series"),
],
)
def test_snapshot_query_failure_is_not_an_empty_or_partial_tree(snapshot_server, operation, path):
"""
根目录、子目录或文件元数据读取失败时,整个监控路径都不能提交为有效快照。
"""
state = snapshot_server
baseline = state.storage.snapshot(state.root)
state.fault.update(operation=operation, path=path)
assert state.module.snapshot_storage(
"smb", state.root, last_snapshot_time=0, previous_snapshot=baseline,
) is None
assert len(baseline) == 1
def test_disconnected_snapshot_preserves_failure_semantics(snapshot_server):
"""
连接失效不能冒充目录已删除;普通文件浏览仍沿用空列表回退。
"""
state = snapshot_server
state.storage._connected = False
assert state.module.snapshot_storage("smb", state.root) is None
assert state.storage.list(FileItem(storage="smb", type="dir", path="/downloads")) == []
@pytest.mark.parametrize("root_missing", [False, True])
def test_confirmed_empty_snapshot_is_success(snapshot_server, root_missing):
"""
确认目录已清空或根目录已删除时应返回空快照,而不是失败。
"""
state = snapshot_server
baseline = state.storage.snapshot(state.root)
if root_missing:
state.tree.clear()
else:
del state.tree["/downloads/Series/Season01/old.mkv"]
assert state.module.snapshot_storage(
"smb", state.root, last_snapshot_time=100, previous_snapshot=baseline,
) == {}
def test_snapshot_keeps_depth_limit(snapshot_server):
"""
遍历不因 mtime 剪枝,但仍遵守调用方的递归深度预算。
"""
state = snapshot_server
state.client.listdir.reset_mock()
assert state.storage.snapshot(state.root, max_depth=2) == {}
assert state.client.listdir.call_count == 2
def test_poll_retries_failed_smb_scan_without_losing_baseline(snapshot_server, monkeypatch):
"""
SMB 断线时保留旧基线,恢复后发现保留旧 mtime 的移入文件且只派发一次。
"""
state = snapshot_server
records = {}
cache = Mock()
cache.get.side_effect = lambda key, **_kwargs: records.get(key)
cache.set.side_effect = lambda key, value, **_kwargs: records.__setitem__(key, value)
store = SnapshotStore(cache=cache)
baseline = state.storage.snapshot(state.root)
assert store.save("smb", baseline, file_count=1, last_snapshot_time=100)
saved_baseline = store.load("smb")
dispatcher = Mock()
dispatcher.is_transfer_candidate_path.return_value = True
dispatcher.handle_file.return_value = True
chain = SimpleNamespace(snapshot_storage=state.module.snapshot_storage)
monkeypatch.setattr("app.monitor.poller.StorageChain", lambda: chain)
poller = RemotePoller(store=store, dispatcher=dispatcher)
new_path = "/downloads/Series/Season01/new.mkv"
state.tree[new_path] = {"directory": False, "mtime": 1, "size": 20}
state.fault.update(operation="list", path="/downloads/Series/Season01")
assert poller.poll("smb", [state.root]) is None
assert store.load("smb") == saved_baseline
dispatcher.handle_file.assert_not_called()
state.fault.update(operation=None, path=None)
assert poller.poll("smb", [state.root]) == 2
dispatcher.handle_file.assert_called_once()
assert dispatcher.handle_file.call_args.kwargs["event_path"] == Path(new_path)
assert poller.poll("smb", [state.root]) == 2
dispatcher.handle_file.assert_called_once()