"""群文件打包/投递 (group_file.py) 单元测试 group_file.py 只依赖标准库 + nonebot.logger,故用 importlib 按文件路径裸加载 (与 test_video_policy.py 同法)。build_archive 一律显式传 out_dir —— 默认目录 要经相对导入取 utils.get_temp_root(),裸加载模块没有包上下文。 """ import importlib.util import sys import zipfile from pathlib import Path import pytest _MODULE_PATH = ( Path(__file__).resolve().parents[1] / "hexi" / "plugins" / "nonebot_plugin_video_analysis" / "services" / "storage" / "group_file.py" ) @pytest.fixture(scope="module") def gf(): """以独立模块名加载 group_file.py,避免与插件包 __init__ 冲突""" spec = importlib.util.spec_from_file_location( "video_group_file_under_test", _MODULE_PATH ) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) yield module sys.modules.pop(spec.name, None) def _make_files(directory: Path, names: list[str]) -> list[Path]: directory.mkdir(parents=True, exist_ok=True) made = [] for name in names: path = directory / name path.write_bytes(f"hello {name}".encode()) made.append(path) return made def test_plain_archive(gf, tmp_path): files = _make_files(tmp_path / "src", ["a.txt", "b.txt"]) archive = gf.build_archive(files, "我的作品", out_dir=tmp_path / "out") assert archive.exists() and archive.suffix == ".zip" assert "我的作品" in archive.name with zipfile.ZipFile(archive) as zf: assert sorted(zf.namelist()) == ["a.txt", "b.txt"] assert zf.read("a.txt") == b"hello a.txt" def test_encrypted_archive_requires_password(gf, tmp_path): pyzipper = pytest.importorskip("pyzipper") files = _make_files(tmp_path / "src", ["v.mp4"]) archive = gf.build_archive( files, "加密作品", password="pw123", out_dir=tmp_path / "out" ) # 标准库打不开加密包 with zipfile.ZipFile(archive) as zf: with pytest.raises(RuntimeError): zf.read("v.mp4") # 正确密码可读,错误密码不行 with pyzipper.AESZipFile(archive) as zf: zf.setpassword(b"pw123") assert zf.read("v.mp4") == b"hello v.mp4" with pyzipper.AESZipFile(archive) as zf: zf.setpassword(b"wrong") with pytest.raises(RuntimeError): zf.read("v.mp4") def test_duplicate_names_indexed(gf, tmp_path): first = _make_files(tmp_path / "p1", ["same.txt"])[0] second = _make_files(tmp_path / "p2", ["same.txt"])[0] archive = gf.build_archive([first, second], "t", out_dir=tmp_path / "out") with zipfile.ZipFile(archive) as zf: assert sorted(zf.namelist()) == ["same.txt", "same_2.txt"] assert zf.read("same_2.txt") == b"hello same.txt" def test_output_path_is_unique(gf, tmp_path): files = _make_files(tmp_path / "src", ["a.txt"]) out = tmp_path / "out" one = gf.build_archive(files, "t", out_dir=out) two = gf.build_archive(files, "t", out_dir=out) assert one != two and one.exists() and two.exists() def test_placeholder_title_falls_back(gf, tmp_path): """universal.py 传的 title 是占位符 "title",不该出现在文件名里""" files = _make_files(tmp_path / "src", ["a.txt"]) archive = gf.build_archive(files, "title", out_dir=tmp_path / "out") assert archive.name.startswith("群文件_") assert "群文件_群文件" not in archive.name def test_title_with_path_separators_is_sanitized(gf, tmp_path): """原始标题(抖音文案/YouTube 标题)可能带 /、换行、#话题,不能进文件名""" files = _make_files(tmp_path / "src", ["a.txt"]) archive = gf.build_archive(files, "和/或 #话题\n测试", out_dir=tmp_path / "out") assert "/" not in archive.name and "\\" not in archive.name assert "\n" not in archive.name and "#" not in archive.name assert archive.name.startswith("和_或_测试") def test_title_starting_with_archive_prefix_falls_back(gf, tmp_path): """标题本身以「群文件」开头时不要产出 群文件_xxx_群文件_yyy.zip""" files = _make_files(tmp_path / "src", ["a.txt"]) archive = gf.build_archive(files, "群文件_120606", out_dir=tmp_path / "out") assert archive.name.startswith("群文件_") assert "群文件_120606_群文件" not in archive.name def test_empty_or_missing_input(gf, tmp_path): with pytest.raises(ValueError): gf.build_archive([], "t", out_dir=tmp_path / "out") with pytest.raises(FileNotFoundError): gf.build_archive([tmp_path / "nope.mp4"], "t", out_dir=tmp_path / "out") def test_password_without_pyzipper_fails_loud(gf, tmp_path, monkeypatch): """设了密码但加密库不可用 → 报错(调用方会放弃上传,绝不能退化传明文)""" monkeypatch.setattr(gf, "pyzipper", None) assert gf.encryption_available() is False files = _make_files(tmp_path / "src", ["a.txt"]) with pytest.raises(RuntimeError): gf.build_archive(files, "t", password="pw", out_dir=tmp_path / "out") # 不加密那一路不受影响 assert gf.build_archive(files, "t", out_dir=tmp_path / "out").exists() # ───────────────────── 上传:URI 直传 + S3 降级 ───────────────────── def test_file_uri_keeps_unicode_and_uses_slashes(gf, tmp_path): target = tmp_path / "dir with space" / "群文件_120606.zip" target.parent.mkdir(parents=True) target.write_bytes(b"x") uri = gf._file_uri(target) assert uri.startswith("file:///") assert "\\" not in uri # 中文/空格保持原样(不做百分号转义,NapCat 侧直接当路径解析) assert uri.endswith("/dir with space/群文件_120606.zip") async def test_s3_link_is_the_primary_channel(gf, tmp_path, monkeypatch): """默认通道就是 S3 链接(本环境本地直传必挂,不先浪费一次调用)""" calls: list = [] link = "http://192.168.2.15:5246/PLANA/x.zip?sig=1" async def fake_call(file_value, group_id, name, folder_id=None): calls.append((file_value, group_id, name)) monkeypatch.setattr(gf, "_call_upload", fake_call) monkeypatch.setattr(gf, "_s3_url", lambda path, policy: link) file = _make_files(tmp_path / "src", ["a.zip"])[0] assert await gf.upload_group_file(file, 123) is True assert calls == [(link, 123, "a.zip")] async def test_s3_call_failure_falls_back_to_local(gf, tmp_path, monkeypatch): seen: list[str] = [] link = "http://192.168.2.15:5246/PLANA/x.zip?sig=1" async def fake_call(file_value, group_id, name, folder_id=None): seen.append(file_value) if file_value.startswith("http"): raise RuntimeError("链接不可达") monkeypatch.setattr(gf, "_call_upload", fake_call) monkeypatch.setattr(gf, "_s3_url", lambda path, policy: link) file = _make_files(tmp_path / "src", ["a.zip"])[0] assert await gf.upload_group_file(file, 123) is True assert seen[0] == link assert seen[1] == gf._file_uri(file) async def test_no_s3_link_falls_back_to_local(gf, tmp_path, monkeypatch): seen: list[str] = [] async def fake_call(file_value, group_id, name, folder_id=None): seen.append(file_value) monkeypatch.setattr(gf, "_call_upload", fake_call) monkeypatch.setattr(gf, "_s3_url", lambda path, policy: "") file = _make_files(tmp_path / "src", ["a.zip"])[0] assert await gf.upload_group_file(file, 123) is True assert seen == [gf._file_uri(file)] async def test_both_transports_fail(gf, tmp_path, monkeypatch): async def always_fail(*args, **kwargs): raise RuntimeError("nope") monkeypatch.setattr(gf, "_call_upload", always_fail) monkeypatch.setattr(gf, "_s3_url", lambda path, policy: "http://x/y.zip") file = _make_files(tmp_path / "src", ["a.zip"])[0] assert await gf.upload_group_file(file, 123) is False async def test_missing_file_short_circuits(gf, tmp_path, monkeypatch): called = [] monkeypatch.setattr(gf, "_call_upload", lambda *a, **kw: called.append(1)) assert await gf.upload_group_file(tmp_path / "nope.zip", 123) is False assert called == [] # ───────────────────────── 投递编排 ───────────────────────── def _record_uploads(gf, monkeypatch, path: Path, ok: bool = True) -> list: calls: list = [] async def fake_upload(file_path, group_id, **kwargs): calls.append((str(file_path), group_id)) return ok monkeypatch.setattr(gf, "upload_group_file", fake_upload) return calls async def test_zip_mode_uploads_single_archive(gf, tmp_path, monkeypatch): archive = tmp_path / "pack.zip" archive.write_bytes(b"zip") calls = _record_uploads(gf, monkeypatch, archive) monkeypatch.setattr( gf, "build_archive", lambda *a, **kw: archive ) files = _make_files(tmp_path / "src", ["a.txt", "b.txt"]) ok = await gf.upload_group_files(files, 123, title="t", password="pw") assert ok is True assert calls == [(str(archive), 123)] async def test_raw_mode_uploads_each_file(gf, tmp_path, monkeypatch): calls = _record_uploads(gf, monkeypatch, tmp_path) files = _make_files(tmp_path / "src", ["a.txt", "b.txt"]) ok = await gf.upload_group_files(files, 123, zip_files=False) assert ok is True assert [c[0] for c in calls] == [str(p) for p in files] async def test_raw_mode_prefixes_work_name_for_multi_files(gf, tmp_path, monkeypatch): """多图作品逐个传时,001.jpg 这类成员名要带上作品名(单文件不加)""" seen: list = [] async def fake_upload(file_path, group_id, **kwargs): seen.append(kwargs.get("name")) return True monkeypatch.setattr(gf, "upload_group_file", fake_upload) images = _make_files(tmp_path / "src", ["001.jpg", "002.jpg"]) await gf.upload_group_files( images, 123, zip_files=False, rel_dir="作者_9/海边日落" ) assert seen == ["海边日落_001.jpg", "海边日落_002.jpg"] seen.clear() single = _make_files(tmp_path / "src2", ["作品.mp4"]) await gf.upload_group_files(single, 123, zip_files=False, rel_dir="作者_9") assert seen == ["作品.mp4"] # 单文件时文件名本身就是作品名,不再加前缀 async def test_pack_failure_skips_upload_entirely(gf, tmp_path, monkeypatch): """打包失败(例如缺 pyzipper)时不能退化成上传原文件""" calls = _record_uploads(gf, monkeypatch, tmp_path) def boom(*args, **kwargs): raise RuntimeError("pyzipper 未安装") monkeypatch.setattr(gf, "build_archive", boom) files = _make_files(tmp_path / "src", ["a.txt"]) ok = await gf.upload_group_files(files, 123, title="t", password="pw") assert ok is False assert calls == [] async def test_empty_file_list(gf, tmp_path, monkeypatch): calls = _record_uploads(gf, monkeypatch, tmp_path) assert await gf.upload_group_files([], 123) is False assert calls == []