mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
166 lines
6.6 KiB
Python
166 lines
6.6 KiB
Python
"""
|
||
媒体身份的持久化不变量:app/db/models/_identity.py 的 mapper 事件。
|
||
|
||
这条不变量此前靠六个 Oper 各自在建模前调一次归一来保证,是调用点纪律;现在下沉成
|
||
flush 前的事件。因此这里断言的是「绕过 Oper 直接建模写库」时不变量仍然成立——那正是
|
||
下沉之前会漏掉的路径,也是这次改动唯一真正新增的保证。
|
||
|
||
半对身份在持久化侧是「清空 + 告警」而非抛错:这几张表都是记账性写入,因一个次要字段
|
||
就丢掉整条整理历史,代价比留下一条无身份记录更大。告警是这次一并补上的——此前它是
|
||
完全沉默的,脏数据只能靠翻库发现。DTO 侧仍然抛错,两边语义的差异是有意的。
|
||
"""
|
||
import pytest
|
||
|
||
from app.db.models.transferhistory import TransferHistory
|
||
from app.db.models.transferpending import TransferPending
|
||
from app.schemas.types import MediaSource
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _track(db):
|
||
"""把两张表纳入用例级回收。"""
|
||
db.watermark(TransferHistory, TransferPending)
|
||
|
||
|
||
def _history(**identity) -> TransferHistory:
|
||
"""构造一条最小可写的整理历史,只有身份字段按用例变化。"""
|
||
return TransferHistory(src="/downloads/x.mkv", src_storage="local",
|
||
dest="/media/x.mkv", dest_storage="local",
|
||
mode="link", title="身份用例", status=1, files=[],
|
||
**identity)
|
||
|
||
|
||
def _write(db, row):
|
||
"""落库并重新读回,确保拿到的是写入后的值而非内存里的原值。"""
|
||
db.add(row)
|
||
db.session.expire_all()
|
||
return TransferHistory.get(db.session, row.id)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 归一
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def test_alias_source_is_persisted_as_canonical_value(db):
|
||
"""
|
||
别名来源必须落成规范值。
|
||
|
||
同一个来源以 tmdb / themoviedb 两种拼法写进去,按身份查重就会把同一部剧
|
||
当成两条,洗版与去重全部失效。
|
||
"""
|
||
row = _write(db, _history(media_source="tmdb", media_id="550"))
|
||
|
||
assert row.media_source == MediaSource.TMDB.value
|
||
|
||
|
||
def test_media_id_is_stripped(db):
|
||
"""
|
||
ID 两端空白必须去掉——带空格的 ID 与不带的是两个不同的字符串,查重对不上。
|
||
"""
|
||
row = _write(db, _history(media_source=MediaSource.TMDB, media_id=" 550 "))
|
||
|
||
assert row.media_id == "550"
|
||
|
||
|
||
def test_enum_source_is_persisted_as_value_not_repr(db):
|
||
"""
|
||
传枚举时落库的是它的值,不是枚举的字符串表示。
|
||
"""
|
||
row = _write(db, _history(media_source=MediaSource.TMDB, media_id="550"))
|
||
|
||
assert row.media_source == MediaSource.TMDB.value
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 半对身份
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
@pytest.mark.parametrize(
|
||
"identity",
|
||
[
|
||
pytest.param({"media_source": MediaSource.TMDB, "media_id": None}, id="缺ID"),
|
||
pytest.param({"media_source": None, "media_id": "550"}, id="缺来源"),
|
||
pytest.param({"media_source": MediaSource.TMDB, "media_id": "0"}, id="零值ID"),
|
||
pytest.param({"media_source": MediaSource.TMDB, "media_id": " "}, id="空白ID"),
|
||
pytest.param({"media_source": "不存在的源", "media_id": "550"}, id="非法来源"),
|
||
],
|
||
)
|
||
def test_incomplete_identity_is_cleared_on_both_columns(db, identity):
|
||
"""
|
||
身份不成对时两列一起清空,不能只留下半边。
|
||
|
||
只留半边的行既匹配不上任何查重条件,也无法被后续的身份修复流程识别,
|
||
等于永久躺在表里的脏数据。
|
||
"""
|
||
row = _write(db, _history(**identity))
|
||
|
||
assert row.media_source is None
|
||
assert row.media_id is None
|
||
|
||
|
||
def test_incomplete_identity_is_not_silent(db, monkeypatch):
|
||
"""
|
||
清空必须留下告警,且告警里带得上被丢弃的原值。
|
||
|
||
这是本次下沉一并修掉的东西:此前归一是完全沉默的,写进一条无身份记录后没有任何
|
||
痕迹,只能靠翻库才发现。告警让它变成日志里可检索的事件——因此消息里必须有原值,
|
||
否则只知道"某处丢了身份",仍然定位不到是谁写的。
|
||
|
||
直接换掉模块里的 logger 而不用 caplog:项目的 LoggerManager 把 propagate 关了
|
||
(app/runtime/log.py:333),caplog 挂在 root 上根本收不到。
|
||
"""
|
||
warnings: list[str] = []
|
||
monkeypatch.setattr("app.db.models._identity.logger",
|
||
type("_Spy", (), {"warn": staticmethod(warnings.append)})())
|
||
|
||
_write(db, _history(media_source=MediaSource.TMDB, media_id=None))
|
||
|
||
assert len(warnings) == 1
|
||
assert "媒体身份不成对" in warnings[0]
|
||
# 原值要出现在消息里,否则日志定位不到是哪条写入
|
||
assert "themoviedb" in warnings[0] and "None" in warnings[0]
|
||
|
||
|
||
def test_complete_identity_does_not_warn(db, monkeypatch):
|
||
"""
|
||
身份完整时不得告警——告警一旦对正常写入也响,就会被当成噪声忽略掉。
|
||
"""
|
||
warnings: list[str] = []
|
||
monkeypatch.setattr("app.db.models._identity.logger",
|
||
type("_Spy", (), {"warn": staticmethod(warnings.append)})())
|
||
|
||
_write(db, _history(media_source="tmdb", media_id=" 550 "))
|
||
|
||
assert warnings == []
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 覆盖范围
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def test_normalization_also_applies_on_update(db):
|
||
"""
|
||
更新路径同样归一——只管 insert 会让一条合法记录被后续更新改成半对身份。
|
||
"""
|
||
row = _write(db, _history(media_source=MediaSource.TMDB, media_id="550"))
|
||
|
||
row.update(db.session, {"media_source": "douban", "media_id": " 1291546 "})
|
||
db.session.commit()
|
||
db.session.expire_all()
|
||
updated = TransferHistory.get(db.session, row.id)
|
||
|
||
assert updated.media_source == MediaSource.Douban.value
|
||
assert updated.media_id == "1291546"
|
||
|
||
|
||
def test_tables_without_identity_columns_are_untouched(db):
|
||
"""
|
||
不带身份列的表不受影响——事件挂在 Mapper 上覆盖全部映射,必须靠列名检查收窄,
|
||
否则会去动一张根本没有这两列的表。
|
||
"""
|
||
TransferPending.register(db.session, storage="local", src_path="/mnt/a.mkv",
|
||
now_time="2026-08-14 10:00:00")
|
||
|
||
rows = [r for r in TransferPending.list_all(db.session) if r.src_path == "/mnt/a.mkv"]
|
||
assert len(rows) == 1
|