Files
MoviePilot/tests/test_subscribe_endpoint.py

1425 lines
50 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 types import SimpleNamespace
from unittest import TestCase
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import ValidationError
from app.api.endpoints.subscribe import create_subscribe
from app.schemas.subscribe import Subscribe
from app.schemas.types import EventType, MediaSource, MediaType
class SubscribeEndpointTest(TestCase):
"""
订阅接口回归测试。
"""
def test_read_subscribes_scopes_regular_user_and_keeps_superuser_global(self):
"""
普通用户只能看到自己创建的订阅,超级用户保留全局视图。
"""
from app.api.endpoints.subscribe import list_subscribes, read_subscribes
own = _EndpointSubscribe(id=1, username="alice", name="自己的订阅")
other = _EndpointSubscribe(id=2, username="bob", name="他人的订阅")
legacy = _EndpointSubscribe(id=3, username=None, name="旧订阅")
all_subscribes = [own, other, legacy]
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list",
new=AsyncMock(return_value=all_subscribes),
), patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_username",
new=AsyncMock(return_value=[own]),
):
api_token_result = asyncio.run(list_subscribes(_="api-token"))
self.assertEqual([sub.id for sub in api_token_result], [1, 2, 3])
regular_result = asyncio.run(
read_subscribes(
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertEqual([sub.id for sub in regular_result], [1])
superuser_result = asyncio.run(
read_subscribes(
db=object(),
current_user=_EndpointUser(name="admin", is_superuser=True),
)
)
self.assertEqual([sub.id for sub in superuser_result], [1, 2, 3])
def test_read_subscribe_hides_other_and_legacy_from_regular_user(self):
"""
订阅详情按 owner 隐藏他人和 legacy 订阅,避免泄露订阅行存在性。
"""
from app.api.endpoints.subscribe import read_subscribe
current_user = _EndpointUser(name="alice", is_superuser=False)
cases = [
(_EndpointSubscribe(id=1, username="alice", name="自己的订阅"), 1),
(_EndpointSubscribe(id=2, username="bob", name="他人的订阅"), None),
(_EndpointSubscribe(id=3, username=None, name="旧订阅"), None),
]
for subscribe, expected_id in cases:
with self.subTest(subscribe_id=subscribe.id), patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(return_value=subscribe),
):
result = asyncio.run(
read_subscribe(
subscribe_id=subscribe.id,
db=object(),
current_user=current_user,
)
)
self.assertEqual(getattr(result, "id", None), expected_id)
def test_manage_permission_does_not_allow_cross_user_update(self):
"""
manage 权限不等于跨用户订阅管理权限,普通用户不能修改他人或 legacy 订阅。
"""
from app.api.endpoints.subscribe import update_subscribe
manage_user = _EndpointUser(
name="alice",
is_superuser=False,
permissions={"manage": True},
)
for subscribe in [
_EndpointSubscribe(
id=2,
username="bob",
name="他人的订阅",
total_episode=8,
lack_episode=2,
),
_EndpointSubscribe(
id=3,
username=None,
name="旧订阅",
total_episode=8,
lack_episode=2,
),
]:
with self.subTest(subscribe_id=subscribe.id), patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(return_value=subscribe),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
update_subscribe(
subscribe_in=Subscribe(
id=subscribe.id,
name="改名",
total_episode=8,
lack_episode=2,
),
db=object(),
current_user=manage_user,
)
)
self.assertFalse(response.success)
self.assertEqual(response.message, "订阅不存在")
send_event.assert_not_awaited()
def test_owner_can_update_own_subscribe(self):
"""
owner 可以继续管理自己创建的订阅。
"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=4,
username="alice",
name="旧标题",
total_episode=8,
lack_episode=2,
vote=0.0,
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
update_subscribe(
subscribe_in=Subscribe(
id=4,
name="新标题",
total_episode=8,
lack_episode=2,
),
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
send_event.assert_awaited_once()
def test_update_subscribe_preserves_existing_owner(self):
"""
普通更新不得允许请求体改写订阅 owner。
"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=12,
username="alice",
name="旧标题",
total_episode=8,
lack_episode=2,
vote=0.0,
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
subscribe_in = Subscribe(
id=12,
username="bob",
name="新标题",
total_episode=8,
lack_episode=2,
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
update_subscribe(
subscribe_in=subscribe_in,
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
self.assertEqual(subscribe.username, "alice")
event_type, payload = send_event.await_args.args
self.assertEqual(event_type, EventType.SubscribeModified)
self.assertNotIn("username", payload["fields"])
self.assertEqual(payload["subscribe_info"]["username"], "alice")
def test_update_subscribe_preserves_existing_media_identity_when_omitted(self):
"""普通字段更新未提交身份时,不得把已有媒体身份清空。"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=24,
username="alice",
name="旧标题",
media_source=MediaSource.TMDB,
media_id="12345",
total_episode=8,
lack_episode=2,
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
update_subscribe(
subscribe_in=Subscribe(id=24, name="新标题"),
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
self.assertEqual(subscribe.media_source, MediaSource.TMDB)
self.assertEqual(subscribe.media_id, "12345")
def test_update_subscribe_clears_existing_media_identity_with_empty_pair(self):
"""更新同时显式提交两个空身份字段时,应清空存量身份。"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=26,
username="alice",
name="旧标题",
media_source=MediaSource.TMDB,
media_id="12345",
total_episode=8,
lack_episode=2,
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
update_subscribe(
subscribe_in=Subscribe(
id=26,
name="新标题",
media_source="",
media_id="",
),
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
self.assertIsNone(subscribe.media_source)
self.assertIsNone(subscribe.media_id)
def test_update_subscribe_rejects_partial_media_identity(self):
"""更新媒体身份时只提交来源或 ID 之一应在 Schema 边界直接拒绝。"""
with self.assertRaises(ValidationError):
Subscribe(id=25, media_source=MediaSource.Douban)
def test_update_subscribe_preserves_recognized_music_entity(self):
"""普通编辑不得把专辑改为单曲或覆盖整专完成判定所需的曲目总数。"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=23,
username="alice",
name="叶惠美",
type=MediaType.MUSIC.value,
music_type="album",
total_tracks=11,
total_episode=0,
lack_episode=0,
vote=0.0,
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
subscribe_in = Subscribe(
id=23,
name="叶惠美",
type=MediaType.MUSIC.value,
music_type="recording",
total_tracks=1,
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
update_subscribe(
subscribe_in=subscribe_in,
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
self.assertEqual(subscribe.type, MediaType.MUSIC.value)
self.assertEqual(subscribe.music_type, "album")
self.assertEqual(subscribe.total_tracks, 11)
def test_superuser_can_update_other_and_legacy_subscribe(self):
"""
超级用户可以管理他人和 legacy 订阅。
"""
from app.api.endpoints.subscribe import update_subscribe_status
current_user = _EndpointUser(name="admin", is_superuser=True)
for subscribe in [
_EndpointSubscribe(id=5, username="bob", state="R", name="他人的订阅"),
_EndpointSubscribe(id=6, username=None, state="R", name="旧订阅"),
]:
with self.subTest(subscribe_id=subscribe.id), patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
update_subscribe_status(
subid=subscribe.id,
state="S",
db=object(),
current_user=current_user,
)
)
self.assertTrue(response.success)
send_event.assert_awaited_once()
self.assertEqual(subscribe.state, "S")
def test_share_subscribe_requires_local_owner(self):
"""
分享本地订阅前必须确认当前用户有权读取该订阅行。
"""
from app.api.endpoints.subscribe import subscribe_share
from app.schemas.subscribe import SubscribeShare
other = _EndpointSubscribe(id=7, username="bob", name="他人的订阅")
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(return_value=other),
), patch(
"app.api.endpoints.subscribe.MoviePilotServerHelper.async_sub_share",
new=AsyncMock(return_value=(True, "")),
) as sub_share:
response = asyncio.run(
subscribe_share(
sub=SubscribeShare(
subscribe_id=7,
share_title="分享",
share_comment="",
share_user="alice",
),
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertFalse(response.success)
self.assertEqual(response.message, "订阅不存在")
sub_share.assert_not_awaited()
def test_subscribe_media_identity_returns_owner_when_other_candidate_matches_first(self):
"""
按媒体查询订阅时,他人订阅不能挡住当前用户自己的订阅。
"""
from app.api.endpoints.subscribe import subscribe_media_identity
other = _EndpointSubscribe(
id=13, username="bob", media_source="themoviedb", media_id="123", season=1
)
own = _EndpointSubscribe(
id=14, username="alice", media_source="themoviedb", media_id="123", season=1
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
new=AsyncMock(return_value=[other, own]),
):
result = asyncio.run(
subscribe_media_identity(
media_id="123",
media_source=MediaSource.TMDB,
season=1,
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertEqual(result.id, 14)
def test_subscribe_media_identity_distinguishes_recording_and_album_entities(self):
"""同一来源身份下查询专辑时不能返回单曲订阅。"""
from app.api.endpoints.subscribe import subscribe_media_identity
recording = _EndpointSubscribe(
id=21,
username="alice",
type=MediaType.MUSIC.value,
music_type="recording",
media_source="musicbrainz",
media_id="shared-id",
)
album = _EndpointSubscribe(
id=22,
username="alice",
type=MediaType.MUSIC.value,
music_type="album",
media_source="musicbrainz",
media_id="shared-id",
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
new=AsyncMock(return_value=[recording, album]),
) as list_by_identity:
result = asyncio.run(
subscribe_media_identity(
media_id="shared-id",
media_source=MediaSource.MusicBrainz,
music_type="album",
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertEqual(result.id, 22)
self.assertEqual(list_by_identity.await_args.kwargs["music_type"], "album")
def test_subscribe_media_identity_does_not_fallback_to_title(self):
"""统一身份未命中时不得按标题串联其他来源的订阅。"""
from app.api.endpoints.subscribe import subscribe_media_identity
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
new=AsyncMock(return_value=[]),
), patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_title",
new=AsyncMock(),
) as title_lookup:
result = asyncio.run(
subscribe_media_identity(
media_id="legacy-recording",
media_source=MediaSource.MusicBrainz,
title="周杰伦 - 晴天",
music_type="recording",
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertIsNone(result.id)
title_lookup.assert_not_awaited()
def test_delete_subscribe_by_media_identity_deletes_owner_candidate(self):
"""
按媒体删除订阅时,应在候选集合中删除当前用户自己的订阅。
"""
from app.api.endpoints.subscribe import delete_subscribe_by_media_identity
other = _EndpointSubscribe(
id=15, username="bob", media_source="douban", media_id="douban-1"
)
own = _EndpointSubscribe(
id=16, username="alice", media_source="douban", media_id="douban-1"
)
db = _EndpointAsyncDb()
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
new=AsyncMock(return_value=[other, own]),
), patch(
"app.api.endpoints.subscribe.build_subscribe_event_payload",
return_value={"id": 16, "media_source": "douban", "media_id": "douban-1"},
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
delete_subscribe_by_media_identity(
media_id="douban-1",
media_source=MediaSource.Douban,
db=db,
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
self.assertEqual(db.deleted, [own])
send_event.assert_awaited_once()
def test_delete_subscribe_by_media_identity_forwards_music_entity(self):
"""取消专辑订阅时必须把实体类型传给统一身份查询。"""
from app.api.endpoints.subscribe import delete_subscribe_by_media_identity
db = _EndpointAsyncDb()
with patch(
"app.api.endpoints.subscribe.list_subscribes_by_media_identity",
new=AsyncMock(return_value=[]),
) as list_by_key:
response = asyncio.run(
delete_subscribe_by_media_identity(
media_id="release-group-1",
media_source=MediaSource.MusicBrainz,
music_type="album",
db=db,
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
list_by_key.assert_awaited_once_with(
db,
MediaSource.MusicBrainz,
"release-group-1",
None,
"album",
)
def test_search_subscribes_regular_user_schedules_only_owned_rows(self):
"""
普通用户批量搜索只按自己的订阅 ID 入队。
"""
from app.api.endpoints.subscribe import search_subscribes
background_tasks = _EndpointBackgroundTasks()
owned = [
_EndpointSubscribe(id=17, username="alice", state="R"),
_EndpointSubscribe(id=18, username="alice", state="R"),
]
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_username",
new=AsyncMock(return_value=owned),
), patch("app.api.endpoints.subscribe.Scheduler") as scheduler_cls:
response = asyncio.run(
search_subscribes(
background_tasks=background_tasks,
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
self.assertEqual(
[task["kwargs"]["sid"] for task in background_tasks.tasks],
[17, 18],
)
self.assertEqual(scheduler_cls.return_value.start.call_count, 0)
def test_subscribe_files_hides_other_user_row(self):
"""
订阅文件接口不能向普通用户暴露他人的订阅文件信息。
"""
from app.api.endpoints.subscribe import subscribe_files
other = _EndpointSubscribe(id=19, username="bob", name="他人的订阅")
with patch(
"app.api.endpoints.subscribe.Subscribe.get",
return_value=other,
), patch(
"app.api.endpoints.subscribe.SubscribeChain"
) as subscribe_chain:
result = subscribe_files(
subscribe_id=19,
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
self.assertEqual(result.episodes, {})
subscribe_chain.return_value.subscribe_files_info.assert_not_called()
def test_user_subscribes_hides_other_user_list(self):
"""
普通用户不能通过 username 参数读取其他用户订阅列表。
"""
from app.api.endpoints.subscribe import user_subscribes
with patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_username",
new=AsyncMock(return_value=[_EndpointSubscribe(id=20, username="bob")]),
) as list_by_username:
result = asyncio.run(
user_subscribes(
username="bob",
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertEqual(result, [])
list_by_username.assert_not_awaited()
def test_subscribe_oper_async_add_scopes_duplicate_lookup_by_owner(self):
"""
owner-aware 创建不应把他人已有订阅当作当前用户订阅。
"""
from app.db.subscribe_oper import SubscribeOper
other = _EndpointSubscribe(id=21, username="bob")
own = _EndpointSubscribe(id=22, username="alice")
created = SimpleNamespace(async_create=AsyncMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.async_exists = AsyncMock(return_value=other)
subscribe_model.async_exists_by_username = AsyncMock(
side_effect=[None, own]
)
subscribe_model.return_value = created
sid, message = asyncio.run(
SubscribeOper(db=object()).async_add(
mediainfo=_EndpointMediaInfo(),
username="alice",
owner_scope=True,
season=1,
)
)
self.assertEqual(sid, 22)
self.assertEqual(message, "新增订阅成功")
subscribe_model.async_exists.assert_not_awaited()
self.assertEqual(subscribe_model.async_exists_by_username.await_count, 2)
created.async_create.assert_awaited_once()
def test_subscribe_history_scopes_regular_user_and_keeps_superuser_global(self):
"""
订阅历史分页必须在 DB 层按 owner 收窄,避免全局页过滤后误判没有更多数据。
"""
from app.api.endpoints.subscribe import subscribe_history
own = _EndpointSubscribe(
id=8,
username="alice",
name="自己的历史",
type=MediaType.MOVIE.value,
)
other = _EndpointSubscribe(
id=9,
username="bob",
name="他人的历史",
type=MediaType.MOVIE.value,
)
legacy = _EndpointSubscribe(
id=10,
username="",
name="旧历史",
type=MediaType.MOVIE.value,
)
db = object()
owner_query = AsyncMock(return_value=[own])
global_query = AsyncMock(return_value=[other, legacy])
with patch(
"app.api.endpoints.subscribe.SubscribeHistory.async_list_by_type",
new=global_query,
), patch(
"app.api.endpoints.subscribe.SubscribeHistory.async_list_by_type_and_username",
new=owner_query,
create=True,
):
regular_result = asyncio.run(
subscribe_history(
mtype=MediaType.MOVIE.value,
page=1,
count=2,
db=db,
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertEqual([history.id for history in regular_result], [8])
owner_query.assert_awaited_once_with(
db,
mtype=MediaType.MOVIE.value,
username="alice",
page=1,
count=2,
)
global_query.assert_not_awaited()
owner_query.reset_mock()
global_query.reset_mock(return_value=True)
global_query.return_value = [own, other, legacy]
superuser_result = asyncio.run(
subscribe_history(
mtype=MediaType.MOVIE.value,
page=1,
count=3,
db=db,
current_user=_EndpointUser(name="admin", is_superuser=True),
)
)
self.assertEqual([history.id for history in superuser_result], [8, 9, 10])
global_query.assert_awaited_once_with(
db,
mtype=MediaType.MOVIE.value,
page=1,
count=3,
)
owner_query.assert_not_awaited()
def test_delete_subscribe_history_hides_other_from_regular_user(self):
"""
普通用户删除他人订阅历史时按不存在处理。
"""
from app.api.endpoints.subscribe import delete_subscribe_history
other = _EndpointSubscribe(
id=11,
username="bob",
name="他人的历史",
type=MediaType.MOVIE.value,
)
with patch(
"app.api.endpoints.subscribe.SubscribeHistory.async_get",
new=AsyncMock(return_value=other),
), patch(
"app.api.endpoints.subscribe.SubscribeHistory.async_delete",
new=AsyncMock(),
) as async_delete:
response = asyncio.run(
delete_subscribe_history(
history_id=11,
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertTrue(response.success)
async_delete.assert_not_awaited()
def test_global_refresh_and_check_require_superuser(self):
"""
没有 owner 参数的全局订阅任务只允许超级用户触发。
"""
from app.api.endpoints.subscribe import check_subscribes, refresh_subscribes
regular_user = _EndpointUser(name="alice", is_superuser=False)
superuser = _EndpointUser(name="admin", is_superuser=True)
for endpoint in [refresh_subscribes, check_subscribes]:
with self.subTest(endpoint=endpoint.__name__), patch(
"app.api.endpoints.subscribe.Scheduler"
) as scheduler:
response = endpoint(current_user=regular_user)
self.assertFalse(response.success)
self.assertEqual(response.message, "订阅不存在")
scheduler.return_value.start.assert_not_called()
for endpoint, job_id in [
(refresh_subscribes, "subscribe_refresh"),
(check_subscribes, "subscribe_tmdb"),
]:
with self.subTest(endpoint=endpoint.__name__), patch(
"app.api.endpoints.subscribe.Scheduler"
) as scheduler:
response = endpoint(current_user=superuser)
self.assertTrue(response.success)
scheduler.return_value.start.assert_called_once_with(job_id)
def test_create_subscribe_excludes_system_fields_from_write_payload(self):
"""
新增订阅时不应把历史 ID、媒体元数据和响应派生字段传入持久化链路。
"""
subscribe_in = Subscribe(
id=99,
name="测试剧集",
year="2026",
type=MediaType.TV.value,
season=1,
poster="old-poster.jpg",
backdrop="old-backdrop.jpg",
vote=8.0,
description="旧历史简介",
total_episode=10,
lack_episode=3,
)
self.assertEqual(subscribe_in.completed_episode, 7)
with patch(
"app.api.endpoints.subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "新增订阅成功")),
) as async_add:
response = asyncio.run(
create_subscribe(
subscribe_in=subscribe_in,
current_user=_EndpointUser(name="moviepilot-user", is_superuser=False),
)
)
self.assertTrue(response.success)
payload = async_add.await_args.kwargs
for field in ("id", "poster", "backdrop", "vote", "description", "completed_episode"):
self.assertNotIn(field, payload)
self.assertEqual(payload["username"], "moviepilot-user")
self.assertTrue(payload["owner_scope"])
def test_create_subscribe_ignores_runtime_fact_fields(self):
"""
公共新增接口只能写目标和配置,调用方携带的运行事实不得进入新增链路。
"""
subscribe_in = Subscribe(
name="测试剧集",
year="2026",
type=MediaType.TV.value,
season=1,
total_episode=10,
lack_episode=3,
note=[1, 2, 3],
state="S",
last_update="2026-07-20 12:00:00",
username="forged-user",
current_priority=90,
episode_priority={"1": 90},
date="2026-07-19 12:00:00",
)
with patch(
"app.api.endpoints.subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "新增订阅成功")),
) as async_add:
response = asyncio.run(
create_subscribe(
subscribe_in=subscribe_in,
current_user=_EndpointUser(name="moviepilot-user", is_superuser=False),
)
)
self.assertTrue(response.success)
payload = async_add.await_args.kwargs
self.assertEqual(payload["username"], "moviepilot-user")
for field in (
"lack_episode",
"note",
"state",
"last_update",
"current_priority",
"episode_priority",
"date",
"completed_episode",
):
self.assertNotIn(field, payload)
def test_create_subscribe_preserves_special_season_zero_with_douban_identity(self):
"""
新增订阅带豆瓣 ID 且显式指定 S0 时,标题规整不应覆盖调用方传入的季号。
"""
subscribe_in = Subscribe(
name="测试剧集",
year="2026",
type=MediaType.TV.value,
media_source=MediaSource.Douban,
media_id="12345",
season=0,
total_episode=5,
lack_episode=5,
)
with patch(
"app.api.endpoints.subscribe.MetaInfo",
return_value=SimpleNamespace(name="测试剧集", begin_season=None),
), patch(
"app.api.endpoints.subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "新增订阅成功")),
) as async_add:
response = asyncio.run(
create_subscribe(
subscribe_in=subscribe_in,
current_user=_EndpointUser(name="moviepilot-user", is_superuser=False),
)
)
self.assertTrue(response.success)
self.assertEqual(async_add.await_args.kwargs["season"], 0)
self.assertTrue(async_add.await_args.kwargs["owner_scope"])
def test_create_subscribe_keeps_superuser_global_deduplication(self):
"""
超级用户新增订阅保持全局去重语义。
"""
subscribe_in = Subscribe(
name="测试电影",
year="2026",
type=MediaType.MOVIE.value,
)
with patch(
"app.api.endpoints.subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "订阅已存在")),
) as async_add:
response = asyncio.run(
create_subscribe(
subscribe_in=subscribe_in,
current_user=_EndpointUser(name="admin", is_superuser=True),
)
)
self.assertTrue(response.success)
self.assertFalse(async_add.await_args.kwargs["owner_scope"])
def test_update_status_sends_modified_event_payload_with_scene_and_fields(self):
"""
状态更新只负责发出订阅修改事件,并携带场景和真实变更字段。
"""
from app.api.endpoints.subscribe import update_subscribe_status
subscribe = _EndpointSubscribe(id=5, state="R", name="测试订阅")
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
update_subscribe_status(
subid=5,
state="S",
db=object(),
current_user=_EndpointUser(name="admin", is_superuser=True),
)
)
self.assertTrue(response.success)
send_event.assert_awaited_once()
event_type, payload = send_event.await_args.args
self.assertEqual(event_type, EventType.SubscribeModified)
self.assertEqual(payload["subscribe_id"], 5)
self.assertEqual(payload["scene"], "status")
self.assertEqual(payload["fields"], ["state"])
self.assertEqual(payload["old_subscribe_info"]["state"], "R")
self.assertEqual(payload["subscribe_info"]["state"], "S")
def test_reset_sends_modified_event_payload_with_reset_scene(self):
"""
reset 事件需要明确 scene消费者不需要再从字段差异猜测用户意图。
"""
from app.api.endpoints.subscribe import reset_subscribes
subscribe = _EndpointSubscribe(
id=6,
state="S",
name="测试订阅",
total_episode=10,
lack_episode=3,
manual_total_episode=92,
note=[1, 2],
current_priority=80,
episode_priority={"1": 80},
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
reset_subscribes(
subid=6,
db=object(),
current_user=_EndpointUser(name="admin", is_superuser=True),
)
)
self.assertTrue(response.success)
send_event.assert_awaited_once()
event_type, payload = send_event.await_args.args
self.assertEqual(event_type, EventType.SubscribeModified)
self.assertEqual(payload["subscribe_id"], 6)
self.assertEqual(payload["scene"], "reset")
self.assertEqual(
payload["fields"],
[
"current_priority",
"episode_priority",
"lack_episode",
"manual_total_episode",
"note",
"state",
],
)
self.assertEqual(payload["subscribe_info"]["note"], [])
self.assertEqual(payload["subscribe_info"]["lack_episode"], 10)
self.assertEqual(payload["subscribe_info"]["manual_total_episode"], 0)
def test_update_subscribe_sends_modified_event_payload_without_progress_refresh(self):
"""
普通更新只发送 modify 事件;进度刷新由事件消费者或后续流程处理。
"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=7,
name="旧标题",
total_episode=8,
lack_episode=2,
vote=0.0,
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
subscribe_in = Subscribe(id=7, name="新标题", total_episode=8, lack_episode=2)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
) as send_event:
response = asyncio.run(
update_subscribe(
subscribe_in=subscribe_in,
db=object(),
current_user=_EndpointUser(name="admin", is_superuser=True),
)
)
self.assertTrue(response.success)
send_event.assert_awaited_once()
event_type, payload = send_event.await_args.args
self.assertEqual(event_type, EventType.SubscribeModified)
self.assertEqual(payload["subscribe_id"], 7)
self.assertEqual(payload["scene"], "update")
self.assertEqual(payload["fields"], ["name"])
self.assertEqual(payload["old_subscribe_info"]["name"], "旧标题")
self.assertEqual(payload["subscribe_info"]["name"], "新标题")
def test_update_subscribe_ignores_runtime_fact_fields(self):
"""
公共普通更新不得覆盖运行事实,状态调整继续由专用接口负责。
"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=8,
username="alice",
name="旧标题",
total_episode=10,
lack_episode=5,
state="R",
note=[1, 2, 3, 4, 5],
current_priority=60,
episode_priority={"1": 60},
last_update="2026-07-19 12:00:00",
date="2026-07-18 12:00:00",
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
subscribe_in = Subscribe(
id=8,
name="新标题",
total_episode=10,
lack_episode=0,
state="S",
note=[],
current_priority=100,
episode_priority={"1": 100},
last_update="2026-07-20 12:00:00",
date="2026-07-20 12:00:00",
)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
update_subscribe(
subscribe_in=subscribe_in,
db=object(),
current_user=_EndpointUser(name="admin", is_superuser=True),
)
)
self.assertTrue(response.success)
self.assertEqual(subscribe.name, "新标题")
self.assertEqual(subscribe.lack_episode, 5)
self.assertEqual(subscribe.state, "R")
self.assertEqual(subscribe.note, [1, 2, 3, 4, 5])
self.assertEqual(subscribe.current_priority, 60)
self.assertEqual(subscribe.episode_priority, {"1": 60})
self.assertEqual(subscribe.last_update, "2026-07-19 12:00:00")
self.assertEqual(subscribe.date, "2026-07-18 12:00:00")
def test_update_subscribe_derives_lack_when_total_episode_increases(self):
"""
公共更新扩大目标范围时,缺失集数与人工总集数标记仍由服务端派生。
"""
from app.api.endpoints.subscribe import update_subscribe
subscribe = _EndpointSubscribe(
id=9,
username="alice",
name="测试剧集",
total_episode=10,
lack_episode=2,
manual_total_episode=0,
sites=[],
search_imdbid=0,
filter_groups=[],
start_episode=0,
)
subscribe_in = Subscribe(id=9, name="测试剧集", total_episode=12, lack_episode=0)
with patch(
"app.api.endpoints.subscribe.Subscribe.async_get",
new=AsyncMock(side_effect=[subscribe, subscribe]),
), patch(
"app.api.endpoints.subscribe.eventmanager.async_send_event",
new=AsyncMock(),
):
response = asyncio.run(
update_subscribe(
subscribe_in=subscribe_in,
db=object(),
current_user=_EndpointUser(name="admin", is_superuser=True),
)
)
self.assertTrue(response.success)
self.assertEqual(subscribe.total_episode, 12)
self.assertEqual(subscribe.lack_episode, 4)
self.assertEqual(subscribe.manual_total_episode, 1)
class _EndpointUser(SimpleNamespace):
"""
最小用户替身,模拟订阅 endpoint 依赖的用户权限字段。
"""
def __init__(self, name: str, is_superuser: bool, permissions: dict | None = None):
super().__init__(
name=name,
is_superuser=is_superuser,
permissions=permissions or {},
)
class _EndpointAsyncDb:
"""
最小异步数据库替身,用于观察 endpoint 删除的订阅对象。
"""
def __init__(self):
self.deleted = []
self.committed = False
self.rolled_back = False
async def delete(self, obj):
self.deleted.append(obj)
async def commit(self):
self.committed = True
async def rollback(self):
self.rolled_back = True
class _EndpointBackgroundTasks:
"""
最小后台任务替身,记录 endpoint 入队的任务参数。
"""
def __init__(self):
self.tasks = []
def add_task(self, func, **kwargs):
self.tasks.append({"func": func, "kwargs": kwargs})
class _EndpointMediaInfo:
"""
最小媒体信息替身,模拟 SubscribeOper 写订阅行所需字段。
"""
title = "测试剧集"
year = "2026"
type = MediaType.TV
media_source = MediaSource.TMDB
media_id = "123"
episode_group = None
vote_average = 8.0
overview = "测试简介"
@staticmethod
def get_poster_image():
return "poster.jpg"
@staticmethod
def get_backdrop_image():
return "backdrop.jpg"
class _EndpointSubscribe:
"""
最小订阅替身,模拟 endpoint 依赖的 ORM 对象接口。
"""
def __init__(self, **kwargs):
self.id = kwargs.pop("id", None)
self.username = kwargs.pop("username", None)
self.name = kwargs.pop("name", None)
self.total_episode = kwargs.pop("total_episode", None)
self.lack_episode = kwargs.pop("lack_episode", None)
self.state = kwargs.pop("state", None)
self.note = kwargs.pop("note", None)
self.current_priority = kwargs.pop("current_priority", None)
self.episode_priority = kwargs.pop("episode_priority", None)
self.manual_total_episode = kwargs.pop("manual_total_episode", None)
self.__dict__.update(kwargs)
def to_dict(self):
return {
key: value
for key, value in self.__dict__.items()
if value is not None
}
async def async_update(self, _db, payload):
self.__dict__.update(payload)
def test_subscribe_accepts_empty_strings_for_numeric_fields():
"""前端提交音乐订阅时常以空字符串填充数值字段,不应触发 422。"""
subscribe = Subscribe(
name="Random Access Memories",
type=MediaType.MUSIC.value,
media_source="",
media_id="",
season="",
total_episode="",
start_episode="",
best_version="",
best_version_full="",
current_priority="",
search_imdbid="",
vote="",
episode_priority="",
sites="",
filter_groups="",
)
assert subscribe.media_source is None
assert subscribe.media_id is None
assert subscribe.season is None
assert subscribe.best_version is None
assert subscribe.episode_priority is None
# 空字符串视为未提供,应回退到字段默认值而非 None
assert subscribe.total_episode == 0
assert subscribe.start_episode == 0
assert subscribe.search_imdbid == 0
assert subscribe.vote == 0.0
assert subscribe.sites == []
assert subscribe.filter_groups == []
assert subscribe.type == MediaType.MUSIC.value
def test_subscribe_preserves_explicit_zero_and_numeric_string_values():
"""显式 0 和数字字符串应保持原有行为,不被空字符串归一化影响。"""
subscribe = Subscribe(
name="测试剧集",
type=MediaType.TV.value,
season="2",
media_source=MediaSource.TMDB,
media_id="123",
total_episode=0,
start_episode=0,
search_imdbid=0,
vote=0.0,
)
assert subscribe.season == 2
assert subscribe.media_source == MediaSource.TMDB
assert subscribe.media_id == "123"
assert subscribe.total_episode == 0
assert subscribe.start_episode == 0
assert subscribe.search_imdbid == 0
assert subscribe.vote == 0.0
@pytest.mark.parametrize(
"identity",
[
{"media_source": MediaSource.TMDB},
{"media_id": "123"},
{"media_source": ""},
{"media_id": ""},
{"media_source": "unknown", "media_id": "123"},
{"media_source": MediaSource.TMDB, "media_id": "0"},
{"media_source": MediaSource.TMDB, "media_id": " "},
],
)
def test_subscribe_schema_rejects_incomplete_or_invalid_media_identity(identity):
"""订阅 Schema 自身必须拒绝半对、零值和空白 ID不能只依赖端点兜底。"""
with pytest.raises(ValidationError):
Subscribe(name="测试订阅", **identity)
def test_subscribe_schema_distinguishes_omitted_and_explicit_empty_identity():
"""省略和显式空对都合法,但必须保留字段是否由请求提交的信息。"""
omitted = Subscribe(name="省略身份")
explicit_empty = Subscribe(
name="清空身份",
media_source="",
media_id="",
)
assert omitted.media_source is None
assert omitted.media_id is None
assert not {"media_source", "media_id"}.intersection(omitted.model_fields_set)
assert explicit_empty.media_source is None
assert explicit_empty.media_id is None
assert {"media_source", "media_id"}.issubset(explicit_empty.model_fields_set)
def test_create_subscribe_accepts_music_payload_with_empty_strings():
"""带空字符串的音乐订阅应能通过新增订阅接口,不返回 422。"""
subscribe_in = Subscribe(
name="Random Access Memories",
type=MediaType.MUSIC.value,
music_type="album",
total_tracks=13,
media_source="",
media_id="",
season="",
total_episode="",
episode_priority="",
sites="",
)
with patch(
"app.api.endpoints.subscribe.SubscribeChain.async_add",
new=AsyncMock(return_value=(1, "新增订阅成功")),
) as async_add:
response = asyncio.run(
create_subscribe(
subscribe_in=subscribe_in,
current_user=_EndpointUser(name="moviepilot-user", is_superuser=False),
)
)
assert response.success is True
payload = async_add.await_args.kwargs
# 空字符串回退默认值后应正确传入持久化链路
assert payload["media_source"] is None
assert payload["media_id"] is None
assert payload["total_episode"] == 0
assert payload["sites"] == []
assert payload["type"] == MediaType.MUSIC.value
assert payload["music_type"] == "album"
assert payload["total_tracks"] == 13