mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
HistoryQueryService.count_download() 依赖 AsyncDownloadHistoryQueryRepository 协议的 async_count(),但 startup 注入的请求级适配器 SessionDownloadHistoryRepository 只实现了 async_list_by_page(),导致 GET /api/v1/history/download 在写入分页总数响应头时抛 AttributeError 并返回 500, Web 端下载历史页面无法加载。 为 SessionDownloadHistoryRepository 补齐 async_count(),在请求持有的 AsyncSession 内调用 DownloadHistoryOper.async_count(),Session 类型校验与 async_list_by_page() 保持一致,并补充回归测试。
232 lines
8.4 KiB
Python
232 lines
8.4 KiB
Python
"""下载历史类型化适配器的投影与事务测试。"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from app.application.history import (
|
|
DownloadFileSnapshot,
|
|
DownloadFileWrite,
|
|
DownloadHistorySnapshot,
|
|
DownloadHistoryWrite,
|
|
)
|
|
from app.db.adapters.history.download import (
|
|
SessionDownloadHistoryRepository,
|
|
TransactionalDownloadHistoryRepository,
|
|
)
|
|
from app.db.models.downloadhistory import DownloadHistory
|
|
from app.db.session import SessionFactory, async_session_scope
|
|
from app.db.uow import SqlAlchemyUnitOfWork
|
|
from app.schemas.types import MediaSource, MediaType
|
|
|
|
|
|
def _repository() -> TransactionalDownloadHistoryRepository:
|
|
"""构造绑定测试数据库短 Session 的下载历史仓储。"""
|
|
return TransactionalDownloadHistoryRepository(
|
|
sync_session=SessionFactory,
|
|
async_session=async_session_scope,
|
|
)
|
|
|
|
|
|
def _history_write(
|
|
*,
|
|
download_hash: str = "typed-history-hash",
|
|
) -> DownloadHistoryWrite:
|
|
"""构造覆盖 Chain 消费字段的类型化下载历史写入。"""
|
|
return DownloadHistoryWrite(
|
|
path="/downloads/Typed.Show.S01",
|
|
type=MediaType.TV.value,
|
|
title="Typed Show",
|
|
year="2026",
|
|
media_source=MediaSource.TMDB,
|
|
media_id="7001",
|
|
music_type=None,
|
|
seasons="S01",
|
|
episodes="E01-E02",
|
|
image="https://example.test/backdrop.jpg",
|
|
poster="https://example.test/poster.jpg",
|
|
downloader="qb",
|
|
download_hash=download_hash,
|
|
torrent_name="Typed Show torrent",
|
|
torrent_description="description",
|
|
torrent_site="Example",
|
|
userid=7,
|
|
username="alice",
|
|
channel="telegram",
|
|
date="2026-08-28 10:00:00",
|
|
note={
|
|
"source": "subscribe",
|
|
"nested": {"season": 1, "episodes": [1, 2]},
|
|
},
|
|
media_category_id="tv.episode",
|
|
media_category="剧集",
|
|
classification_rule_id="rule.tv.episode",
|
|
classification_policy_revision=9,
|
|
classification_source="automatic",
|
|
episode_group="group-1",
|
|
custom_words="S02 => S01",
|
|
)
|
|
|
|
|
|
def _file_write(
|
|
*,
|
|
download_hash: str = "typed-history-hash",
|
|
) -> DownloadFileWrite:
|
|
"""构造与类型化历史关联的下载文件写入。"""
|
|
return DownloadFileWrite(
|
|
downloader="qb",
|
|
download_hash=download_hash,
|
|
fullpath="/downloads/Typed.Show.S01/Episode01.mkv",
|
|
savepath="/downloads/Typed.Show.S01",
|
|
filepath="Episode01.mkv",
|
|
torrentname="Typed Show torrent",
|
|
)
|
|
|
|
|
|
def test_transactional_repository_projects_detached_snapshots(db) -> None:
|
|
"""所有同步查询都应在 Session 内投影,且 JSON 不与后续查询共享。"""
|
|
repository = _repository()
|
|
history_id = repository.add(_history_write(), (_file_write(),))
|
|
|
|
by_hash = repository.get_by_hash("typed-history-hash")
|
|
by_path = repository.get_by_path("/downloads/Typed.Show.S01")
|
|
by_hashes = repository.get_by_hashes(["typed-history-hash"])
|
|
by_identity = repository.get_by_media_identity(
|
|
MediaSource.TMDB,
|
|
"7001",
|
|
)
|
|
by_fullpath = repository.get_file_by_fullpath("/downloads/Typed.Show.S01/Episode01.mkv")
|
|
by_file_hash = repository.get_files_by_hash(
|
|
"typed-history-hash",
|
|
state=1,
|
|
)
|
|
by_savepath = repository.get_files_by_savepath("/downloads/Typed.Show.S01")
|
|
|
|
assert isinstance(by_hash, DownloadHistorySnapshot)
|
|
assert by_hash.id == history_id
|
|
assert by_hash.userid == "7"
|
|
assert by_hash.media_source == MediaSource.TMDB
|
|
assert by_hash.media_category_id == "tv.episode"
|
|
assert by_hash.media_category == "剧集"
|
|
assert by_hash.classification_rule_id == "rule.tv.episode"
|
|
assert by_hash.classification_policy_revision == 9
|
|
assert by_hash.classification_source == "automatic"
|
|
assert by_path == by_hash
|
|
assert by_hashes == {"typed-history-hash": by_hash}
|
|
assert by_identity == [by_hash]
|
|
assert isinstance(by_fullpath, DownloadFileSnapshot)
|
|
assert by_file_hash == [by_fullpath]
|
|
assert by_savepath == [by_fullpath]
|
|
assert not hasattr(by_hash, "_sa_instance_state")
|
|
assert not hasattr(by_fullpath, "_sa_instance_state")
|
|
|
|
assert isinstance(by_hash.note, dict)
|
|
with pytest.raises(TypeError, match="不可修改"):
|
|
by_hash.note["source"] = "mutated"
|
|
nested = by_hash.note["nested"]
|
|
assert isinstance(nested, dict)
|
|
with pytest.raises(TypeError, match="不可修改"):
|
|
nested["season"] = 2
|
|
episodes = nested["episodes"]
|
|
assert isinstance(episodes, list)
|
|
with pytest.raises(TypeError, match="不可修改"):
|
|
episodes.append(3)
|
|
refreshed = repository.get_by_hash("typed-history-hash")
|
|
assert refreshed is not None
|
|
assert refreshed.note == {
|
|
"source": "subscribe",
|
|
"nested": {"season": 1, "episodes": [1, 2]},
|
|
}
|
|
|
|
|
|
def test_transactional_repository_rolls_back_history_and_files_on_commit_failure(
|
|
db,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""历史与文件提交失败时必须整体回滚,不得留下半写入记录。"""
|
|
repository = _repository()
|
|
download_hash = "typed-rollback-hash"
|
|
|
|
def fail_commit(_unit_of_work) -> None:
|
|
"""模拟数据库提交失败。"""
|
|
raise RuntimeError("commit failed")
|
|
|
|
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
|
|
|
|
with pytest.raises(RuntimeError, match="commit failed"):
|
|
repository.add(
|
|
_history_write(download_hash=download_hash),
|
|
(_file_write(download_hash=download_hash),),
|
|
)
|
|
|
|
assert repository.get_by_hash(download_hash) is None
|
|
assert repository.get_files_by_hash(download_hash) == []
|
|
|
|
|
|
def test_transactional_repository_async_query_and_delete(db) -> None:
|
|
"""异步分页返回脱离 Session 的快照,删除由独立事务提交。"""
|
|
repository = _repository()
|
|
baseline_count = asyncio.run(repository.async_count())
|
|
history_id = repository.add(_history_write(download_hash="typed-async-hash"))
|
|
|
|
async def exercise() -> list[DownloadHistorySnapshot]:
|
|
"""在同一事件循环中执行异步分页和删除。"""
|
|
records = await repository.async_list_by_page(count=10)
|
|
assert await repository.async_count() == baseline_count + 1
|
|
await repository.async_delete(history_id)
|
|
return records
|
|
|
|
records = asyncio.run(exercise())
|
|
|
|
assert any(record.id == history_id for record in records)
|
|
assert all(not hasattr(record, "_sa_instance_state") for record in records)
|
|
assert repository.get_by_hash("typed-async-hash") is None
|
|
|
|
|
|
def test_session_repository_obeys_caller_transaction(db) -> None:
|
|
"""请求级 adapter 只暂存变更,提交与回滚由调用方 UoW 决定。"""
|
|
history = db.add(
|
|
DownloadHistory(
|
|
path="/downloads/request-history",
|
|
type=MediaType.MOVIE.value,
|
|
title="Request History",
|
|
download_hash="request-history-hash",
|
|
)
|
|
)
|
|
|
|
with SessionFactory() as session:
|
|
repository = SessionDownloadHistoryRepository(session)
|
|
repository.stage_delete_history(history.id)
|
|
session.rollback()
|
|
assert _repository().get_by_hash("request-history-hash") is not None
|
|
|
|
with SessionFactory() as session:
|
|
repository = SessionDownloadHistoryRepository(session)
|
|
repository.stage_delete_history(history.id)
|
|
session.commit()
|
|
assert _repository().get_by_hash("request-history-hash") is None
|
|
|
|
|
|
def test_session_repository_counts_history_in_caller_async_session(db) -> None:
|
|
"""请求级 adapter 在调用方 AsyncSession 内统计总数,与分页读取口径一致。"""
|
|
repository = _repository()
|
|
baseline_count = asyncio.run(repository.async_count())
|
|
history_id = repository.add(_history_write(download_hash="request-async-count-hash"))
|
|
|
|
async def exercise() -> tuple[int, list[DownloadHistorySnapshot]]:
|
|
"""在同一请求 AsyncSession 内先统计总数再分页读取。"""
|
|
async with async_session_scope() as session:
|
|
session_repository = SessionDownloadHistoryRepository(session)
|
|
total = await session_repository.async_count()
|
|
records = await session_repository.async_list_by_page(count=10)
|
|
return total, records
|
|
|
|
total, records = asyncio.run(exercise())
|
|
|
|
assert total == baseline_count + 1
|
|
assert any(record.id == history_id for record in records)
|
|
|
|
with SessionFactory() as session:
|
|
with pytest.raises(RuntimeError):
|
|
asyncio.run(SessionDownloadHistoryRepository(session).async_count())
|