diff --git a/common/pyproject.toml b/common/pyproject.toml index 1a1068f..5f32862 100644 --- a/common/pyproject.toml +++ b/common/pyproject.toml @@ -31,3 +31,7 @@ default = true dev = [ "pytest>=9.1.1", ] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/common/tests/__init__.py b/common/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/tests/storage/__init__.py b/common/tests/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/tests/storage/test_factory.py b/common/tests/storage/test_factory.py new file mode 100644 index 0000000..5c1079a --- /dev/null +++ b/common/tests/storage/test_factory.py @@ -0,0 +1,134 @@ +"""create_storage 工厂测试:覆盖 mode 字段严格拒绝 + 正常路径。""" + +from common.storage.base import AsyncStorageBackend +from common.storage.exceptions import StorageConfigError +from common.storage.factory import ( + PURPOSE_BUCKETS, + USAGE_TYPE_TO_PURPOSE, + build_storage_config, + create_storage, +) + + +# ── mode 字段严格拒绝 ────────────────────────────────────────────── + + +def test_create_storage_rejects_mode_sync(): + """显式 mode='sync' 现在必须拒绝 —— 同步抽象已砍掉。""" + import pytest + + with pytest.raises(StorageConfigError) as exc_info: + create_storage({"type": "local", "base_dir": "/tmp", "mode": "sync"}) + msg = str(exc_info.value) + assert "不再接受 'mode' 字段" in msg + + +def test_create_storage_rejects_mode_async(): + """显式 mode='async' 也必须拒绝 —— 只有 async 一条路,不需要再声明。""" + import pytest + + with pytest.raises(StorageConfigError) as exc_info: + create_storage({"type": "s3", "bucket": "x", "mode": "async"}) + msg = str(exc_info.value) + assert "不再接受 'mode' 字段" in msg + + +def test_create_storage_rejects_any_mode_value(): + """任何 mode 字段(包含未来可能新增的合法值)都拒绝 —— 简化语义。""" + import pytest + + for value in ("async", "sync", "dual", ""): + with pytest.raises(StorageConfigError): + create_storage({"type": "local", "base_dir": "/tmp", "mode": value}) + + +# ── 正常路径 ──────────────────────────────────────────────────────── + + +def test_create_storage_returns_async_subclass(): + s = create_storage({"type": "local", "base_dir": "/tmp/cc-factory-test"}) + assert isinstance(s, AsyncStorageBackend) + # 确认是真 AsyncStorageBackend,不是同步兼容形态 + assert type(s).__name__ == "LocalStorageBackend" + + +def test_create_storage_missing_type_raises(): + import pytest + + with pytest.raises(StorageConfigError) as exc_info: + create_storage({"base_dir": "/tmp"}) + assert "缺少 'type' 字段" in str(exc_info.value) + + +def test_create_storage_unknown_type_raises(): + import pytest + + with pytest.raises(StorageConfigError) as exc_info: + create_storage({"type": "nonexistent"}) + assert "未知的存储后端" in str(exc_info.value) + + +def test_create_storage_does_not_mutate_input_dict(): + """调用方传入的字典不能被改写。""" + cfg = {"type": "local", "base_dir": "/tmp/cc-no-mutate"} + cfg_id = id(cfg) + snapshot = dict(cfg) + create_storage(cfg) + assert dict(cfg) == snapshot, "factory should not mutate caller's dict" + assert id(cfg) == cfg_id + + +def test_create_storage_kwargs_mismatch_raises_wrapped_error(): + import pytest + + with pytest.raises(StorageConfigError) as exc_info: + create_storage({"type": "local", "base_dir": 12345}) # base_dir 必须是 str + msg = str(exc_info.value) + assert "参数不匹配" in msg + + +# ── build_storage_config 不再产生 mode 字段 ───────────────────────── + + +def test_build_storage_config_local_has_no_mode(monkeypatch): + """local 分支的输出 dict 不能含 'mode'。""" + from common import config as common_config + + monkeypatch.setattr(common_config.settings, "storage_backend", "local", raising=False) + monkeypatch.setattr(common_config.settings, "local_storage_base_dir", "/tmp", raising=False) + + cfg = build_storage_config("workspace") + assert "mode" not in cfg, f"local cfg must not have 'mode', got: {cfg}" + assert cfg["type"] == "local" + assert "base_dir" in cfg + + +def test_build_storage_config_s3_has_no_mode(monkeypatch): + """s3 分支的输出 dict 也不能含 'mode'。""" + from common import config as common_config + + monkeypatch.setattr(common_config.settings, "storage_backend", "s3", raising=False) + monkeypatch.setattr(common_config.settings, "s3_workspace_bucket", "wb", raising=False) + monkeypatch.setattr(common_config.settings, "s3_endpoint", "http://s3", raising=False) + monkeypatch.setattr(common_config.settings, "s3_access_key", "ak", raising=False) + monkeypatch.setattr(common_config.settings, "s3_secret_key", "sk", raising=False) + + cfg = build_storage_config("workspace") + assert "mode" not in cfg, f"s3 cfg must not have 'mode', got: {cfg}" + assert cfg["type"] == "s3" + + +def test_build_storage_config_unknown_bucket_raises(): + import pytest + + from common.storage.exceptions import StorageConfigError + with pytest.raises(StorageConfigError): + build_storage_config("not-a-real-bucket") + + +def test_purpose_buckets_constant_complete(): + """PURPOSE_BUCKETS 必须覆盖 USAGE_TYPE_TO_PURPOSE 中所有 purpose。""" + purposes = set(USAGE_TYPE_TO_PURPOSE.values()) + assert purposes.issubset(set(PURPOSE_BUCKETS)), ( + f"missing buckets for purposes: {purposes - set(PURPOSE_BUCKETS)}" + ) \ No newline at end of file diff --git a/common/tests/storage/test_registry.py b/common/tests/storage/test_registry.py new file mode 100644 index 0000000..34be20a --- /dev/null +++ b/common/tests/storage/test_registry.py @@ -0,0 +1,71 @@ +"""后端注册表测试:覆盖 register / get / 内置 backend 注册。""" + +from common.storage.base import AsyncStorageBackend +from common.storage.exceptions import StorageConfigError +from common.storage.registry import ( + get_backend_class, + register_backend, + registered_backends, +) + + +def test_local_and_s3_are_registered_at_import_time(): + """import common.storage 应该触发 local / s3 的注册。""" + backend_classes = registered_backends() + assert "local" in backend_classes + assert "s3" in backend_classes + for cls in backend_classes.values(): + assert issubclass(cls, AsyncStorageBackend) + + +def test_get_backend_class_returns_async_subclass(): + cls = get_backend_class("local") + assert issubclass(cls, AsyncStorageBackend) + + +def test_get_backend_class_unknown_raises(): + import pytest + + with pytest.raises(StorageConfigError) as exc_info: + get_backend_class("does-not-exist") + assert "未知的存储后端" in str(exc_info.value) + assert "does-not-exist" in str(exc_info.value) + + +def test_register_backend_idempotent_for_same_class(): + """同一个类对象重复注册是 no-op,不抛错(``is`` 比对,避免重复 import 时误冲突)。""" + from common.storage.registry import _REGISTRY + + class _SameAgain(AsyncStorageBackend): + pass + + _REGISTRY["same-again-test"] = _SameAgain + + # 第二次装饰同一个类对象:当前实现里装饰器返回 cls 并把 _REGISTRY[name] 重新写一遍。 + # 直接重新调用 register_backend("same-again-test")(_SameAgain) 不抛错即可。 + fn = register_backend("same-again-test") + result = fn(_SameAgain) + assert result is _SameAgain + assert _REGISTRY["same-again-test"] is _SameAgain + + _REGISTRY.pop("same-again-test", None) + + +def test_register_backend_conflict_raises(): + import pytest + + @register_backend("conflict-test") + class A(AsyncStorageBackend): + pass + + with pytest.raises(StorageConfigError) as exc_info: + + @register_backend("conflict-test") + class B(AsyncStorageBackend): + pass + + assert "已被注册" in str(exc_info.value) + + # cleanup:避免污染全局 registry 影响其他测试 + from common.storage.registry import _REGISTRY + _REGISTRY.pop("conflict-test", None) \ No newline at end of file