mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-02 14:06:50 +08:00
184 lines
5.9 KiB
Python
184 lines
5.9 KiB
Python
"""媒体服务器本地缓存短事务适配器的回归测试。"""
|
|
|
|
import json
|
|
from dataclasses import FrozenInstanceError
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app.application.mediaserver import MediaServerQueryService, MediaServerSyncItem
|
|
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
|
|
from app.db.base import Base
|
|
from app.db.models.mediaserver import MediaServerItem
|
|
from app.schemas.mediaserver import MediaServerItem as MediaServerItemSchema
|
|
from app.schemas.types import MediaSource
|
|
|
|
|
|
@pytest.fixture
|
|
def session_factory(tmp_path):
|
|
"""创建隔离的媒体服务器缓存数据库与会话工厂。"""
|
|
engine = create_engine(f"sqlite:///{tmp_path / 'mediaserver-adapter.db'}")
|
|
factory = sessionmaker(bind=engine)
|
|
Base.metadata.create_all(bind=engine)
|
|
yield factory
|
|
engine.dispose()
|
|
|
|
|
|
def _sync_item(*, title: str, sync_time: str) -> MediaServerSyncItem:
|
|
"""构造一个可重复 upsert 的冻结媒体库条目。"""
|
|
return MediaServerSyncItem(
|
|
server="plex",
|
|
library="movies",
|
|
item_id="item-1",
|
|
item_type="电影",
|
|
title=title,
|
|
original_title=None,
|
|
year="2026",
|
|
media_source=MediaSource.TMDB,
|
|
media_id="1001",
|
|
path=f"/media/{title}.mkv",
|
|
seasoninfo=((1, (1, 2)),),
|
|
note_json=None,
|
|
lst_mod_date=sync_time,
|
|
)
|
|
|
|
|
|
def test_upsert_commits_insert_and_update_without_external_session(
|
|
session_factory,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""每次 upsert 应在单一短 Session 中完成查询、写入和提交。"""
|
|
from app.db import base as db_base
|
|
|
|
monkeypatch.setattr(
|
|
db_base,
|
|
"run_sync_transaction",
|
|
lambda _operation: (_ for _ in ()).throw(
|
|
AssertionError("显式 Session 不应创建兼容事务")
|
|
),
|
|
)
|
|
repository = TransactionalMediaServerRepository(session_factory)
|
|
|
|
assert repository.upsert(
|
|
_sync_item(title="旧标题", sync_time="2026-08-28 10:00:00")
|
|
)
|
|
assert not repository.upsert(
|
|
_sync_item(title="新标题", sync_time="2026-08-28 11:00:00")
|
|
)
|
|
|
|
with session_factory() as session:
|
|
items = session.query(MediaServerItem).all()
|
|
|
|
assert len(items) == 1
|
|
assert items[0].title == "新标题"
|
|
assert items[0].path == "/media/新标题.mkv"
|
|
assert items[0].seasoninfo == {"1": [1, 2]}
|
|
|
|
|
|
def test_get_item_id_returns_scalar_after_short_session_closes(session_factory) -> None:
|
|
"""查询端口只返回标量 ID,不把 ORM 条目带出短 Session。"""
|
|
repository = TransactionalMediaServerRepository(session_factory)
|
|
repository.upsert(
|
|
_sync_item(title="查询标题", sync_time="2026-08-28 10:00:00")
|
|
)
|
|
|
|
item_id = repository.get_item_id(
|
|
media_source=MediaSource.TMDB,
|
|
media_id="1001",
|
|
mtype="电影",
|
|
season=1,
|
|
)
|
|
|
|
assert item_id == "item-1"
|
|
|
|
|
|
def test_sync_item_deeply_detaches_mutable_remote_values() -> None:
|
|
"""冻结 DTO 必须规范化季集并断开与远端 note 的可变引用。"""
|
|
note = {"nested": ["original"]}
|
|
source = MediaServerItemSchema(
|
|
server="plex",
|
|
library="shows",
|
|
item_id="show-1",
|
|
item_type="Series",
|
|
title="剧集",
|
|
note=note,
|
|
)
|
|
|
|
snapshot = MediaServerSyncItem.from_item(
|
|
source,
|
|
item_type="电视剧",
|
|
seasoninfo={1: None, 2: [1, 2]},
|
|
sync_time="2026-08-28 10:00:00",
|
|
)
|
|
note["nested"].append("changed")
|
|
|
|
assert snapshot.seasoninfo == ((1, ()), (2, (1, 2)))
|
|
assert json.loads(snapshot.note_json or "null") == {"nested": ["original"]}
|
|
with pytest.raises(FrozenInstanceError):
|
|
snapshot.note_json = None
|
|
|
|
|
|
def test_upsert_rolls_back_and_preserves_original_error() -> None:
|
|
"""单条写入异常时必须回滚短事务并继续抛出原始异常。"""
|
|
session = MagicMock()
|
|
session.__enter__.return_value = session
|
|
session.__exit__.return_value = False
|
|
repository = TransactionalMediaServerRepository(MagicMock(return_value=session))
|
|
|
|
with patch(
|
|
"app.db.adapters.mediaserver.MediaServerOper.upsert",
|
|
side_effect=ValueError("invalid item"),
|
|
), pytest.raises(ValueError, match="invalid item"):
|
|
repository.upsert(
|
|
_sync_item(title="失败标题", sync_time="2026-08-28 10:00:00")
|
|
)
|
|
|
|
session.rollback.assert_called_once_with()
|
|
session.commit.assert_not_called()
|
|
|
|
|
|
def test_upsert_rolls_back_commit_failure() -> None:
|
|
"""提交阶段失败也必须回滚短事务并传播原始异常。"""
|
|
session = MagicMock()
|
|
session.__enter__.return_value = session
|
|
session.__exit__.return_value = False
|
|
session.commit.side_effect = RuntimeError("commit failed")
|
|
repository = TransactionalMediaServerRepository(MagicMock(return_value=session))
|
|
|
|
with patch(
|
|
"app.db.adapters.mediaserver.MediaServerOper.upsert",
|
|
return_value=True,
|
|
), pytest.raises(RuntimeError, match="commit failed"):
|
|
repository.upsert(
|
|
_sync_item(title="提交失败", sync_time="2026-08-28 10:00:00")
|
|
)
|
|
|
|
session.commit.assert_called_once_with()
|
|
session.rollback.assert_called_once_with()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_service_consumes_scalar_repository_result() -> None:
|
|
"""Application 查询服务只消费标量 ID,不读取 ORM 属性。"""
|
|
repository = MagicMock()
|
|
repository.async_get_item_id = AsyncMock(return_value="item-1")
|
|
service = MediaServerQueryService(repository)
|
|
|
|
result = await service.find_item_id(
|
|
media_source=MediaSource.TMDB,
|
|
media_id="1001",
|
|
mtype="电影",
|
|
)
|
|
|
|
assert result == "item-1"
|
|
repository.async_get_item_id.assert_awaited_once_with(
|
|
title=None,
|
|
year=None,
|
|
mtype="电影",
|
|
media_source=MediaSource.TMDB,
|
|
media_id="1001",
|
|
season=None,
|
|
)
|