Files
MoviePilot/tests/test_db_site_queries.py
T

331 lines
13 KiB
Python

"""
站点相关四张表的查询行为:站点、图标、访问统计、用户数据快照。
站点数据快照的 get_latest 是这里唯一带子查询与 JOIN 的查询——「每个站点取最新一天」
用普通过滤写不出来,改写时最容易退化成「全表按时间倒序取第一条」,那样多站点场景下
只会剩一个站点的数据,而页面不会报错,只是少了几行。
"""
import asyncio
import pytest
from app.db import base as db_base
from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
from app.db.models.sitestatistic import SiteStatistic
from app.db.models.siteuserdata import SiteUserData
from app.db.oper.site import SiteOper
from app.db.session import async_session_scope
@pytest.fixture(autouse=True)
def _track(db):
"""把站点相关表纳入用例级回收。"""
db.watermark(Site, SiteIcon, SiteStatistic, SiteUserData)
def _site(name: str, domain: str, pri: int = 1, is_active: bool = True) -> Site:
"""构造一条站点记录。"""
return Site(name=name, domain=domain, url=f"https://{domain}/", pri=pri, is_active=is_active)
# --------------------------------------------------------------------------- #
# Site
# --------------------------------------------------------------------------- #
def test_site_get_by_domain_matches_async_twin(db):
"""
按域名取站点的同步、异步结果必须指向同一行。
"""
db.add(_site("站点A", "a.test"), _site("站点B", "b.test"))
assert Site.get_by_domain(db.session, "a.test").name == "站点A"
assert db.run_async_session(
lambda session: Site.async_get_by_domain(session, "a.test")
).name == "站点A"
assert db.run_async_session(
lambda session: Site.async_get_by_name(session, "站点B")
).domain == "b.test"
def test_site_get_by_domain_returns_none_when_absent(db):
"""
域名不存在时返回 None,调用方据此判断站点是否已配置。
"""
assert Site.get_by_domain(db.session, "missing.test") is None
def test_site_get_actives_excludes_disabled_sites(db):
"""
取启用站点必须排除已停用的。
停用站点仍被返回意味着它照样会被搜索和刷流访问,等于停用开关没生效。
"""
db.add(_site("启用1", "on1.test"), _site("启用2", "on2.test"),
_site("停用", "off.test", is_active=False))
assert {s.domain for s in Site.get_actives(db.session)} == {"on1.test", "on2.test"}
assert {s.domain for s in db.run_async_session(Site.async_get_actives)} == {
"on1.test", "on2.test"
}
def test_site_list_order_by_pri_is_ascending(db):
"""
站点列表必须按优先级升序——顺序决定搜索与下载的站点先后。
"""
db.add(_site("三", "p3.test", pri=3), _site("一", "p1.test", pri=1),
_site("二", "p2.test", pri=2))
assert [s.domain for s in Site.list_order_by_pri(db.session)] == \
["p1.test", "p2.test", "p3.test"]
assert [s.domain for s in db.run_async_session(Site.async_list_order_by_pri)] == \
["p1.test", "p2.test", "p3.test"]
def test_site_get_domains_by_ids_returns_plain_strings(db):
"""
按 ID 批量取域名必须返回纯字符串列表,且只含请求的那些 ID。
"""
first = db.add(_site("一", "d1.test"))
second = db.add(_site("二", "d2.test"))
db.add(_site("三", "d3.test"))
domains = Site.get_domains_by_ids(db.session, [first.id, second.id])
assert sorted(domains) == ["d1.test", "d2.test"]
assert all(isinstance(item, str) for item in domains)
def test_site_get_domains_by_ids_with_empty_list(db):
"""
ID 列表为空时返回空列表,不能退化成返回全部域名。
"""
db.add(_site("一", "e1.test"))
assert Site.get_domains_by_ids(db.session, []) == []
def test_site_reset_empties_the_table(db):
"""
重置会清空站点表——CookieCloud 全量同步依赖它先清场再写入。
"""
db.add(_site("一", "r1.test"))
Site.reset(db.session)
assert Site.list_order_by_pri(db.session) == []
# --------------------------------------------------------------------------- #
# SiteIcon / SiteStatistic
# --------------------------------------------------------------------------- #
def test_siteicon_get_by_domain_matches_async_twin(db):
"""
图标按域名查找的同步、异步结果必须一致。
"""
db.add(SiteIcon(name="站点A", domain="icon-a.test", url="https://icon-a.test/f.ico"),
SiteIcon(name="站点B", domain="icon-b.test", url="https://icon-b.test/f.ico"))
assert SiteIcon.get_by_domain(db.session, "icon-a.test").name == "站点A"
assert db.run_async_session(
lambda session: SiteIcon.async_get_by_domain(session, "icon-a.test")
).name == "站点A"
assert SiteIcon.get_by_domain(db.session, "icon-missing.test") is None
def test_sitestatistic_get_by_domain_matches_async_twin(db):
"""
访问统计按域名查找的同步、异步结果必须一致。
"""
db.add(SiteStatistic(domain="stat-a.test", success=3, fail=1, seconds=2, lst_state=0),
SiteStatistic(domain="stat-b.test", success=1, fail=0, seconds=1, lst_state=0))
assert SiteStatistic.get_by_domain(db.session, "stat-a.test").success == 3
assert db.run_async_session(
lambda session: SiteStatistic.async_get_by_domain(session, "stat-a.test")
).success == 3
assert SiteStatistic.get_by_domain(db.session, "stat-missing.test") is None
def test_sitestatistic_reset_empties_the_table(db):
"""
重置统计会清空整表,供「重置站点数据」入口使用。
"""
db.add(SiteStatistic(domain="stat-reset.test", success=1, fail=0, seconds=1, lst_state=0))
SiteStatistic.reset(db.session)
assert SiteStatistic.get_by_domain(db.session, "stat-reset.test") is None
# --------------------------------------------------------------------------- #
# SiteUserData
# --------------------------------------------------------------------------- #
def _userdata(domain: str, day: str, time: str, upload: float = 0,
err_msg: str = None) -> SiteUserData:
"""构造一条站点用户数据快照。"""
return SiteUserData(domain=domain, name=domain, username="u", upload=upload,
updated_day=day, updated_time=time, err_msg=err_msg)
def test_userdata_get_by_domain_narrows_with_date_and_time(db):
"""
按域名查询时,日期与时刻参数应逐级收窄结果范围。
"""
db.add(_userdata("ud.test", "2026-08-11", "10:00:00"),
_userdata("ud.test", "2026-08-12", "10:00:00"),
_userdata("ud.test", "2026-08-12", "20:00:00"),
_userdata("other.test", "2026-08-12", "10:00:00"))
assert len(SiteUserData.get_by_domain(db.session, "ud.test")) == 3
assert len(SiteUserData.get_by_domain(db.session, "ud.test", workdate="2026-08-12")) == 2
assert len(SiteUserData.get_by_domain(db.session, "ud.test",
workdate="2026-08-12", worktime="20:00:00")) == 1
def test_userdata_get_by_domain_matches_async_twin(db):
"""
三种收窄组合下同步与异步必须给出同样多的行。
"""
db.add(_userdata("ud2.test", "2026-08-12", "10:00:00"),
_userdata("ud2.test", "2026-08-12", "20:00:00"))
for kwargs in ({}, {"workdate": "2026-08-12"},
{"workdate": "2026-08-12", "worktime": "20:00:00"}):
sync_rows = SiteUserData.get_by_domain(db.session, "ud2.test", **kwargs)
async_rows = db.run_async_session(
lambda session: SiteUserData.async_get_by_domain(
session, domain="ud2.test", **kwargs
)
)
assert len(sync_rows) == len(async_rows)
def test_site_oper_reuses_explicit_userdata_query_sessions(db, monkeypatch):
"""站点用户数据 Oper 必须复用调用方同步与异步会话。"""
db.add(_userdata("explicit-site.test", "2026-08-12", "10:00:00"))
monkeypatch.setattr(
db_base,
"run_sync_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
)
assert SiteOper(db.session).get_userdata_by_domain("explicit-site.test")
async def check() -> None:
"""验证异步站点用户数据查询复用显式 AsyncSession。"""
async with async_session_scope() as session:
monkeypatch.setattr(
db_base,
"run_async_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
)
assert await SiteOper(session).async_get_userdata_by_domain(
"explicit-site.test"
)
asyncio.run(check())
def test_userdata_get_by_date_returns_all_domains_of_that_day(db):
"""
按日期查询应跨站点返回当天全部快照。
"""
db.add(_userdata("day-a.test", "2026-08-12", "10:00:00"),
_userdata("day-b.test", "2026-08-12", "10:00:00"),
_userdata("day-a.test", "2026-08-11", "10:00:00"))
rows = SiteUserData.get_by_date(db.session, "2026-08-12")
assert {r.domain for r in rows} == {"day-a.test", "day-b.test"}
def test_userdata_get_latest_returns_one_day_per_domain(db):
"""
每个站点只返回其最新一天的快照,且跨站点互不影响。
这条正是子查询存在的理由:退化成「全表取最新」时,只会剩下日期最大的那个站点。
"""
db.add(_userdata("late-a.test", "2026-08-10", "10:00:00", upload=1),
_userdata("late-a.test", "2026-08-12", "10:00:00", upload=2),
_userdata("late-b.test", "2026-08-11", "10:00:00", upload=3))
latest = {r.domain: r for r in SiteUserData.get_latest(db.session)
if r.domain in ("late-a.test", "late-b.test")}
assert set(latest) == {"late-a.test", "late-b.test"}
assert latest["late-a.test"].updated_day == "2026-08-12"
assert latest["late-b.test"].updated_day == "2026-08-11"
def test_userdata_get_latest_ignores_failed_snapshots_when_picking_the_day(db):
"""
带错误信息的快照不参与「最新一天」的判定。
抓取失败当天也会留一条记录,若它决定了最新日期,站点数据会显示成空。
"""
db.add(_userdata("err.test", "2026-08-10", "10:00:00", upload=5),
_userdata("err.test", "2026-08-12", "10:00:00", err_msg="登录失败"))
rows = [r for r in SiteUserData.get_latest(db.session) if r.domain == "err.test"]
assert [r.updated_day for r in rows] == ["2026-08-10"]
def test_userdata_get_latest_matches_async_twin(db):
"""
同步与异步的「最新一天」必须选出同一批行。
"""
db.add(_userdata("par.test", "2026-08-10", "10:00:00"),
_userdata("par.test", "2026-08-12", "10:00:00"))
sync_rows = [(r.domain, r.updated_day) for r in SiteUserData.get_latest(db.session)]
async_rows = [
(r.domain, r.updated_day)
for r in db.run_async_session(SiteUserData.async_get_latest)
]
assert sorted(sync_rows) == sorted(async_rows)
def test_userdata_delete_before_is_batched_and_bounded(db):
"""
清理旧快照必须分批并遵守上限,且不碰保留期内的数据。
一次性删除大表会长时间持锁,SQLite 下直接表现为整个应用卡住。
"""
for index in range(5):
db.add(_userdata("old.test", "2026-01-0%d" % (index + 1), "10:00:00"))
db.add(_userdata("old.test", "2026-08-12", "10:00:00"))
assert SiteUserData.delete_before(db.session, before_day="2026-08-01", limit=2) == 2
assert SiteUserData.delete_before(db.session, before_day="2026-08-01", limit=100) == 3
assert SiteUserData.delete_before(db.session, before_day="2026-08-01", limit=100) == 0
remaining = SiteUserData.get_by_domain(db.session, "old.test")
assert [r.updated_day for r in remaining] == ["2026-08-12"]
def test_userdata_delete_before_keeps_the_row_exactly_at_the_boundary(db):
"""
保留日期当天的快照属于「保留期内」,不能被清理(``updated_day < before_day``)。
上面那条用例的数据离水位有半年之遥,``<`` 写成 ``<=`` 也照样绿;
这里把行压在水位当天,让开闭区间之差可观测——差一天就是少一天的站点数据曲线。
"""
boundary = "2026-05-01"
db.add(_userdata("boundary.test", boundary, "10:00:00"),
_userdata("boundary.test", "2026-04-30", "10:00:00"))
assert SiteUserData.delete_before(db.session, before_day=boundary, limit=100) == 1
remaining = SiteUserData.get_by_domain(db.session, "boundary.test")
assert [r.updated_day for r in remaining] == [boundary]