mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-18 20:53:59 +08:00
457 lines
20 KiB
Python
457 lines
20 KiB
Python
"""
|
||
插件数据、消息、Agent 会话、Agent 定时任务与下载失败冷却五张表的查询行为。
|
||
|
||
这一组的共同风险是「按用户/插件归属收窄」和「分页 + 排序」:归属条件丢失就是越权,
|
||
分页排序错乱则表现为消息重复或漏掉,两者都不会抛异常。
|
||
"""
|
||
import asyncio
|
||
|
||
import pytest
|
||
|
||
from app.db.models.agentchat import AgentChat
|
||
from app.db.models.agenttask import AgentTask
|
||
from app.db.models.downloadfailure import DownloadFailure
|
||
from app.db.models.message import Message
|
||
from app.db.models.plugindata import PluginData
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _track(db):
|
||
"""把本文件涉及的表纳入用例级回收。"""
|
||
db.watermark(PluginData, Message, AgentChat, AgentTask, DownloadFailure)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# PluginData
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def test_plugindata_is_scoped_by_plugin_id(db):
|
||
"""
|
||
插件数据必须按插件隔离——串读会让一个插件拿到另一个插件的配置。
|
||
"""
|
||
db.add(PluginData(plugin_id="PluginA", key="k1", value={"v": 1}),
|
||
PluginData(plugin_id="PluginA", key="k2", value={"v": 2}),
|
||
PluginData(plugin_id="PluginB", key="k1", value={"v": 3}))
|
||
|
||
rows = PluginData.get_plugin_data(db.session, "PluginA")
|
||
|
||
assert {r.key for r in rows} == {"k1", "k2"}
|
||
assert {r.key for r in asyncio.run(PluginData.async_get_plugin_data(plugin_id="PluginA"))} \
|
||
== {"k1", "k2"}
|
||
|
||
|
||
def test_plugindata_get_by_key_needs_both_plugin_and_key(db):
|
||
"""
|
||
按键取值必须同时匹配插件与键,只匹配键会取到同名键的别家数据。
|
||
"""
|
||
db.add(PluginData(plugin_id="PluginA", key="shared", value={"v": 1}),
|
||
PluginData(plugin_id="PluginB", key="shared", value={"v": 2}))
|
||
|
||
assert PluginData.get_plugin_data_by_key(db.session, "PluginA", "shared").value == {"v": 1}
|
||
assert PluginData.get_plugin_data_by_key(db.session, "PluginB", "shared").value == {"v": 2}
|
||
assert PluginData.get_plugin_data_by_key(db.session, "PluginC", "shared") is None
|
||
assert asyncio.run(PluginData.async_get_plugin_data_by_key(
|
||
plugin_id="PluginA", key="shared")).value == {"v": 1}
|
||
|
||
|
||
def test_plugindata_delete_by_key_removes_only_that_entry(db):
|
||
"""
|
||
删除单个键不能波及同插件的其他键,也不能波及别的插件。
|
||
"""
|
||
db.add(PluginData(plugin_id="PluginA", key="drop", value={"v": 1}),
|
||
PluginData(plugin_id="PluginA", key="keep", value={"v": 2}),
|
||
PluginData(plugin_id="PluginB", key="drop", value={"v": 3}))
|
||
|
||
PluginData.del_plugin_data_by_key(db.session, "PluginA", "drop")
|
||
|
||
assert PluginData.get_plugin_data_by_key(db.session, "PluginA", "drop") is None
|
||
assert PluginData.get_plugin_data_by_key(db.session, "PluginA", "keep") is not None
|
||
assert PluginData.get_plugin_data_by_key(db.session, "PluginB", "drop") is not None
|
||
|
||
|
||
def test_plugindata_delete_all_clears_only_that_plugin(db):
|
||
"""
|
||
卸载插件时清空其数据,不能连带清掉其他插件——那等于误删用户配置。
|
||
"""
|
||
db.add(PluginData(plugin_id="PluginA", key="k1", value={"v": 1}),
|
||
PluginData(plugin_id="PluginB", key="k1", value={"v": 2}))
|
||
|
||
PluginData.del_plugin_data(db.session, "PluginA")
|
||
|
||
assert PluginData.get_plugin_data(db.session, "PluginA") == []
|
||
assert len(PluginData.get_plugin_data_by_plugin_id(db.session, "PluginB")) == 1
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Message
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def _message(reg_time: str, title: str, source: str = None, action: int = 1,
|
||
image: str = None) -> Message:
|
||
"""构造一条消息记录。"""
|
||
return Message(channel="wechat", source=source, mtype="Manual", title=title,
|
||
text=title, reg_time=reg_time, action=action, image=image)
|
||
|
||
|
||
def test_message_list_by_page_is_newest_first_and_paged(db):
|
||
"""
|
||
消息列表按登记时间倒序、同时间按主键倒序,并遵守分页。
|
||
|
||
排序不稳定时相邻两页会出现重复或漏掉的消息,用户看到的是「消息丢了」。
|
||
"""
|
||
for index in range(5):
|
||
db.add(_message(f"2026-08-13 10:00:0{index}", f"msg-{index}"))
|
||
|
||
first_page = Message.list_by_page(db.session, page=1, count=2)
|
||
second_page = Message.list_by_page(db.session, page=2, count=2)
|
||
|
||
assert [m.title for m in first_page] == ["msg-4", "msg-3"]
|
||
assert [m.title for m in second_page] == ["msg-2", "msg-1"]
|
||
|
||
|
||
def test_message_list_by_page_matches_async_twin(db):
|
||
"""
|
||
同步与异步分页必须返回同一批消息,前端两条链路才不会互相矛盾。
|
||
"""
|
||
for index in range(3):
|
||
db.add(_message(f"2026-08-13 11:00:0{index}", f"par-{index}"))
|
||
|
||
sync_titles = [m.title for m in Message.list_by_page(db.session, page=1, count=3)]
|
||
async_titles = [m.title for m in asyncio.run(Message.async_list_by_page(page=1, count=3))]
|
||
|
||
assert sync_titles == async_titles
|
||
|
||
|
||
def test_message_exists_by_source_detects_duplicates(db):
|
||
"""
|
||
来源标识存在性判断用于消息去重,判错会导致同一条通知重复推送。
|
||
"""
|
||
db.add(_message("2026-08-13 10:00:00", "有来源", source="uniq-source-1"))
|
||
|
||
assert Message.exists_by_source(db.session, "uniq-source-1") is True
|
||
assert Message.exists_by_source(db.session, "uniq-source-missing") is False
|
||
|
||
|
||
def test_message_delete_before_is_batched_and_keeps_recent(db):
|
||
"""
|
||
历史消息清理必须分批、遵守上限,且不碰保留期内的消息。
|
||
"""
|
||
for index in range(4):
|
||
db.add(_message(f"2026-01-01 10:00:0{index}", f"old-{index}"))
|
||
recent = db.add(_message("2026-08-13 10:00:00", "recent"))
|
||
|
||
assert Message.delete_before(db.session, before_time="2026-08-01", limit=2) == 2
|
||
assert Message.delete_before(db.session, before_time="2026-08-01", limit=100) == 2
|
||
assert Message.delete_before(db.session, before_time="2026-08-01", limit=100) == 0
|
||
|
||
assert Message.list_by_page(db.session, page=1, count=1)[0].id == recent.id
|
||
|
||
|
||
def test_message_delete_before_keeps_the_row_exactly_at_the_boundary(db):
|
||
"""
|
||
保留时间点上的消息属于「保留期内」,不能被清理掉(``reg_time < before_time``)。
|
||
|
||
比较符若写成 ``<=``,每次清理都会多吃掉恰好落在保留起点的那一批消息;
|
||
数据从不压在边界上时这一字之差完全不可观测,故此处专门把行摆在边界上。
|
||
"""
|
||
boundary = "2026-05-01 00:00:00"
|
||
at_boundary = db.add(_message(boundary, "边界上"))
|
||
db.add(_message("2026-04-30 23:59:59", "边界前一秒"))
|
||
|
||
assert Message.delete_before(db.session, before_time=boundary, limit=100) == 1
|
||
|
||
assert db.session.get(Message, at_boundary.id) is not None
|
||
|
||
|
||
def test_message_async_list_sent_excludes_the_clear_boundary(db):
|
||
"""
|
||
三个清理水位都取「严格晚于水位」的消息,正好落在水位上的必须被滤掉。
|
||
|
||
水位是「本次清空动作发生的时刻」,与它同一秒的消息属于已清空的那一批;
|
||
比较符放宽成 ``>=`` 会让用户清空后又看见最后一条旧消息。
|
||
"""
|
||
boundary, after = "2026-03-01 10:00:00", "2026-03-01 10:00:01"
|
||
db.add(_message(boundary, "bd-系统-边界上"),
|
||
_message(after, "bd-系统-边界后"),
|
||
_message(boundary, "bd-媒体-边界上", image="http://img/1.jpg"),
|
||
_message(after, "bd-媒体-边界后", image="http://img/2.jpg"))
|
||
|
||
def _titles(**clears) -> set:
|
||
"""取本用例写入的消息标题集合,隔离其他用例可能残留的消息。"""
|
||
rows = asyncio.run(Message.async_list_sent_by_page(page=1, count=100, **clears))
|
||
return {m.title for m in rows if m.title.startswith("bd-")}
|
||
|
||
# 全量清空水位:边界上的两条都属于被清空的那一批
|
||
assert _titles(all_clear_before=boundary) == {"bd-系统-边界后", "bd-媒体-边界后"}
|
||
# 系统消息(无图)清空水位:只吃无图消息,带图的媒体消息不受影响
|
||
assert _titles(system_clear_before=boundary) == {
|
||
"bd-系统-边界后", "bd-媒体-边界上", "bd-媒体-边界后"}
|
||
# 媒体消息(有图)清空水位:只吃带图消息,无图的系统消息不受影响
|
||
assert _titles(media_clear_before=boundary) == {
|
||
"bd-系统-边界上", "bd-系统-边界后", "bd-媒体-边界后"}
|
||
|
||
|
||
def test_message_create_and_to_dict_returns_persisted_fields(db):
|
||
"""
|
||
创建后返回的字典必须已带上数据库生成的主键。
|
||
|
||
返回未落库的字段会让调用方拿到 id 为 None 的消息,后续更新无从下手。
|
||
"""
|
||
created = _message("2026-08-13 10:00:00", "新消息").create_and_to_dict(db.session)
|
||
|
||
assert created["id"] is not None
|
||
assert created["title"] == "新消息"
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# AgentChat
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def _chat(session_id: str, user_id: str = "u1", updated_at: str = "2026-08-13 10:00:00",
|
||
username: str = None) -> AgentChat:
|
||
"""构造一条 Agent 会话记录。"""
|
||
return AgentChat(session_id=session_id, user_id=user_id, username=username,
|
||
channel="web", title=session_id, updated_at=updated_at,
|
||
created_at=updated_at, message_count=0)
|
||
|
||
|
||
def test_agentchat_get_by_session_takes_the_newest_row(db):
|
||
"""
|
||
同一会话 ID 存在多行时取主键最大的那条——它才是最新的会话状态。
|
||
"""
|
||
db.add(_chat("s-dup"), _chat("s-dup"))
|
||
newest = db.add(_chat("s-dup"))
|
||
|
||
assert AgentChat.get_by_session(db.session, "s-dup").id == newest.id
|
||
assert asyncio.run(AgentChat.async_get_by_session(session_id="s-dup")).id == newest.id
|
||
|
||
|
||
def test_agentchat_get_by_session_enforces_user_scope(db):
|
||
"""
|
||
传入用户 ID 时必须同时匹配,否则一个用户能读到另一个用户的会话内容。
|
||
"""
|
||
db.add(_chat("s-owned", user_id="alice"))
|
||
|
||
assert AgentChat.get_by_session(db.session, "s-owned", user_id="alice") is not None
|
||
assert AgentChat.get_by_session(db.session, "s-owned", user_id="bob") is None
|
||
assert asyncio.run(AgentChat.async_get_by_session(session_id="s-owned", user_id="bob")) is None
|
||
|
||
|
||
def test_agentchat_list_by_page_matches_either_user_or_username(db):
|
||
"""
|
||
同时给出用户 ID 与用户名时按「或」匹配。
|
||
|
||
渠道侧只有用户名、前端只有用户 ID,改成「与」会让两边各自都查不到自己的会话。
|
||
"""
|
||
db.add(_chat("s-by-id", user_id="uid-1", username=None),
|
||
_chat("s-by-name", user_id="uid-other", username="alice"),
|
||
_chat("s-neither", user_id="uid-x", username="bob"))
|
||
|
||
listed = AgentChat.list_by_page(db.session, user_id="uid-1", username="alice")
|
||
|
||
assert {c.session_id for c in listed} == {"s-by-id", "s-by-name"}
|
||
|
||
|
||
@pytest.mark.parametrize("kwargs,expected", [
|
||
({"user_id": "uid-1"}, {"s-by-id"}),
|
||
({"username": "alice"}, {"s-by-name"}),
|
||
])
|
||
def test_agentchat_list_by_page_single_scope(db, kwargs, expected):
|
||
"""
|
||
只给用户 ID 或只给用户名时,各自按单一条件收窄。
|
||
"""
|
||
db.add(_chat("s-by-id", user_id="uid-1", username=None),
|
||
_chat("s-by-name", user_id="uid-other", username="alice"))
|
||
|
||
assert {c.session_id for c in AgentChat.list_by_page(db.session, **kwargs)} == expected
|
||
|
||
|
||
def test_agentchat_list_by_page_is_newest_first_and_paged(db):
|
||
"""
|
||
会话列表按更新时间倒序分页,顺序错乱会让用户的最近会话沉到后面。
|
||
"""
|
||
for index in range(4):
|
||
db.add(_chat(f"s-p{index}", user_id="uid-page",
|
||
updated_at=f"2026-08-13 10:00:0{index}"))
|
||
|
||
page1 = AgentChat.list_by_page(db.session, page=1, count=2, user_id="uid-page")
|
||
page2 = AgentChat.list_by_page(db.session, page=2, count=2, user_id="uid-page")
|
||
|
||
assert [c.session_id for c in page1] == ["s-p3", "s-p2"]
|
||
assert [c.session_id for c in page2] == ["s-p1", "s-p0"]
|
||
assert [c.session_id for c in asyncio.run(
|
||
AgentChat.async_list_by_page(page=1, count=2, user_id="uid-page"))] == ["s-p3", "s-p2"]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# AgentTask
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def _task(name: str, user_id: str = "u1", enabled: bool = True,
|
||
created_at: str = "2026-08-13 10:00:00") -> dict:
|
||
"""构造 Agent 定时任务的新增参数。"""
|
||
return dict(name=name, content="做点什么", trigger_type="cron",
|
||
cron_expression="0 * * * *", enabled=enabled, user_id=user_id,
|
||
session_id=f"sess-{name}", created_at=created_at,
|
||
updated_at=created_at, last_status="waiting", run_count=0)
|
||
|
||
|
||
def test_agenttask_get_for_user_enforces_ownership(db):
|
||
"""
|
||
带用户 ID 查询时必须匹配归属,否则任意用户都能读到别人的定时任务。
|
||
"""
|
||
task_id = AgentTask.add_task(db.session, **_task("t1", user_id="alice"))
|
||
|
||
assert AgentTask.get_for_user(db.session, task_id).id == task_id
|
||
assert AgentTask.get_for_user(db.session, task_id, user_id="alice").id == task_id
|
||
assert AgentTask.get_for_user(db.session, task_id, user_id="bob") is None
|
||
|
||
|
||
def test_agenttask_list_for_user_filters_by_owner_and_enabled(db):
|
||
"""
|
||
列表按归属与启用状态收窄,并按创建时间倒序。
|
||
|
||
调度器取的是「已启用」这一批,条件失效会把用户停掉的任务重新跑起来。
|
||
"""
|
||
AgentTask.add_task(db.session, **_task("t-on", user_id="alice",
|
||
created_at="2026-08-13 10:00:00"))
|
||
AgentTask.add_task(db.session, **_task("t-off", user_id="alice", enabled=False,
|
||
created_at="2026-08-13 11:00:00"))
|
||
AgentTask.add_task(db.session, **_task("t-other", user_id="bob"))
|
||
|
||
mine = AgentTask.list_for_user(db.session, user_id="alice")
|
||
assert [t.name for t in mine] == ["t-off", "t-on"]
|
||
|
||
assert [t.name for t in AgentTask.list_for_user(db.session, user_id="alice", enabled=True)] \
|
||
== ["t-on"]
|
||
assert [t.name for t in AgentTask.list_for_user(db.session, user_id="alice", enabled=False)] \
|
||
== ["t-off"]
|
||
|
||
|
||
def test_agenttask_update_enforces_ownership(db):
|
||
"""
|
||
更新必须校验归属,并如实返回是否命中。
|
||
|
||
删除、认领执行(mark_running)与收尾计数(finish_task)已随运行记录的引入迁出本模型,
|
||
改由 AgentTaskRun / AgentTaskOper 承担,对应用例见 tests/test_agent_task_runs.py 与
|
||
tests/test_agent_scheduled_tasks.py,此处不再重复覆盖。
|
||
"""
|
||
task_id = AgentTask.add_task(db.session, **_task("t-own", user_id="alice"))
|
||
|
||
assert AgentTask.update_task(db.session, task_id, {"name": "改名"}, user_id="bob") is False
|
||
assert AgentTask.update_task(db.session, task_id, {"name": "改名"}, user_id="alice") is True
|
||
assert AgentTask.get_for_user(db.session, task_id).name == "改名"
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# DownloadFailure
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def _failure(fingerprint: str, next_retry_at: str) -> dict:
|
||
"""构造下载失败冷却记录的写入参数。"""
|
||
return dict(fingerprint=fingerprint, now_time="2026-08-13 10:00:00",
|
||
next_retry_at=next_retry_at, title="片名", type="电影")
|
||
|
||
|
||
def test_download_failure_active_lookup_excludes_expired_cooldowns(db):
|
||
"""
|
||
只返回仍在冷却期内的记录。
|
||
|
||
冷却已过却仍被判为「冷却中」,资源会被永久跳过、订阅永远下不下来。
|
||
"""
|
||
DownloadFailure.record_failure(db.session, **_failure("fp-cold", "2026-08-13 20:00:00"))
|
||
DownloadFailure.record_failure(db.session, **_failure("fp-expired", "2026-08-13 09:00:00"))
|
||
|
||
active = DownloadFailure.get_active_by_fingerprints(
|
||
db.session, ["fp-cold", "fp-expired"], now_time="2026-08-13 12:00:00")
|
||
|
||
assert [f.fingerprint for f in active] == ["fp-cold"]
|
||
|
||
|
||
def test_download_failure_active_lookup_excludes_the_expiry_boundary(db):
|
||
"""
|
||
冷却到点即结束:``next_retry_at`` 恰好等于当前时刻的记录不再算「冷却中」。
|
||
|
||
条件是 ``next_retry_at > now_time``;放宽成 ``>=`` 会让资源在到点那一秒仍被跳过,
|
||
而两侧数据都离边界一小时时,这一字之差查不出来。
|
||
"""
|
||
now_time = "2026-08-13 12:00:00"
|
||
DownloadFailure.record_failure(db.session, **_failure("fp-at-boundary", now_time))
|
||
DownloadFailure.record_failure(
|
||
db.session, **_failure("fp-past-boundary", "2026-08-13 12:00:01"))
|
||
|
||
active = DownloadFailure.get_active_by_fingerprints(
|
||
db.session, ["fp-at-boundary", "fp-past-boundary"], now_time=now_time)
|
||
|
||
assert [f.fingerprint for f in active] == ["fp-past-boundary"]
|
||
|
||
|
||
def test_download_failure_active_lookup_dedupes_and_ignores_blanks(db):
|
||
"""
|
||
指纹列表去重并剔除空值,空列表直接短路返回。
|
||
|
||
条件为空的 IN 查询在部分方言下会退化成全表匹配,把所有资源判成冷却中。
|
||
"""
|
||
DownloadFailure.record_failure(db.session, **_failure("fp-a", "2026-08-13 20:00:00"))
|
||
|
||
assert DownloadFailure.get_active_by_fingerprints(db.session, [], "2026-08-13 12:00:00") == []
|
||
assert DownloadFailure.get_active_by_fingerprints(
|
||
db.session, ["", None], "2026-08-13 12:00:00") == []
|
||
|
||
found = DownloadFailure.get_active_by_fingerprints(
|
||
db.session, ["fp-a", "fp-a", ""], "2026-08-13 12:00:00")
|
||
assert [f.fingerprint for f in found] == ["fp-a"]
|
||
|
||
|
||
def test_download_failure_record_increments_retry_count(db):
|
||
"""
|
||
同一指纹再次失败时累加重试次数,而不是新增一行。
|
||
|
||
每次新增会让冷却窗口永远停留在第一档,退避策略形同虚设。
|
||
"""
|
||
first = DownloadFailure.record_failure(db.session, **_failure("fp-retry", "2026-08-13 20:00:00"))
|
||
assert first.retry_count == 1
|
||
|
||
second = DownloadFailure.record_failure(
|
||
db.session, **_failure("fp-retry", "2026-08-14 20:00:00"))
|
||
|
||
assert second.id == first.id
|
||
assert second.retry_count == 2
|
||
assert second.next_retry_at == "2026-08-14 20:00:00"
|
||
|
||
|
||
def test_download_failure_delete_expired_is_batched(db):
|
||
"""
|
||
过期记录清理分批执行,且不碰仍在冷却期内的记录。
|
||
"""
|
||
for index in range(3):
|
||
DownloadFailure.record_failure(
|
||
db.session, **_failure(f"fp-old-{index}", "2026-01-0%d 10:00:00" % (index + 1)))
|
||
DownloadFailure.record_failure(db.session, **_failure("fp-live", "2026-12-01 10:00:00"))
|
||
|
||
assert DownloadFailure.delete_expired(db.session, before_time="2026-08-01", limit=2) == 2
|
||
assert DownloadFailure.delete_expired(db.session, before_time="2026-08-01", limit=100) == 1
|
||
assert DownloadFailure.delete_expired(db.session, before_time="2026-08-01", limit=100) == 0
|
||
|
||
assert DownloadFailure.get_active_by_fingerprints(
|
||
db.session, ["fp-live"], "2026-08-13 12:00:00")
|
||
|
||
|
||
def test_download_failure_delete_expired_keeps_the_row_exactly_at_the_boundary(db):
|
||
"""
|
||
``next_retry_at`` 恰好等于清理水位的记录不算过期,必须留下(``next_retry_at < before_time``)。
|
||
|
||
比较符若写成 ``<=``,正好排到水位那一秒的冷却记录会被提前抹掉,该资源随即被重新
|
||
下载一遍——退避直接失效。上面那条分批用例的数据离水位有半年之遥,压不到边界。
|
||
"""
|
||
boundary = "2026-05-01 00:00:00"
|
||
DownloadFailure.record_failure(db.session, **_failure("fp-at-boundary", boundary))
|
||
DownloadFailure.record_failure(
|
||
db.session, **_failure("fp-before-boundary", "2026-04-30 23:59:59"))
|
||
|
||
assert DownloadFailure.delete_expired(db.session, before_time=boundary, limit=100) == 1
|
||
|
||
remaining = DownloadFailure.get_active_by_fingerprints(
|
||
db.session, ["fp-at-boundary", "fp-before-boundary"], now_time="2026-01-01 00:00:00")
|
||
assert [f.fingerprint for f in remaining] == ["fp-at-boundary"]
|