Files
MoviePilot/tests/test_subscribe_create_command.py

517 lines
18 KiB
Python
Raw 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.

"""订阅新增事务所有权与默认入口集成测试。"""
import asyncio
from dataclasses import replace
from unittest.mock import AsyncMock, Mock
import pytest
from sqlalchemy import delete, select
from app.application.subscription.contract import (
SubscriptionIdentity,
SubscriptionPatch,
)
from app.application.subscription.write import (
AsyncCreateSubscriptionBatchCommand,
AsyncCreateSubscriptionCommand,
CreateSubscriptionCommand,
SubscriptionCreateRequest,
add_subscribe,
async_add_subscribe,
)
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
SqlAlchemyAsyncOutboxStager,
)
from app.db.adapters.subscription import (
SessionSubscriptionBatchWriter,
SessionSubscriptionRepository,
TransactionalSubscriptionRepository,
)
from app.db.models.outbox import OutboxMessage
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper, SubscribeStageResult
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import SqlAlchemyAsyncUnitOfWork
from app.domain.context import MediaInfo
from app.schemas.types import MediaSource, MediaType
def _media(media_id: str) -> MediaInfo:
"""构造默认事务写入路径所需的最小媒体信息。"""
media = MediaInfo()
media.type = MediaType.MOVIE
media.title = "事务测试电影"
media.year = "2026"
media.media_source = MediaSource.TMDB
media.media_id = media_id
media.vote_average = 8.0
media.overview = "事务切片"
return media
def _writer() -> TransactionalSubscriptionRepository:
"""按组合根合同构造显式短事务订阅仓储。"""
return TransactionalSubscriptionRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
def _batch_request(
media_id: str,
season: int,
) -> SubscriptionCreateRequest:
"""构造真实数据库批量写测试使用的一季订阅请求。"""
return SubscriptionCreateRequest(
identity=SubscriptionIdentity(
media_source=MediaSource.TMDB,
media_id=media_id,
season=season,
),
payload=SubscriptionPatch({
"name": "批量事务剧集",
"year": "2026",
"type": MediaType.TV.value,
"media_source": str(MediaSource.TMDB),
"media_id": media_id,
"season": season,
"username": "Seerr",
"total_episode": 12,
"lack_episode": 12,
}),
notification={"title": f"{season} 季订阅"},
)
def test_sync_command_orders_stage_commit_before_caller_effect() -> None:
"""同步新增只有在仓储暂存和提交成功后才把结果交给外部副作用。"""
calls: list[str] = []
repository = Mock()
repository.stage_add.side_effect = lambda *_: (
calls.append("stage")
or SubscribeStageResult(10, "新增订阅成功", True)
)
unit_of_work = Mock()
unit_of_work.commit.side_effect = lambda: calls.append("commit")
command = CreateSubscriptionCommand(repository, unit_of_work)
result = command.execute({"media_id": "10"}, {"name": "demo"})
calls.append("effect")
assert result == (10, "新增订阅成功")
assert calls == ["stage", "commit", "effect"]
unit_of_work.rollback.assert_not_called()
def test_sync_command_rolls_back_commit_failure_and_skips_effect() -> None:
"""提交失败必须回滚并传播原异常,调用方不能误执行提交后副作用。"""
commit_error = RuntimeError("commit failed")
repository = Mock()
repository.stage_add.return_value = SubscribeStageResult(
11,
"新增订阅成功",
True,
)
unit_of_work = Mock()
unit_of_work.commit.side_effect = commit_error
command = CreateSubscriptionCommand(repository, unit_of_work)
effects: list[str] = []
with pytest.raises(RuntimeError) as raised:
command.execute({"media_id": "11"}, {"name": "demo"})
effects.append("effect")
assert raised.value is commit_error
assert effects == []
unit_of_work.rollback.assert_called_once_with()
def test_sync_command_does_not_commit_duplicate_request() -> None:
"""查重命中沿用旧 ID 和消息,不开启无意义写事务。"""
repository = Mock()
repository.stage_add.return_value = SubscribeStageResult(
12,
"订阅已存在",
False,
)
unit_of_work = Mock()
command = CreateSubscriptionCommand(repository, unit_of_work)
assert command.execute({}, {}) == (12, "订阅已存在")
unit_of_work.commit.assert_not_called()
unit_of_work.rollback.assert_not_called()
def test_sync_event_failure_does_not_roll_back_committed_subscription() -> None:
"""事件属于提交后副作用,失败只向上传播且不能伪装成数据库回滚。"""
calls: list[str] = []
repository = Mock()
repository.stage_add.return_value = SubscribeStageResult(
13,
"新增订阅成功",
True,
)
unit_of_work = Mock()
unit_of_work.commit.side_effect = lambda: calls.append("commit")
event_error = RuntimeError("event failed")
def send_event(_subscribe_id: int) -> None:
"""模拟 Chain 在提交后发送订阅事件失败。"""
calls.append("event")
raise event_error
command = CreateSubscriptionCommand(repository, unit_of_work)
with pytest.raises(RuntimeError) as raised:
command.execute({}, {}, after_commit=send_event)
assert raised.value is event_error
assert calls == ["commit", "event"]
unit_of_work.rollback.assert_not_called()
@pytest.mark.asyncio
async def test_async_command_rolls_back_staging_failure() -> None:
"""异步 flush 或唯一约束失败同样由命令回滚,不留部分写入。"""
staging_error = RuntimeError("flush failed")
repository = Mock()
repository.async_stage_add = AsyncMock(side_effect=staging_error)
unit_of_work = Mock()
unit_of_work.commit = AsyncMock()
unit_of_work.rollback = AsyncMock()
command = AsyncCreateSubscriptionCommand(repository, unit_of_work)
with pytest.raises(RuntimeError) as raised:
await command.execute({}, {})
assert raised.value is staging_error
unit_of_work.commit.assert_not_awaited()
unit_of_work.rollback.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_async_report_failure_happens_after_event_without_rollback() -> None:
"""异步上报失败保留事件先行顺序,也不回滚已经提交的订阅。"""
calls: list[str] = []
repository = Mock()
repository.async_stage_add = AsyncMock(
return_value=SubscribeStageResult(14, "新增订阅成功", True)
)
unit_of_work = Mock()
unit_of_work.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
unit_of_work.rollback = AsyncMock()
report_error = RuntimeError("report failed")
async def send_event_and_report(_subscribe_id: int) -> None:
"""模拟 Chain 先发事件再执行统计上报。"""
calls.append("event")
calls.append("report")
raise report_error
command = AsyncCreateSubscriptionCommand(repository, unit_of_work)
with pytest.raises(RuntimeError) as raised:
await command.execute({}, {}, after_commit=send_event_and_report)
assert raised.value is report_error
assert calls == ["commit", "event", "report"]
unit_of_work.rollback.assert_not_awaited()
def test_async_batch_command_commits_once_and_stages_each_added_intent(db) -> None:
"""真实数据库中的多季订阅与各自事件、通知、统计 intent 只提交一次。"""
db.watermark(Subscribe, OutboxMessage)
async def execute() -> tuple[tuple[int, ...], list[int]]:
"""在真实 AsyncSession 中执行批量 writer 并记录提交后回调。"""
async with async_session_scope() as session:
actual_uow = SqlAlchemyAsyncUnitOfWork(session)
unit_of_work = Mock()
unit_of_work.commit = AsyncMock(wraps=actual_uow.commit)
unit_of_work.rollback = AsyncMock(wraps=actual_uow.rollback)
effects: list[int] = []
async def after_commit(subscribe_id: int) -> bool:
"""证明冻结在每季请求内的副作用只在唯一提交后执行。"""
assert unit_of_work.commit.await_count == 1
effects.append(subscribe_id)
return True
requests = [
replace(
_batch_request("servarr-batch-success", season),
after_commit=after_commit,
)
for season in (1, 2)
]
writer = SessionSubscriptionBatchWriter(
repository=SessionSubscriptionRepository(session),
unit_of_work=unit_of_work,
outbox=SqlAlchemyAsyncOutboxStager(session),
dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(
async_session_scope
),
)
results = await writer.async_add(requests)
unit_of_work.commit.assert_awaited_once_with()
unit_of_work.rollback.assert_not_awaited()
return tuple(result.subscribe_id for result in results), effects
subscribe_ids, effects = asyncio.run(execute())
assert effects == list(subscribe_ids)
rows = db.session.execute(
select(Subscribe)
.where(Subscribe.media_id == "servarr-batch-success")
.order_by(Subscribe.season)
).scalars().all()
assert [(row.id, row.season) for row in rows] == [
(subscribe_ids[0], 1),
(subscribe_ids[1], 2),
]
intents = db.session.execute(
select(OutboxMessage)
.where(OutboxMessage.event_key.contains("servarr-batch-success"))
.order_by(OutboxMessage.id)
).scalars().all()
assert [intent.topic for intent in intents] == [
"subscribe.added",
"subscribe.added.notification",
"subscribe.added.report",
"subscribe.added",
"subscribe.added.notification",
"subscribe.added.report",
]
assert all(intent.status == "completed" for intent in intents)
def test_async_batch_command_rolls_back_all_rows_when_later_season_fails(db) -> None:
"""真实数据库中后一季暂存失败时回滚此前季和已经暂存的 outbox intents。"""
db.watermark(Subscribe, OutboxMessage)
requests = [
_batch_request("servarr-batch-rollback", 1),
_batch_request("servarr-batch-rollback", 2),
]
failure = RuntimeError("second season failed")
async def execute() -> None:
"""让第二次暂存失败,并使用真实 UoW 验证完整回滚。"""
async with async_session_scope() as session:
repository = SessionSubscriptionRepository(session)
stage_calls = 0
async def fail_second(*args, **kwargs):
"""第一季真实 flush第二季在同一事务内抛出失败。"""
nonlocal stage_calls
stage_calls += 1
if stage_calls == 2:
raise failure
return await repository.async_stage_add(*args, **kwargs)
failing_repository = Mock()
failing_repository.async_stage_add = AsyncMock(side_effect=fail_second)
actual_uow = SqlAlchemyAsyncUnitOfWork(session)
unit_of_work = Mock()
unit_of_work.commit = AsyncMock(wraps=actual_uow.commit)
unit_of_work.rollback = AsyncMock(wraps=actual_uow.rollback)
command = AsyncCreateSubscriptionBatchCommand(
repository=failing_repository,
unit_of_work=unit_of_work,
outbox=SqlAlchemyAsyncOutboxStager(session),
)
with pytest.raises(RuntimeError) as raised:
await command.execute(requests)
assert raised.value is failure
unit_of_work.commit.assert_not_awaited()
unit_of_work.rollback.assert_awaited_once_with()
asyncio.run(execute())
rows = db.session.execute(
select(Subscribe).where(
Subscribe.media_id == "servarr-batch-rollback"
)
).scalars().all()
intents = db.session.execute(
select(OutboxMessage).where(
OutboxMessage.event_key.contains("servarr-batch-rollback")
)
).scalars().all()
assert rows == []
assert intents == []
def test_default_sync_writer_persists_once_and_reuses_duplicate(db) -> None:
"""Chain 默认入口使用独立事务写入,重复媒体身份返回同一订阅。"""
db.watermark(Subscribe)
media = _media("arch-221-sync")
after_commit = Mock()
writer = _writer()
first = add_subscribe(
mediainfo=media,
subscribe_oper=writer,
after_commit=after_commit,
)
second = add_subscribe(
mediainfo=media,
subscribe_oper=writer,
after_commit=after_commit,
)
assert first[0] > 0
assert first[1] == "新增订阅成功"
assert second == (first[0], "订阅已存在")
db.session.expire_all()
rows = Subscribe.list_by_media_identity(
db.session,
media_source=MediaSource.TMDB,
media_id="arch-221-sync",
)
assert [row.id for row in rows] == [first[0]]
assert after_commit.call_args_list == [((first[0],), {})]
def test_default_sync_writer_allows_reused_subscribe_id_with_retained_outbox(db) -> None:
"""删除订阅后复用数据库主键时,新增事件仍以本次创建事实区分。"""
db.watermark(Subscribe, OutboxMessage)
media = _media("arch-221-reused-id")
writer = _writer()
first = add_subscribe(
mediainfo=media,
subscribe_oper=writer,
after_commit=lambda _subscribe_id: True,
)
with SessionFactory() as session:
session.execute(delete(Subscribe).where(Subscribe.id == first[0]))
session.commit()
second = add_subscribe(
mediainfo=media,
subscribe_oper=writer,
after_commit=lambda _subscribe_id: True,
)
assert second[0] == first[0]
intents = db.session.execute(
select(OutboxMessage)
.where(OutboxMessage.event_key.contains(media.media_id))
.order_by(OutboxMessage.id)
).scalars().all()
assert [intent.topic for intent in intents] == [
"subscribe.added",
"subscribe.added.report",
"subscribe.added",
"subscribe.added.report",
]
assert len({intent.event_key for intent in intents}) == 4
assert all(intent.status == "completed" for intent in intents)
def test_default_sync_writer_keeps_failed_report_pending_without_raising(db) -> None:
"""新增统计未确认时接口仍成功,事件 intent 收口而统计 intent 等待重试。"""
db.watermark(Subscribe, OutboxMessage)
media = _media("arch-221-report-pending")
subscribe_id, message = add_subscribe(
mediainfo=media,
subscribe_oper=_writer(),
after_commit=lambda _subscribe_id: False,
)
assert subscribe_id > 0
assert message == "新增订阅成功"
intents = db.session.execute(
select(OutboxMessage)
.where(OutboxMessage.event_key.contains(media.media_id))
.order_by(OutboxMessage.id)
).scalars().all()
assert [(intent.topic, intent.status) for intent in intents] == [
("subscribe.added", "completed"),
("subscribe.added.report", "pending"),
]
def test_default_async_writer_keeps_failed_report_pending_without_raising(db) -> None:
"""异步新增入口同样返回成功,并只留下统计 intent 等待重试。"""
db.watermark(Subscribe, OutboxMessage)
media = _media("arch-221-async-report-pending")
async def report_failed(_subscribe_id: int) -> bool:
"""模拟异步统计接口未确认。"""
return False
subscribe_id, message = asyncio.run(
async_add_subscribe(
mediainfo=media,
subscribe_oper=_writer(),
after_commit=report_failed,
)
)
assert subscribe_id > 0
assert message == "新增订阅成功"
intents = db.session.execute(
select(OutboxMessage)
.where(OutboxMessage.event_key.contains(media.media_id))
.order_by(OutboxMessage.id)
).scalars().all()
assert [(intent.topic, intent.status) for intent in intents] == [
("subscribe.added", "completed"),
("subscribe.added.report", "pending"),
]
def test_stage_add_reuses_explicit_session_without_commit(db, monkeypatch) -> None:
"""Oper 将调用方 Session 传给 Model 查询原语,暂存期间不自行提交。"""
db.watermark(Subscribe)
commit = Mock(wraps=db.session.commit)
exists = Mock(wraps=Subscribe.exists)
monkeypatch.setattr(db.session, "commit", commit)
monkeypatch.setattr(Subscribe, "exists", exists)
oper = SubscribeOper(db.session)
identity = {
"media_source": str(MediaSource.TMDB),
"media_id": "arch-221-stage",
"music_type": None,
"season": None,
"episode_group": None,
}
staged = oper.stage_add(
identity,
{
"name": "Oper SQL",
"type": MediaType.MOVIE.value,
"state": "N",
**identity,
},
)
assert staged.created is True
assert staged.subscribe_id > 0
assert exists.call_args.args[0] is db.session
commit.assert_not_called()
db.session.rollback()
def test_default_async_writer_persists_committed_row(db) -> None:
"""Agent/API 使用的异步 Chain 入口在返回前已完成请求级提交。"""
db.watermark(Subscribe)
media = _media("arch-221-async")
subscribe_id, message = asyncio.run(
async_add_subscribe(mediainfo=media, subscribe_oper=_writer())
)
assert subscribe_id > 0
assert message == "新增订阅成功"
db.session.expire_all()
persisted = Subscribe.get(db.session, subscribe_id)
assert persisted is not None
assert persisted.media_id == "arch-221-async"