mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
331 lines
13 KiB
Python
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]
|