mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
244 lines
8.4 KiB
Python
244 lines
8.4 KiB
Python
"""
|
||
大体积二进制缓存的后端路由测试。
|
||
|
||
启用 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()
|