mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-01 13:37:24 +08:00
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""下载与整理 durable 事件的原子写入和对象恢复测试。"""
|
|
|
|
import json
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, delete, select
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app.application.chain.durable_events import (
|
|
download_added_event_key,
|
|
restore_download_added,
|
|
restore_transfer_result,
|
|
snapshot_download_added,
|
|
snapshot_transfer_result,
|
|
transfer_result_event_key,
|
|
)
|
|
from app.db.base import Base
|
|
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
|
|
from app.db.models.outbox import OutboxMessage
|
|
from app.db.models.transferhistory import TransferHistory
|
|
from app.domain.context import Context, MediaInfo, TorrentInfo
|
|
from app.domain.metainfo import MetaInfo
|
|
from app.schemas.file import FileItem
|
|
from app.schemas.transfer import TransferInfo
|
|
from app.schemas.types import MediaSource, MediaType
|
|
from app.db.adapters.chain import TransactionalChainDurableEventWriter
|
|
|
|
|
|
def _session_factory():
|
|
"""创建只服务当前测试的内存数据库和同步 Session 工厂。"""
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
return sessionmaker(bind=engine)
|
|
|
|
|
|
def _objects():
|
|
"""构造下载和整理事件共用的最小真实领域对象。"""
|
|
meta = MetaInfo("Demo.2026.1080p.mkv")
|
|
media = MediaInfo(
|
|
type=MediaType.MOVIE,
|
|
title="Demo",
|
|
year="2026",
|
|
media_source=MediaSource.TMDB,
|
|
media_id="42",
|
|
)
|
|
torrent = TorrentInfo(title="Demo torrent", site_name="test")
|
|
context = Context(meta_info=meta, media_info=media, torrent_info=torrent)
|
|
fileitem = FileItem(
|
|
storage="local",
|
|
path="/downloads/Demo.mkv",
|
|
name="Demo.mkv",
|
|
type="file",
|
|
)
|
|
transferinfo = TransferInfo(
|
|
success=True,
|
|
fileitem=fileitem,
|
|
target_item=FileItem(
|
|
storage="local",
|
|
path="/library/Demo (2026)/Demo.mkv",
|
|
name="Demo.mkv",
|
|
type="file",
|
|
),
|
|
transfer_type="copy",
|
|
)
|
|
return meta, media, context, fileitem, transferinfo
|
|
|
|
|
|
def _assert_event_key(
|
|
event_key: str,
|
|
topic: str,
|
|
history_id: int,
|
|
) -> None:
|
|
"""校验事件键保留业务关联,并以合法 UUID 区分每次事实。"""
|
|
key_topic, key_history_id, occurrence_id, version = event_key.split(":")
|
|
assert key_topic == topic
|
|
assert key_history_id == str(history_id)
|
|
assert UUID(occurrence_id).hex == occurrence_id
|
|
assert version == "v1"
|
|
|
|
|
|
def test_durable_snapshots_are_json_and_restore_plugin_runtime_objects():
|
|
"""outbox 只存 JSON 快照,重放时恢复插件一直收到的对象类型。"""
|
|
meta, media, context, fileitem, transferinfo = _objects()
|
|
download_payload = {
|
|
"hash": "hash-1",
|
|
"context": context,
|
|
"username": "alice",
|
|
"downloader": "qb",
|
|
"episodes": [1, 2],
|
|
"source": "manual",
|
|
"idempotency_key": "download.added:qb:hash-1:v1",
|
|
}
|
|
transfer_payload = {
|
|
"fileitem": fileitem,
|
|
"meta": meta,
|
|
"mediainfo": media,
|
|
"transferinfo": transferinfo,
|
|
"downloader": "qb",
|
|
"download_hash": "hash-1",
|
|
"transfer_history_id": 9,
|
|
"idempotency_key": "transfer.completed:9:v1",
|
|
}
|
|
|
|
download_snapshot = snapshot_download_added(download_payload)
|
|
transfer_snapshot = snapshot_transfer_result(transfer_payload)
|
|
json.dumps(download_snapshot)
|
|
json.dumps(transfer_snapshot)
|
|
|
|
restored_download = restore_download_added(download_snapshot)
|
|
restored_transfer = restore_transfer_result(transfer_snapshot)
|
|
assert isinstance(restored_download["context"], Context)
|
|
assert isinstance(restored_download["context"].media_info, MediaInfo)
|
|
assert isinstance(restored_transfer["fileitem"], FileItem)
|
|
assert type(restored_transfer["meta"]) is type(meta)
|
|
assert isinstance(restored_transfer["mediainfo"], MediaInfo)
|
|
assert isinstance(restored_transfer["transferinfo"], TransferInfo)
|
|
|
|
|
|
def test_download_history_and_event_intent_share_one_transaction():
|
|
"""下载历史、文件清单和 intent 提交后才执行通知与即时事件。"""
|
|
factory = _session_factory()
|
|
writer = TransactionalChainDurableEventWriter(factory)
|
|
_, _, context, _, _ = _objects()
|
|
calls = []
|
|
|
|
writer.download_added(
|
|
history_payload={
|
|
"path": "/downloads/Demo.mkv",
|
|
"type": MediaType.MOVIE.value,
|
|
"title": "Demo",
|
|
"download_hash": "hash-2",
|
|
},
|
|
file_payloads=[
|
|
{
|
|
"download_hash": "hash-2",
|
|
"downloader": "qb",
|
|
"fullpath": "/downloads/Demo.mkv",
|
|
"savepath": "/downloads",
|
|
"filepath": "Demo.mkv",
|
|
"torrentname": "Demo torrent",
|
|
}
|
|
],
|
|
event_payload={
|
|
"hash": "hash-2",
|
|
"context": context,
|
|
"username": "alice",
|
|
"downloader": "qb",
|
|
"episodes": [],
|
|
"source": "manual",
|
|
},
|
|
after_commit=lambda: calls.append("after_commit"),
|
|
publish=lambda payload: calls.append(("event", payload)),
|
|
)
|
|
|
|
with factory() as session:
|
|
history = session.execute(select(DownloadHistory)).scalar_one()
|
|
download_file = session.execute(select(DownloadFiles)).scalar_one()
|
|
outbox = session.execute(select(OutboxMessage)).scalar_one()
|
|
assert history.download_hash == "hash-2"
|
|
assert download_file.fullpath == "/downloads/Demo.mkv"
|
|
assert outbox.status == "completed"
|
|
_assert_event_key(outbox.event_key, "download.added", history.id)
|
|
assert [call if isinstance(call, str) else call[0] for call in calls] == [
|
|
"after_commit",
|
|
"event",
|
|
]
|
|
|
|
writer.download_added(
|
|
history_payload={
|
|
"path": "/downloads/duplicate.mkv",
|
|
"type": MediaType.MOVIE.value,
|
|
"title": "Duplicate",
|
|
"download_hash": "hash-2",
|
|
},
|
|
file_payloads=[],
|
|
event_payload={
|
|
"hash": "hash-2",
|
|
"context": context,
|
|
"downloader": "qb",
|
|
"episodes": [],
|
|
},
|
|
after_commit=lambda: None,
|
|
publish=lambda _payload: None,
|
|
)
|
|
with factory() as session:
|
|
histories = session.execute(
|
|
select(DownloadHistory).order_by(DownloadHistory.id)
|
|
).scalars().all()
|
|
outboxes = session.execute(
|
|
select(OutboxMessage).order_by(OutboxMessage.id)
|
|
).scalars().all()
|
|
assert len(histories) == 2
|
|
assert len(outboxes) == 2
|
|
assert all(message.status == "completed" for message in outboxes)
|
|
for history, message in zip(histories, outboxes, strict=True):
|
|
_assert_event_key(message.event_key, "download.added", history.id)
|
|
assert outboxes[0].event_key != outboxes[1].event_key
|
|
|
|
|
|
def test_event_keys_distinguish_reused_history_ids():
|
|
"""历史主键被数据库复用时,每次业务事实仍获得不同的幂等键。"""
|
|
download_keys = {download_added_event_key(7) for _ in range(2)}
|
|
transfer_keys = {
|
|
transfer_result_event_key("transfer.completed", 7)
|
|
for _ in range(2)
|
|
}
|
|
assert len(download_keys) == 2
|
|
assert len(transfer_keys) == 2
|
|
for event_key in download_keys:
|
|
_assert_event_key(event_key, "download.added", 7)
|
|
for event_key in transfer_keys:
|
|
_assert_event_key(event_key, "transfer.completed", 7)
|
|
|
|
|
|
def test_transfer_succeeds_when_history_id_is_reused_with_retained_outbox():
|
|
"""整理历史删除而 outbox 保留时,复用主键不得阻断新整理记录。"""
|
|
factory = _session_factory()
|
|
writer = TransactionalChainDurableEventWriter(factory)
|
|
|
|
def transfer(src: str):
|
|
"""写入一条最小成功整理事实并完成即时事件。"""
|
|
return writer.transfer_result(
|
|
topic="transfer.completed",
|
|
stage_history=lambda repository: repository.add_force(
|
|
src=src,
|
|
src_storage="local",
|
|
status=1,
|
|
),
|
|
event_payload={},
|
|
publish=lambda _payload: None,
|
|
)
|
|
|
|
first_history = transfer("/downloads/first.mkv")
|
|
with factory() as session:
|
|
session.execute(delete(TransferHistory))
|
|
session.commit()
|
|
|
|
second_history = transfer("/downloads/second.mkv")
|
|
|
|
with factory() as session:
|
|
histories = session.execute(select(TransferHistory)).scalars().all()
|
|
outboxes = session.execute(
|
|
select(OutboxMessage).order_by(OutboxMessage.id)
|
|
).scalars().all()
|
|
assert first_history is not None
|
|
assert second_history is not None
|
|
assert first_history.id == second_history.id
|
|
assert len(histories) == 1
|
|
assert len(outboxes) == 2
|
|
assert all(message.status == "completed" for message in outboxes)
|
|
assert outboxes[0].event_key != outboxes[1].event_key
|
|
for message in outboxes:
|
|
_assert_event_key(
|
|
message.event_key,
|
|
"transfer.completed",
|
|
second_history.id,
|
|
)
|
|
|
|
|
|
def test_transfer_event_failure_leaves_committed_intent_pending():
|
|
"""整理历史提交后即时广播失败不得误删可供恢复的 pending intent。"""
|
|
factory = _session_factory()
|
|
writer = TransactionalChainDurableEventWriter(factory)
|
|
meta, media, _, fileitem, transferinfo = _objects()
|
|
|
|
def stage_history(repository):
|
|
"""通过应用历史端口名暂存一条成功整理记录。"""
|
|
return repository.add_force(
|
|
src=fileitem.path,
|
|
src_storage=fileitem.storage,
|
|
src_fileitem=fileitem.model_dump(mode="json"),
|
|
dest=transferinfo.target_item.path,
|
|
dest_storage=transferinfo.target_item.storage,
|
|
dest_fileitem=transferinfo.target_item.model_dump(mode="json"),
|
|
status=1,
|
|
)
|
|
|
|
def fail_publish(_payload):
|
|
"""模拟插件事件总线在业务提交后失败。"""
|
|
raise RuntimeError("event failed")
|
|
|
|
with pytest.raises(RuntimeError, match="event failed"):
|
|
writer.transfer_result(
|
|
topic="transfer.completed",
|
|
stage_history=stage_history,
|
|
event_payload={
|
|
"fileitem": fileitem,
|
|
"meta": meta,
|
|
"mediainfo": media,
|
|
"transferinfo": transferinfo,
|
|
"downloader": "qb",
|
|
"download_hash": "hash-3",
|
|
"transfer_history_id": None,
|
|
},
|
|
publish=fail_publish,
|
|
)
|
|
|
|
with factory() as session:
|
|
history = session.execute(select(TransferHistory)).scalar_one()
|
|
outbox = session.execute(select(OutboxMessage)).scalar_one()
|
|
assert history.status is True
|
|
assert outbox.status == "pending"
|
|
_assert_event_key(outbox.event_key, "transfer.completed", history.id)
|