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

244 lines
8.4 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.
"""
大体积二进制缓存的后端路由测试。
启用 Redis 缓存后端时,图片、种子、远程目录快照等二进制载荷必须留在本地
文件系统或进程内有界内存,小体积结构化缓存仍走 Redis。所有 Redis 访问均由
内存替身承接,不产生真实网络连接。
"""
import asyncio
import json
from types import SimpleNamespace
from typing import Any, Dict, Optional, Tuple
import pytest
import app.adapters.cache.redis as redis_adapter_module
import app.application.image as image_module
import app.monitor.snapshot as snapshot_module
from app.adapters.cache.backends import AsyncFileBackend, FileBackend
from app.adapters.cache.redis import AsyncRedisBackend, RedisBackend
from app.foundation.singleton import Singleton
from app.monitor.snapshot import SnapshotStore
from app.runtime.cache import (
AsyncCache,
AsyncFileCache,
AsyncMemoryBackend,
Cache,
FileCache,
MemoryBackend,
cached,
)
from app.runtime.config import settings
class _FakeRedisHelper:
"""
以进程内字典模拟 Redis 键空间,记录全部写入以便断言。
"""
store: Dict[Tuple[str, str], Any] = {}
def set(self, key: str, value: Any, ttl: Optional[int] = None,
region: str = "DEFAULT", **_kwargs) -> None:
"""
记录写入的键值。
"""
self.store[(region, key)] = value
def get(self, key: str, region: str = "DEFAULT") -> Any:
"""
读取已记录的键值。
"""
return self.store.get((region, key))
def exists(self, key: str, region: str = "DEFAULT") -> bool:
"""
判断键是否存在。
"""
return (region, key) in self.store
def delete(self, key: str, region: str = "DEFAULT") -> None:
"""
删除已记录的键。
"""
self.store.pop((region, key), None)
class _FakeAsyncRedisHelper:
"""
异步 Redis 客户端替身,与同步替身共享键空间。
"""
async def set(self, key: str, value: Any, ttl: Optional[int] = None,
region: str = "DEFAULT", **_kwargs) -> None:
"""
记录写入的键值。
"""
_FakeRedisHelper.store[(region, key)] = value
async def get(self, key: str, region: str = "DEFAULT") -> Any:
"""
读取已记录的键值。
"""
return _FakeRedisHelper.store.get((region, key))
@pytest.fixture
def redis_mode(monkeypatch, compose_cache_backend):
"""
切换到 Redis 缓存后端,并用内存替身替换 Redis 客户端。
"""
_FakeRedisHelper.store = {}
monkeypatch.setattr(redis_adapter_module, "RedisHelper", _FakeRedisHelper)
monkeypatch.setattr(redis_adapter_module, "AsyncRedisHelper", _FakeAsyncRedisHelper)
# 缓存类型在启动时选定,按启动流程重新装配后 Redis 路由才生效。
compose_cache_backend("redis")
return _FakeRedisHelper.store
def test_file_cache_default_keeps_redis_routing(redis_mode):
"""
未声明 local_only 的调用方(含插件 SDK)在 Redis 模式下保持原有 Redis 路由。
"""
assert isinstance(FileCache(), RedisBackend)
assert isinstance(AsyncFileCache(), AsyncRedisBackend)
assert isinstance(Cache(), RedisBackend)
assert isinstance(AsyncCache(), AsyncRedisBackend)
def test_local_only_file_cache_uses_filesystem_in_redis_mode(redis_mode, tmp_path):
"""
local_only 的文件缓存在 Redis 模式下仍写入本地文件,不产生 Redis 键。
"""
cache = FileCache(base=tmp_path, ttl=60, local_only=True)
async_cache = AsyncFileCache(base=tmp_path, ttl=60, local_only=True)
cache.set("poster.jpg", b"\xff\xd8image", region="images")
assert isinstance(cache, FileBackend)
assert isinstance(async_cache, AsyncFileBackend)
assert (tmp_path / "images" / "poster.jpg").read_bytes() == b"\xff\xd8image"
assert asyncio.run(async_cache.get("poster.jpg", region="images")) == b"\xff\xd8image"
assert redis_mode == {}
def test_local_only_memory_cache_ignores_redis_mode(redis_mode):
"""
local_only 的通用缓存在 Redis 模式下使用进程内有界内存缓存。
"""
assert isinstance(Cache(maxsize=2, ttl=60, local_only=True), MemoryBackend)
assert isinstance(AsyncCache(maxsize=2, ttl=60, local_only=True), AsyncMemoryBackend)
def test_file_cache_uses_filesystem_in_memory_mode(monkeypatch, tmp_path):
"""
未启用 Redis 时文件缓存不受 local_only 影响,始终使用文件系统。
"""
monkeypatch.setattr(settings, "CACHE_BACKEND_TYPE", "cachetools")
assert isinstance(FileCache(base=tmp_path), FileBackend)
assert isinstance(FileCache(base=tmp_path, local_only=True), FileBackend)
assert isinstance(AsyncFileCache(base=tmp_path), AsyncFileBackend)
def test_cached_local_only_keeps_values_out_of_redis(redis_mode):
"""
cached(local_only=True) 在 Redis 模式下只缓存到进程内存。
"""
calls = []
@cached(region="binary_routing_test", maxsize=4, ttl=60, local_only=True)
def load(url: str) -> bytes:
"""
返回固定二进制内容并记录调用次数。
"""
calls.append(url)
return b"cover"
assert load("https://example.test/a.jpg") == b"cover"
assert load("https://example.test/a.jpg") == b"cover"
assert calls == ["https://example.test/a.jpg"]
assert redis_mode == {}
def test_image_helper_uses_filesystem_in_redis_mode(redis_mode, monkeypatch, tmp_path):
"""
图片缓存在 Redis 模式下仍落在 CACHE_PATH/images。
"""
monkeypatch.delitem(
Singleton._instances, (image_module.ImageHelper, (), frozenset()), raising=False
)
monkeypatch.setattr(
image_module,
"get_chain_runtime_config_snapshot",
lambda: SimpleNamespace(cache_path=tmp_path, global_image_cache_days=7),
)
helper = image_module.ImageHelper()
helper.file_cache.set("poster.jpg", b"\xff\xd8image", region="images")
assert isinstance(helper.file_cache, FileBackend)
assert isinstance(helper.async_file_cache, AsyncFileBackend)
assert (tmp_path / "images" / "poster.jpg").exists()
assert redis_mode == {}
def _snapshot_bytes(timestamp: float) -> bytes:
"""
构造指定游标的快照内容。
"""
return json.dumps({
"version": SnapshotStore.VERSION,
"timestamp": timestamp,
"file_count": 1,
"snapshot": {"/mon/a.mkv": {"size": 1, "modify_time": timestamp}},
}).encode("utf-8")
def test_snapshot_store_migrates_legacy_redis_snapshot(redis_mode, monkeypatch, tmp_path):
"""
Redis 模式下升级前的快照可被读取,保存后落盘并删除 Redis 旧键。
"""
monkeypatch.setattr(snapshot_module, "get_runtime_setting", lambda _key: tmp_path)
redis_mode[("snapshots", "alist_snapshot")] = _snapshot_bytes(100)
store = SnapshotStore()
data, ok = store.load_checked("alist")
assert ok is True
assert data["timestamp"] == 100
assert store.save("alist", data["snapshot"], file_count=1, last_snapshot_time=100)
snapshot_file = tmp_path / "snapshots" / "snapshots" / "alist_snapshot"
assert json.loads(snapshot_file.read_bytes())["timestamp"] == 100
assert ("snapshots", "alist_snapshot") not in redis_mode
assert store.load_checked("alist")[0]["timestamp"] == 100
def test_snapshot_store_reset_clears_legacy_redis_snapshot(redis_mode, monkeypatch, tmp_path):
"""
重置快照时同时清理 Redis 旧键,避免下次读取回退到旧基线。
"""
monkeypatch.setattr(snapshot_module, "get_runtime_setting", lambda _key: tmp_path)
redis_mode[("snapshots", "alist_snapshot")] = _snapshot_bytes(100)
store = SnapshotStore()
assert store.reset("alist") is True
assert ("snapshots", "alist_snapshot") not in redis_mode
assert store.load_checked("alist") == (None, True)
def test_snapshot_store_has_no_legacy_source_in_memory_mode(monkeypatch, tmp_path):
"""
未启用 Redis 时快照只读写本地文件,不存在旧数据来源。
"""
monkeypatch.setattr(settings, "CACHE_BACKEND_TYPE", "cachetools")
monkeypatch.setattr(snapshot_module, "get_runtime_setting", lambda _key: tmp_path)
store = SnapshotStore()
assert store.save("alist", {"/mon/a.mkv": {"size": 1, "modify_time": 5}}, file_count=1)
assert store.load_checked("alist")[0]["timestamp"] == 5
assert (tmp_path / "snapshots" / "snapshots" / "alist_snapshot").exists()