fix(subscribe): preserve custom categories from edit forms (#7138)

This commit is contained in:
jxxghp
2026-10-22 17:51:07 +08:00
committed by GitHub
parent 9e24ea0c68
commit 0a429cd8bd
2 changed files with 150 additions and 1 deletions

View File

@@ -189,6 +189,7 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
"audio_quality", "audio_format",
"custom_words", "keyword", "save_path", "episode_group", "downloader",
"min_bitrate", "min_bit_depth", "min_sample_rate",
"media_category_id", "media_category",
})
PUBLIC_WRITE_EXCLUDED_FIELDS: ClassVar[frozenset[str]] = frozenset({
@@ -319,11 +320,17 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
def to_public_write_payload(self, *, exclude_unset: bool = False) -> Dict[str, Any]:
"""
裁剪公共订阅写入字段,可仅保留更新请求显式提交的字段。
文本分类表单会带回详情中的空稳定 ID;同时提交非空路径时,按旧路径输入处理,
避免分类规范化把新输入误判为清空。路径和 ID 都为空时保留显式清空语义。
"""
return self.model_dump(
payload = self.model_dump(
exclude=set(self.PUBLIC_WRITE_EXCLUDED_FIELDS),
exclude_unset=exclude_unset,
)
if payload.get("media_category") and not str(payload.get("media_category_id") or "").strip():
payload.pop("media_category_id", None)
return payload
class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):

View File

@@ -1802,6 +1802,148 @@ def _EndpointHistory(**kwargs: object) -> SubscriptionHistorySnapshot:
return SubscriptionHistorySnapshot(**values)
@pytest.mark.parametrize("media_type", [MediaType.MOVIE, MediaType.TV, MediaType.MUSIC])
@pytest.mark.parametrize("previous_category", [None, "旧目录"])
def test_update_subscribe_round_trip_preserves_custom_category(media_type, previous_category):
"""
完整编辑表单带回空分类 ID 时,新填的自定义分类仍须保存并在详情回显。
"""
from app.api.endpoints.subscribe import read_subscribe, update_subscribe
repository = _SubscriptionRepositoryFake(
_EndpointSubscribe(
id=7134,
username="alice",
type=media_type.value,
media_category=previous_category,
)
)
current_user = _EndpointUser(name="alice", is_superuser=False)
details = asyncio.run(
read_subscribe(
subscribe_id=7134,
query=_subscription_query(repository),
current_user=current_user,
)
)
form = details.model_dump(mode="json")
assert form["media_category_id"] is None
form["media_category"] = "剧集/纪录片"
with patch("app.runtime.events.eventmanager.async_send_event", new=AsyncMock()) as send_event:
response = asyncio.run(
update_subscribe(
subscribe_in=Subscribe.model_validate(form),
mutation=_subscription_mutation(repository),
current_user=current_user,
)
)
reopened = asyncio.run(
read_subscribe(
subscribe_id=7134,
query=_subscription_query(repository),
current_user=current_user,
)
)
assert response.success
assert reopened.media_category_id is None
assert reopened.media_category == "剧集/纪录片"
assert send_event.await_args.args[1]["subscribe_info"]["media_category"] == "剧集/纪录片"
@pytest.mark.parametrize(
("patch_payload", "expected_category"),
[
({"media_category": ""}, None),
({"media_category": None}, None),
({"media_category_id": None, "media_category": ""}, None),
({"media_category_id": None}, None),
({"media_category_id": ""}, None),
({"keyword": "新关键字"}, "剧集/纪录片"),
],
)
def test_update_subscribe_distinguishes_cleared_and_omitted_category(patch_payload, expected_category):
"""
清空自定义分类必须覆盖旧值,未提交分类字段则保留原设置。
"""
from app.api.endpoints.subscribe import update_subscribe
repository = _SubscriptionRepositoryFake(
_EndpointSubscribe(
id=7134,
username="alice",
type=MediaType.TV.value,
media_category="剧集/纪录片",
)
)
with patch("app.runtime.events.eventmanager.async_send_event", new=AsyncMock()):
response = asyncio.run(
update_subscribe(
subscribe_in=Subscribe(id=7134, **patch_payload),
mutation=_subscription_mutation(repository),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
assert response.success
updated = repository.get(7134)
assert updated is not None
assert updated.media_category_id is None
assert updated.media_category == expected_category
@pytest.mark.parametrize("exclude_unset", [False, True])
@pytest.mark.parametrize("category_id", [None, "", " "])
def test_subscribe_public_write_preserves_custom_category_with_empty_id(exclude_unset, category_id):
"""
新增和更新载荷中的空稳定 ID 不得吞掉文本框提交的自定义分类。
"""
from app.application.classification.reference import normalize_classification_reference_payload
subscribe_in = Subscribe(
type=MediaType.TV.value,
media_category_id=category_id,
media_category="剧集/纪录片",
)
normalized = normalize_classification_reference_payload(
subscribe_in.to_public_write_payload(exclude_unset=exclude_unset),
media_type=MediaType.TV,
)
assert normalized["media_category_id"] is None
assert normalized["media_category"] == "剧集/纪录片"
@pytest.mark.parametrize("category", ["../逃逸", r"C:\绝对目录"])
def test_update_subscribe_rejects_unsafe_custom_category_with_empty_id(category):
"""
空分类 ID 兼容文本输入时仍须校验路径,拒绝后保留原分类。
"""
from app.api.endpoints.subscribe import update_subscribe
repository = _SubscriptionRepositoryFake(
_EndpointSubscribe(
id=7134,
username="alice",
type=MediaType.TV.value,
media_category="原分类",
)
)
with patch("app.runtime.events.eventmanager.async_send_event", new=AsyncMock()) as send_event:
response = asyncio.run(
update_subscribe(
subscribe_in=Subscribe(id=7134, media_category_id=None, media_category=category),
mutation=_subscription_mutation(repository),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
assert not response.success
assert repository.get(7134).media_category == "原分类"
send_event.assert_not_awaited()
def test_subscribe_accepts_empty_strings_for_numeric_fields():
"""
前端提交音乐订阅时常以空字符串填充数值字段,不应触发 422。