feat(p0): add favorite folder sync and sqlite data foundation
Implement P0 of the reaction dashboard: config loading with hot reload, SQLite migrations with backups, single-writer queue, read-only Bilibili fav client, folder import/diff sync, invalid-resource handling, blacklist seed, scheduler, and JSON redacted logging. Includes offline unit and transport-level contract tests; all existing and new tests pass.
This commit is contained in:
commit
e2cdf997af
39 files changed
+8575
No files matched your search
@@ -0,0 +1,163 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""脱敏后的真实接口样本(2026-10-31 只读抓取)。
|
||||
|
||||
样本只保留 P0 需要的字段结构,但包含真实接口的完整嵌套形状:
|
||||
``upper`` / ``cnt_info`` / ``attr`` / ``bv_id`` / ``bvid`` 等。
|
||||
不包含 Cookie、token 或任何凭据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
# GET x/v3/fav/resource/ids?media_id=3700023351(两条有效 + 两条后来静默缺失)
|
||||
IDS_PAYLOAD: dict[str, Any] = {
|
||||
"code": 0,
|
||||
"message": "0",
|
||||
"ttl": 1,
|
||||
"data": [
|
||||
{"id": 117530286889638, "type": 2, "bv_id": "BV1xvxi6LEZD", "bvid": "BV1xvxi6LEZD"},
|
||||
{"id": 117014421051497, "type": 2, "bv_id": "BV17tGN6kETF", "bvid": "BV17tGN6kETF"},
|
||||
{"id": 116911710935631, "type": 2, "bv_id": "BV1JoNy66Exp", "bvid": "BV1JoNy66Exp"},
|
||||
{"id": 116769540805008, "type": 2, "bv_id": "BV183jw6hEpt", "bvid": "BV183jw6hEpt"},
|
||||
],
|
||||
}
|
||||
|
||||
# GET x/v3/fav/resource/infos 的真实有效条目
|
||||
VALID_INFO: dict[str, Any] = {
|
||||
"id": 117530286889638,
|
||||
"type": 2,
|
||||
"title": "【咻咻满充电专场①】百变音色机能怪,还有什么风格是你驾驭不了的?!",
|
||||
"cover": "http://i1.hdslb.com/bfs/archive/e5d3d4f25777b08c1dfae8bc9dc3d56b21d548f1.jpg",
|
||||
"intro": "视频唱见咻咻满充电专场,感谢宝宝的充电@推堡儿",
|
||||
"page": 2,
|
||||
"duration": 9504,
|
||||
"upper": {
|
||||
"mid": 434442072,
|
||||
"name": "_-抱抱-_",
|
||||
"face": "https://i0.hdslb.com/bfs/face/3176d3d551875a4dd7dac202963ec76d393ddd18.jpg",
|
||||
"jump_link": "",
|
||||
},
|
||||
"attr": 0,
|
||||
"cnt_info": {
|
||||
"collect": 43,
|
||||
"play": 799,
|
||||
"danmaku": 7,
|
||||
"vt": 0,
|
||||
"play_switch": 0,
|
||||
"reply": 0,
|
||||
"view_text_1": "799",
|
||||
},
|
||||
"link": "bilibili://video/117530286889638",
|
||||
"ctime": 1793370433,
|
||||
"pubtime": 1793370433,
|
||||
"fav_time": 0,
|
||||
"bv_id": "BV1xvxi6LEZD",
|
||||
"bvid": "BV1xvxi6LEZD",
|
||||
"season": None,
|
||||
"ogv": None,
|
||||
"ugc": {"first_cid": 43184885058},
|
||||
"media_list_link": "bilibili://music/playlist/playpage/3728825651?page_type=3&oid=117530286889638&otype=2",
|
||||
}
|
||||
|
||||
# attr=9 表示已失效视频(真实样本)
|
||||
INVALID_INFO: dict[str, Any] = {
|
||||
"id": 116769540805008,
|
||||
"type": 2,
|
||||
"title": "已失效视频",
|
||||
"cover": "http://i0.hdslb.com/bfs/archive/be27fd62c99036dce67efface486fb0a88ffed06.jpg",
|
||||
"intro": "-",
|
||||
"page": 1,
|
||||
"duration": 4504,
|
||||
"upper": {
|
||||
"mid": 3707011906669054,
|
||||
"name": "裤裤喝可乐",
|
||||
"face": "https://i1.hdslb.com/bfs/face/c1d2eb1c9e5c783b8b0aa6e6154892724dfe2812.jpg",
|
||||
"jump_link": "",
|
||||
},
|
||||
"attr": 9,
|
||||
"cnt_info": {
|
||||
"collect": 27,
|
||||
"play": 977,
|
||||
"danmaku": 0,
|
||||
"vt": 0,
|
||||
"play_switch": 0,
|
||||
"reply": 0,
|
||||
"view_text_1": "977",
|
||||
},
|
||||
"link": "bilibili://video/116769540805008",
|
||||
"ctime": 1781762147,
|
||||
"pubtime": 1781762147,
|
||||
"fav_time": 0,
|
||||
"bv_id": "BV183jw6hEpt",
|
||||
"bvid": "BV183jw6hEpt",
|
||||
"season": None,
|
||||
"ogv": None,
|
||||
"ugc": {"first_cid": 39207702089},
|
||||
"media_list_link": "bilibili://music/playlist/playpage/3728825651?page_type=3&oid=116769540805008&otype=2",
|
||||
}
|
||||
|
||||
# GET x/v3/fav/folder/created/list-all 的真实条目
|
||||
FOLDERS_PAYLOAD: dict[str, Any] = {
|
||||
"code": 0,
|
||||
"message": "0",
|
||||
"ttl": 1,
|
||||
"data": {
|
||||
"count": 2,
|
||||
"list": [
|
||||
{
|
||||
"id": 4451053851,
|
||||
"fid": 44510538,
|
||||
"mid": 28802351,
|
||||
"attr": 2,
|
||||
"title": "咻咻满-reaction-2025",
|
||||
"fav_state": 0,
|
||||
"media_count": 167,
|
||||
"is_kid_playlist": False,
|
||||
"kid_playlist_desc": "",
|
||||
},
|
||||
{
|
||||
"id": 3700023351,
|
||||
"fid": 37000233,
|
||||
"mid": 28802351,
|
||||
"attr": 54,
|
||||
"title": "咻咻满 -reaction-2026",
|
||||
"fav_state": 0,
|
||||
"media_count": 664,
|
||||
"is_kid_playlist": False,
|
||||
"kid_playlist_desc": "",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def info_for(
|
||||
aid: int,
|
||||
*,
|
||||
title: str,
|
||||
up_mid: int,
|
||||
up_name: str,
|
||||
pubdate: int = 1793370433,
|
||||
duration: int = 9504,
|
||||
attr: int = 0,
|
||||
bvid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""基于真实样本复制一条 infos 条目,只改测试需要的字段。"""
|
||||
item = copy.deepcopy(VALID_INFO)
|
||||
item["id"] = aid
|
||||
item["title"] = title
|
||||
item["upper"]["mid"] = up_mid
|
||||
item["upper"]["name"] = up_name
|
||||
item["pubtime"] = pubdate
|
||||
item["ctime"] = pubdate
|
||||
item["duration"] = duration
|
||||
item["attr"] = attr
|
||||
item["bvid"] = bvid or f"BV{aid}"
|
||||
item["bv_id"] = item["bvid"]
|
||||
item["link"] = f"bilibili://video/{aid}"
|
||||
item["media_list_link"] = (
|
||||
f"bilibili://music/playlist/playpage/3728825651?page_type=3&oid={aid}&otype=2"
|
||||
)
|
||||
return item
|
||||
@@ -0,0 +1,171 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""auth 相关纯函数自测(不联网): python tests/test_auth.py"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
import favsort as f # noqa: E402
|
||||
|
||||
OLD = (
|
||||
"buvid3=OLD-B3; buvid4=OLD-B4; _uuid=OLD-UUID; buvid_fp=OLD-FP; "
|
||||
"b_nut=1700000000; SESSDATA=old%2C1700000000%2Cabc; bili_jct=oldcsrf; "
|
||||
"DedeUserID=28802351; DedeUserID__ckMd5=oldmd5; sid=oldsid; b_lsid=OLDL; "
|
||||
"bili_ticket=ticket; home_feed_column=5"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_and_merge():
|
||||
pairs = f.parse_cookie(OLD)
|
||||
assert pairs[0] == ("buvid3", "OLD-B3")
|
||||
assert f.cookie_value(OLD, "SESSDATA") == "old%2C1700000000%2Cabc"
|
||||
|
||||
new = {
|
||||
"SESSDATA": "new%2C1800000000%2Cxyz",
|
||||
"bili_jct": "newcsrf",
|
||||
"DedeUserID": "28802351",
|
||||
"DedeUserID__ckMd5": "newmd5",
|
||||
"sid": "newsid",
|
||||
}
|
||||
merged = f.merge_cookie(OLD, new)
|
||||
# 身份字段被替换
|
||||
assert f.cookie_value(merged, "SESSDATA") == "new%2C1800000000%2Cxyz"
|
||||
assert f.cookie_value(merged, "bili_jct") == "newcsrf"
|
||||
assert f.cookie_value(merged, "sid") == "newsid"
|
||||
# 设备指纹原样保留
|
||||
assert f.cookie_value(merged, "buvid3") == "OLD-B3"
|
||||
assert f.cookie_value(merged, "buvid4") == "OLD-B4"
|
||||
assert f.cookie_value(merged, "_uuid") == "OLD-UUID"
|
||||
assert f.cookie_value(merged, "buvid_fp") == "OLD-FP"
|
||||
assert f.cookie_value(merged, "b_nut") == "1700000000"
|
||||
assert f.cookie_value(merged, "home_feed_column") == "5"
|
||||
# 旧会话字段在没有新值时应被丢弃
|
||||
assert f.cookie_value(merged, "b_lsid") == ""
|
||||
|
||||
|
||||
def test_set_cookie_and_url_fallback():
|
||||
headers = [
|
||||
"SESSDATA=abc%2C1800000000%2Cdef; Path=/; Domain=.bilibili.com; HttpOnly",
|
||||
"bili_jct=csrf2; Domain=.bilibili.com",
|
||||
"buvid3=SHOULD-NOT-OVERWRITE; Domain=.bilibili.com",
|
||||
]
|
||||
got = f.cookies_from_set_cookie(headers)
|
||||
assert got["SESSDATA"] == "abc%2C1800000000%2Cdef"
|
||||
assert got["bili_jct"] == "csrf2"
|
||||
|
||||
merged = f.merge_cookie(OLD, got)
|
||||
assert f.cookie_value(merged, "buvid3") == "OLD-B3" # 设备字段不被 Set-Cookie 覆盖
|
||||
|
||||
|
||||
def test_expiry():
|
||||
assert f.sessdata_expiry(OLD) == 1700000000
|
||||
assert f.sessdata_expiry("SESSDATA=bad") is None
|
||||
assert f.sessdata_expiry("") is None
|
||||
|
||||
|
||||
def test_ensure_device_cookies():
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_get_json(cfg, url, auth=False):
|
||||
called["n"] += 1
|
||||
return {"code": 0, "data": {"b_3": "NEW-B3", "b_4": "NEW-B4"}}
|
||||
|
||||
orig = f.get_json
|
||||
f.get_json = fake_get_json
|
||||
try:
|
||||
cfg = {"cookie": "SESSDATA=x; bili_jct=y"}
|
||||
added = f.ensure_device_cookies(cfg)
|
||||
assert "buvid3" in added and "buvid4" in added and "_uuid" in added
|
||||
assert f.cookie_value(cfg["cookie"], "buvid3") == "NEW-B3"
|
||||
assert f.cookie_value(cfg["cookie"], "buvid4") == "NEW-B4"
|
||||
assert f.cookie_value(cfg["cookie"], "_uuid").endswith("infoc")
|
||||
|
||||
# 已有设备字段时不应调用 spi,也不应改动
|
||||
called["n"] = 0
|
||||
cfg2 = {"cookie": OLD}
|
||||
added2 = f.ensure_device_cookies(cfg2)
|
||||
assert added2 == [] and called["n"] == 0
|
||||
assert cfg2["cookie"] == OLD
|
||||
finally:
|
||||
f.get_json = orig
|
||||
|
||||
|
||||
def test_finish_auth_write_and_rollback():
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
new = {"SESSDATA": "new%2C1800000000%2Cxyz", "bili_jct": "newcsrf",
|
||||
"DedeUserID": "28802351", "DedeUserID__ckMd5": "newmd5"}
|
||||
cfg = {"mid": 28802351, "csrf": "oldcsrf", "cookie": OLD,
|
||||
"overrides": {}, "sleep": {}}
|
||||
old_raw = json.dumps(cfg, ensure_ascii=False, indent=2)
|
||||
|
||||
orig = (f.CONFIG_PATH, f.CONFIG_BACKUP, f.fetch_nav, f.ensure_device_cookies)
|
||||
|
||||
def fake_nav_ok(c):
|
||||
return {"code": 0, "data": {"isLogin": True, "uname": "tester", "mid": 28802351}}
|
||||
|
||||
def fake_nav_bad(c):
|
||||
return {"code": -101, "message": "账号未登录", "data": {"isLogin": False}}
|
||||
|
||||
def fake_device(c):
|
||||
return []
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "config.json")
|
||||
f.CONFIG_PATH, f.CONFIG_BACKUP = path, path + ".bak"
|
||||
f.ensure_device_cookies = fake_device
|
||||
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(old_raw)
|
||||
|
||||
# 成功:写入新身份字段,设备字段不变,并生成备份
|
||||
f.fetch_nav = fake_nav_ok
|
||||
assert f.finish_auth(cfg, dict(new)) == 0
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
saved = json.load(fh)
|
||||
assert "newcsrf" == saved["csrf"]
|
||||
assert f.cookie_value(saved["cookie"], "buvid3") == "OLD-B3"
|
||||
assert f.cookie_value(saved["cookie"], "SESSDATA") == "new%2C1800000000%2Cxyz"
|
||||
assert os.path.exists(path + ".bak")
|
||||
|
||||
# 失败:用上一份备份回滚
|
||||
before = open(path, encoding="utf-8").read()
|
||||
cfg2 = dict(saved)
|
||||
f.fetch_nav = fake_nav_bad
|
||||
assert f.finish_auth(cfg2, {"SESSDATA": "x%2C1%2Cy", "bili_jct": "z"}) == 4
|
||||
after = open(path, encoding="utf-8").read()
|
||||
assert after == before
|
||||
finally:
|
||||
f.CONFIG_PATH, f.CONFIG_BACKUP, f.fetch_nav, f.ensure_device_cookies = orig
|
||||
|
||||
|
||||
def test_qr_png():
|
||||
import tempfile
|
||||
|
||||
path = os.path.join(tempfile.gettempdir(), "favsort-test-qr.png")
|
||||
try:
|
||||
f.write_qr_png("https://example.com/login?key=abc&from=test", path)
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read()
|
||||
assert data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
assert len(data) > 500
|
||||
finally:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def main():
|
||||
test_parse_and_merge()
|
||||
test_set_cookie_and_url_fallback()
|
||||
test_expiry()
|
||||
test_ensure_device_cookies()
|
||||
test_finish_auth_write_and_rollback()
|
||||
test_qr_png()
|
||||
print("OK: auth 纯函数测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,295 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B 站只读客户端测试(transport 边界 Mock,不联网):
|
||||
uv run python tests/test_bilibili_client.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.bilibili.client import ( # noqa: E402
|
||||
BilibiliAPIError,
|
||||
BilibiliClient,
|
||||
BilibiliHTTPError,
|
||||
BilibiliProtocolError,
|
||||
HTTPResponse,
|
||||
Pacer,
|
||||
TransportError,
|
||||
)
|
||||
from fixtures import ( # noqa: E402
|
||||
FOLDERS_PAYLOAD,
|
||||
IDS_PAYLOAD,
|
||||
INVALID_INFO,
|
||||
VALID_INFO,
|
||||
info_for,
|
||||
)
|
||||
|
||||
|
||||
def http_json(payload, status=200):
|
||||
return HTTPResponse(
|
||||
status=status,
|
||||
headers={"content-type": "application/json"},
|
||||
body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueTransport:
|
||||
responses: list
|
||||
calls: list = field(default_factory=list)
|
||||
|
||||
def request(self, method, url, headers, data=None, timeout=30):
|
||||
self.calls.append({
|
||||
"method": method, "url": url, "headers": dict(headers),
|
||||
"data": data, "timeout": timeout,
|
||||
})
|
||||
item = self.responses.pop(0)
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteTransport:
|
||||
handler: object
|
||||
calls: list = field(default_factory=list)
|
||||
|
||||
def request(self, method, url, headers, data=None, timeout=30):
|
||||
self.calls.append({
|
||||
"method": method, "url": url, "headers": dict(headers),
|
||||
"data": data, "timeout": timeout,
|
||||
})
|
||||
return self.handler(url, headers)
|
||||
|
||||
|
||||
def make_client(transport, *, cookie="SESSDATA=secret; buvid3=device", retries=3):
|
||||
sleeps = []
|
||||
client = BilibiliClient(
|
||||
cookie=cookie,
|
||||
csrf="csrf",
|
||||
mid=28802351,
|
||||
transport=transport,
|
||||
pacer=Pacer(0, 0, sleep=sleeps.append),
|
||||
sleeper=sleeps.append,
|
||||
retries=retries,
|
||||
backoff=1.0,
|
||||
)
|
||||
return client, sleeps
|
||||
|
||||
|
||||
def test_fav_ids_parse_and_anonymous_header():
|
||||
transport = QueueTransport([http_json(IDS_PAYLOAD)])
|
||||
client, _ = make_client(transport)
|
||||
ids = client.fav_ids(3700023351)
|
||||
assert len(ids) == 4
|
||||
assert ids[0].aid == 117530286889638
|
||||
assert ids[0].type == 2
|
||||
assert ids[0].bvid == "BV1xvxi6LEZD"
|
||||
call = transport.calls[0]
|
||||
assert "media_id=3700023351" in call["url"]
|
||||
assert "SESSDATA" not in call["headers"]["cookie"]
|
||||
assert "buvid3=device" in call["headers"]["cookie"]
|
||||
|
||||
|
||||
def test_anonymous_failure_falls_back_to_full_cookie():
|
||||
transport = QueueTransport([
|
||||
http_json({"code": -101, "message": "账号未登录", "data": None}),
|
||||
http_json(IDS_PAYLOAD),
|
||||
])
|
||||
client, _ = make_client(transport)
|
||||
ids = client.fav_ids(3700023351)
|
||||
assert len(ids) == 4
|
||||
assert len(transport.calls) == 2
|
||||
assert "SESSDATA" not in transport.calls[0]["headers"]["cookie"]
|
||||
assert "SESSDATA=secret" in transport.calls[1]["headers"]["cookie"]
|
||||
|
||||
|
||||
def test_business_error_is_not_retried():
|
||||
error = {"code": -404, "message": "啥都木有", "data": None}
|
||||
transport = QueueTransport([http_json(error), http_json(error)])
|
||||
client, sleeps = make_client(transport)
|
||||
try:
|
||||
client.fav_ids(3700023351)
|
||||
except BilibiliAPIError as exc:
|
||||
assert exc.code == -404
|
||||
assert "啥都木有" in str(exc)
|
||||
else:
|
||||
raise AssertionError("业务错误应抛出")
|
||||
assert len(transport.calls) == 2 # 匿名 + 完整 Cookie 各一次,不重试
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_http_500_retries_with_backoff():
|
||||
transport = QueueTransport([
|
||||
http_json({"code": 0}, status=500),
|
||||
http_json({"code": 0}, status=500),
|
||||
http_json({"code": 0}, status=500),
|
||||
])
|
||||
client, sleeps = make_client(transport, retries=3)
|
||||
try:
|
||||
client.get_json("https://api.bilibili.com/x/test", auth=True)
|
||||
except BilibiliHTTPError as exc:
|
||||
assert exc.status == 500
|
||||
else:
|
||||
raise AssertionError("HTTP 500 应抛出")
|
||||
assert len(transport.calls) == 3
|
||||
assert sleeps == [1.0, 2.0]
|
||||
|
||||
|
||||
def test_network_error_retry_then_success():
|
||||
transport = QueueTransport([TransportError("reset"), http_json(IDS_PAYLOAD)])
|
||||
client, sleeps = make_client(transport)
|
||||
ids = client.fav_ids(3700023351)
|
||||
assert len(ids) == 4
|
||||
assert len(transport.calls) == 2
|
||||
assert sleeps == [1.0]
|
||||
|
||||
|
||||
def test_invalid_json_is_protocol_error():
|
||||
bad = HTTPResponse(200, {}, b"not json")
|
||||
transport = QueueTransport([bad, bad])
|
||||
client, _ = make_client(transport)
|
||||
try:
|
||||
client.fav_ids(3700023351)
|
||||
except BilibiliProtocolError as exc:
|
||||
assert "JSON" in str(exc)
|
||||
else:
|
||||
raise AssertionError("非法 JSON 应抛出协议错误")
|
||||
|
||||
|
||||
def test_infos_chunking_and_silent_missing():
|
||||
aids = list(range(1000, 1450))
|
||||
missing = 1003
|
||||
|
||||
def handler(url, headers):
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
||||
resources = query["resources"][0]
|
||||
requested = [int(part.split(":")[0]) for part in resources.split(",")]
|
||||
data = [
|
||||
info_for(aid, title=f"视频{aid}", up_mid=100, up_name="UP")
|
||||
for aid in requested if aid != missing
|
||||
]
|
||||
return http_json({"code": 0, "message": "0", "ttl": 1, "data": data})
|
||||
|
||||
transport = RouteTransport(handler)
|
||||
client, _ = make_client(transport)
|
||||
result = client.fav_infos(3700023351, aids, folder_mid=28802351, chunk_size=200)
|
||||
assert len(transport.calls) == 3
|
||||
sizes = []
|
||||
for call in transport.calls:
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(call["url"]).query)
|
||||
sizes.append(len(query["resources"][0].split(",")))
|
||||
assert sizes == [200, 200, 50]
|
||||
assert result.requested == tuple(aids)
|
||||
assert result.missing == (missing,)
|
||||
assert len(result.items) == 449
|
||||
assert result.items[1000].title == "视频1000"
|
||||
assert result.items[1000].invalid is False
|
||||
|
||||
|
||||
def test_infos_invalid_attr_and_empty():
|
||||
transport = QueueTransport([
|
||||
http_json({"code": 0, "message": "0", "ttl": 1, "data": [INVALID_INFO]})
|
||||
])
|
||||
client, _ = make_client(transport)
|
||||
result = client.fav_infos(3700023351, [INVALID_INFO["id"]])
|
||||
assert result.items[INVALID_INFO["id"]].invalid is True
|
||||
assert result.missing == ()
|
||||
|
||||
# 空输入不发起请求
|
||||
empty = client.fav_infos(3700023351, [])
|
||||
assert empty.requested == () and empty.items == {}
|
||||
assert len(transport.calls) == 1
|
||||
|
||||
|
||||
def test_infos_unexpected_aid_is_protocol_error():
|
||||
transport = QueueTransport([http_json({
|
||||
"code": 0, "message": "0", "ttl": 1, "data": [VALID_INFO],
|
||||
})])
|
||||
client, _ = make_client(transport)
|
||||
try:
|
||||
client.fav_infos(3700023351, [1])
|
||||
except BilibiliProtocolError as exc:
|
||||
assert "未请求" in str(exc)
|
||||
else:
|
||||
raise AssertionError("返回未请求 aid 应报错")
|
||||
|
||||
|
||||
def test_fav_folders_uses_full_cookie():
|
||||
transport = QueueTransport([http_json(FOLDERS_PAYLOAD)])
|
||||
client, _ = make_client(transport)
|
||||
folders = client.fav_folders()
|
||||
assert [(f.media_id, f.media_count) for f in folders] == [
|
||||
(4451053851, 167), (3700023351, 664)
|
||||
]
|
||||
call = transport.calls[0]
|
||||
assert "up_mid=28802351" in call["url"]
|
||||
assert "SESSDATA=secret" in call["headers"]["cookie"]
|
||||
|
||||
|
||||
def test_fav_infos_chunk_size_validation():
|
||||
client, _ = make_client(QueueTransport([]))
|
||||
for size in (0, 401):
|
||||
try:
|
||||
client.fav_infos(1, [1], chunk_size=size)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"chunk_size={size} 应失败")
|
||||
|
||||
|
||||
def test_pacer_delays_after_first_request():
|
||||
sleeps = []
|
||||
|
||||
class Rng:
|
||||
def uniform(self, a, b):
|
||||
assert (a, b) == (1.5, 2.5)
|
||||
return 2.0
|
||||
|
||||
pacer = Pacer(1.5, 2.5, sleep=sleeps.append, rng=Rng())
|
||||
assert pacer.wait() == 0.0
|
||||
assert pacer.wait() == 2.0
|
||||
assert pacer.wait() == 2.0
|
||||
assert sleeps == [2.0, 2.0]
|
||||
try:
|
||||
Pacer(2.0, 1.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("非法间隔应失败")
|
||||
|
||||
|
||||
def test_timeout_and_method_forwarded():
|
||||
transport = QueueTransport([http_json(IDS_PAYLOAD)])
|
||||
client, _ = make_client(transport)
|
||||
client.fav_ids(1)
|
||||
call = transport.calls[0]
|
||||
assert call["method"] == "GET"
|
||||
assert call["data"] is None
|
||||
assert call["timeout"] == 30
|
||||
|
||||
|
||||
def main():
|
||||
test_fav_ids_parse_and_anonymous_header()
|
||||
test_anonymous_failure_falls_back_to_full_cookie()
|
||||
test_business_error_is_not_retried()
|
||||
test_http_500_retries_with_backoff()
|
||||
test_network_error_retry_then_success()
|
||||
test_invalid_json_is_protocol_error()
|
||||
test_infos_chunking_and_silent_missing()
|
||||
test_infos_invalid_attr_and_empty()
|
||||
test_infos_unexpected_aid_is_protocol_error()
|
||||
test_fav_folders_uses_full_cookie()
|
||||
test_fav_infos_chunk_size_validation()
|
||||
test_pacer_delays_after_first_request()
|
||||
test_timeout_and_method_forwarded()
|
||||
print("OK: B 站只读客户端契约与重试测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""CLI 入口测试(离线): uv run python tests/test_cli.py"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.cli import main # noqa: E402
|
||||
from reaction_board.database import Database # noqa: E402
|
||||
from reaction_board.repository import Repository # noqa: E402
|
||||
|
||||
|
||||
def write_config(tmp, *, cookie="SESSDATA=x; buvid3=y", csrf="csrf"):
|
||||
path = Path(tmp) / "config.json"
|
||||
path.write_text(json.dumps({
|
||||
"mid": 1,
|
||||
"csrf": csrf,
|
||||
"cookie": cookie,
|
||||
"storage": {
|
||||
"database_path": str(Path(tmp) / "app.db"),
|
||||
"backup_dir": str(Path(tmp) / "backup"),
|
||||
},
|
||||
"folders": [
|
||||
{"year": 2025, "media_id": 11, "title": "2025"},
|
||||
{"year": 2026, "media_id": 22, "title": "2026"},
|
||||
],
|
||||
"up_blacklist_seed": [101, 202],
|
||||
}, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def run_cli(argv):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
code = main(argv)
|
||||
return code, out.getvalue()
|
||||
|
||||
|
||||
def test_init_and_status():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp)
|
||||
code, output = run_cli(["init", "--config", str(path)])
|
||||
assert code == 0
|
||||
assert '"schema_version": 1' in output
|
||||
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
assert len(repo.list_folders()) == 2
|
||||
assert [r["mid"] for r in repo.blacklist()] == [101, 202]
|
||||
assert repo.counts()["video_total"] == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
code, output = run_cli(["status", "--config", str(path)])
|
||||
assert code == 0
|
||||
assert '"configured_folders"' in output
|
||||
assert '"media_count": null' in output
|
||||
assert '"video_total": 0' in output
|
||||
|
||||
|
||||
def test_missing_config():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, output = run_cli(["init", "--config", str(Path(tmp) / "nope.json")])
|
||||
assert code == 2
|
||||
payload = json.loads(output.strip().splitlines()[-1])
|
||||
assert payload["level"] == "error"
|
||||
assert "缺少配置文件" in payload["error"]
|
||||
|
||||
|
||||
def test_sync_requires_auth_without_network():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp, cookie="", csrf="")
|
||||
code, output = run_cli(["sync", "--config", str(path)])
|
||||
assert code == 2
|
||||
payload = json.loads(output.strip().splitlines()[-1])
|
||||
assert "cookie" in payload["error"]
|
||||
assert not (Path(tmp) / "app.db").exists()
|
||||
|
||||
|
||||
def test_run_once_without_folders():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data["folders"] = []
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
code, output = run_cli(["run", "--once", "--config", str(path)])
|
||||
assert code == 0
|
||||
assert '"name": "folder_sync"' in output
|
||||
assert '"ok": true' in output
|
||||
|
||||
|
||||
def main_test():
|
||||
test_init_and_status()
|
||||
test_missing_config()
|
||||
test_sync_requires_auth_without_network()
|
||||
test_run_once_without_folders()
|
||||
print("OK: CLI init/sync/run/status/错误路径测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_test()
|
||||
@@ -0,0 +1,210 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""配置加载 / 校验 / 热加载测试(不联网): uv run python tests/test_config.py"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.config import ConfigError, ConfigStore, load_config # noqa: E402
|
||||
|
||||
COOKIE = "SESSDATA=secret%2C1800000000%2Cabc; bili_jct=csrf-secret; buvid3=dev"
|
||||
|
||||
|
||||
def write(path: Path, data) -> None:
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def bump_mtime(path: Path) -> None:
|
||||
"""保证 mtime 严格大于上一次,避免 Windows 时间精度导致热加载测试偶发失败。"""
|
||||
target = max(path.stat().st_mtime_ns, time.time_ns()) + 1_000_000
|
||||
os.utime(path, ns=(target, target))
|
||||
|
||||
|
||||
def base_legacy() -> dict:
|
||||
return {
|
||||
"mid": 28802351,
|
||||
"csrf": "csrf-secret",
|
||||
"cookie": COOKIE,
|
||||
"overrides": {"1": {"mid": 2, "name": "x"}},
|
||||
"sleep": {"min": 1},
|
||||
"folders": [
|
||||
{"year": 2025, "media_id": 4451053851, "title": "2025"},
|
||||
{"year": 2026, "media_id": 3700023351, "title": "2026"},
|
||||
],
|
||||
"track_fans": [37754047],
|
||||
"up_blacklist_seed": [1, 2, 2],
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_flat_mapping():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "config.json"
|
||||
write(path, base_legacy())
|
||||
cfg = load_config(path, env={})
|
||||
assert cfg.bilibili.mid == 28802351
|
||||
assert cfg.bilibili.csrf == "csrf-secret"
|
||||
assert cfg.bilibili.cookie == COOKIE
|
||||
assert cfg.storage.database_path == Path(tmp) / "data" / "app.db"
|
||||
assert cfg.storage.backup_dir == Path(tmp) / "data" / "backup"
|
||||
assert cfg.schedule.snapshot_at == "00:01"
|
||||
assert [f.media_id for f in cfg.folders] == [4451053851, 3700023351]
|
||||
assert cfg.track_fans == (37754047,)
|
||||
assert cfg.up_blacklist_seed == (1, 2) # 去重
|
||||
assert cfg.raw["overrides"] # 旧字段仍保留给 favsort
|
||||
assert set(cfg.heat_weights) == {
|
||||
"view", "like", "coin", "favorite", "share", "reply", "danmaku"
|
||||
}
|
||||
|
||||
|
||||
def test_nested_and_env_override():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "config.json"
|
||||
write(path, {
|
||||
"bilibili": {"mid": 1, "csrf": "old", "cookie": "old-cookie"},
|
||||
"storage": {"database_path": "var/app.db", "backup_dir": "var/bak"},
|
||||
"folders": [],
|
||||
})
|
||||
env = {
|
||||
"BILI_MID": "999",
|
||||
"BILI_CSRF": "env-csrf",
|
||||
"BILI_COOKIE": "env-cookie",
|
||||
"TG_BOT_TOKEN": "123456:abcdefghijklmnopqrstuvwxyzABCDE",
|
||||
"TG_CHAT_ID": "42",
|
||||
"DASHBOARD_AUTH_TOKEN": "web-token",
|
||||
"DATABASE_PATH": "custom.db",
|
||||
}
|
||||
cfg = load_config(path, env=env)
|
||||
assert cfg.bilibili.mid == 999
|
||||
assert cfg.bilibili.csrf == "env-csrf"
|
||||
assert cfg.bilibili.cookie == "env-cookie"
|
||||
assert cfg.telegram.chat_id == 42
|
||||
assert cfg.telegram.bot_token.startswith("123456:")
|
||||
assert cfg.web.auth_token == "web-token"
|
||||
assert cfg.storage.database_path == Path(tmp) / "custom.db"
|
||||
# 备份目录仍按配置文件目录解析
|
||||
assert cfg.storage.backup_dir == Path(tmp) / "var" / "bak"
|
||||
|
||||
|
||||
def test_absolute_paths_kept():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = str(Path(tmp) / "abs" / "app.db")
|
||||
path = Path(tmp) / "config.json"
|
||||
write(path, {
|
||||
"bilibili": {"mid": 1, "csrf": "c", "cookie": "k"},
|
||||
"storage": {"database_path": db, "backup_dir": str(Path(tmp) / "b")},
|
||||
"folders": [],
|
||||
})
|
||||
cfg = load_config(path, env={})
|
||||
assert cfg.storage.database_path == Path(db)
|
||||
|
||||
|
||||
def test_invalid_configs():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "config.json"
|
||||
cases = [
|
||||
({"folders": [{"year": 2025, "media_id": 1},
|
||||
{"year": 2026, "media_id": 1}]}, "media_id 重复"),
|
||||
({"folders": [{"year": 2025, "media_id": 1},
|
||||
{"year": 2025, "media_id": 2}]}, "year 重复"),
|
||||
({"folders": [{"year": 2025, "media_id": 0}]}, "不能小于 1"),
|
||||
({"schedule": {"snapshot_at": "24:00"}}, "snapshot_at"),
|
||||
({"schedule": {"snapshot_at": "1:01"}}, "snapshot_at"),
|
||||
({"notify": {"max_items_per_message": 0}}, "max_items_per_message"),
|
||||
({"heat_weights": {"view": -1}}, "heat_weights.view"),
|
||||
({"classify": {"require_title": ""}}, "require_title"),
|
||||
]
|
||||
base = {
|
||||
"bilibili": {"mid": 1, "csrf": "c", "cookie": "k"},
|
||||
"folders": [],
|
||||
}
|
||||
for patch, needle in cases:
|
||||
data = json.loads(json.dumps(base))
|
||||
data.update(patch)
|
||||
write(path, data)
|
||||
try:
|
||||
load_config(path, env={})
|
||||
except ConfigError as exc:
|
||||
assert needle in str(exc), (needle, str(exc))
|
||||
else:
|
||||
raise AssertionError(f"应当失败: {patch}")
|
||||
|
||||
|
||||
def test_require_auth():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "config.json"
|
||||
write(path, {"bilibili": {"mid": 1, "csrf": "", "cookie": ""}, "folders": []})
|
||||
load_config(path, env={}, require_auth=False)
|
||||
try:
|
||||
load_config(path, env={}, require_auth=True)
|
||||
except ConfigError as exc:
|
||||
assert "cookie" in str(exc)
|
||||
else:
|
||||
raise AssertionError("require_auth 应拒绝空凭据")
|
||||
|
||||
|
||||
def test_env_int_invalid():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "config.json"
|
||||
write(path, base_legacy())
|
||||
try:
|
||||
load_config(path, env={"BILI_MID": "abc"})
|
||||
except ConfigError as exc:
|
||||
assert "BILI_MID" in str(exc)
|
||||
else:
|
||||
raise AssertionError("非法整数环境变量应失败")
|
||||
|
||||
|
||||
def test_hot_reload_keeps_last_good_config():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "config.json"
|
||||
write(path, base_legacy())
|
||||
store = ConfigStore(path, env={})
|
||||
assert store.get().folders[0].title == "2025"
|
||||
|
||||
data = base_legacy()
|
||||
data["folders"][0]["title"] = "2025-新"
|
||||
write(path, data)
|
||||
bump_mtime(path)
|
||||
assert store.get().folders[0].title == "2025-新"
|
||||
assert store.last_error is None
|
||||
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
bump_mtime(path)
|
||||
assert store.get().folders[0].title == "2025-新"
|
||||
assert store.last_error is not None
|
||||
|
||||
write(path, data)
|
||||
bump_mtime(path)
|
||||
assert store.get().folders[0].title == "2025-新"
|
||||
assert store.last_error is None
|
||||
|
||||
|
||||
def test_missing_file():
|
||||
try:
|
||||
load_config(Path(tempfile.gettempdir()) / "no-such-bili-fav-config.json", env={})
|
||||
except ConfigError as exc:
|
||||
assert "缺少配置文件" in str(exc)
|
||||
else:
|
||||
raise AssertionError("缺失配置应失败")
|
||||
|
||||
|
||||
def main():
|
||||
test_legacy_flat_mapping()
|
||||
test_nested_and_env_override()
|
||||
test_absolute_paths_kept()
|
||||
test_invalid_configs()
|
||||
test_require_auth()
|
||||
test_env_int_invalid()
|
||||
test_hot_reload_keeps_last_good_config()
|
||||
test_missing_file()
|
||||
print("OK: 配置加载、校验与热加载测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库单写队列测试(不联网): uv run python tests/test_database.py"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.database import Database, DatabaseClosed # noqa: E402
|
||||
|
||||
|
||||
def make_db(tmp):
|
||||
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
||||
db.initialize()
|
||||
return db
|
||||
|
||||
|
||||
def test_write_read_transaction():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = make_db(tmp)
|
||||
try:
|
||||
assert db.schema_version == 1
|
||||
db.write(lambda c: c.execute("INSERT INTO kv(k, v) VALUES ('a', '1')"))
|
||||
assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1"
|
||||
|
||||
def boom(conn):
|
||||
conn.execute("UPDATE kv SET v='2' WHERE k='a'")
|
||||
raise RuntimeError("boom")
|
||||
try:
|
||||
db.transaction(boom)
|
||||
except RuntimeError as exc:
|
||||
assert str(exc) == "boom"
|
||||
else:
|
||||
raise AssertionError("事务应抛错")
|
||||
assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_concurrent_writes_are_serialized():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = make_db(tmp)
|
||||
try:
|
||||
db.write(lambda c: c.execute(
|
||||
"CREATE TABLE counter (id INTEGER PRIMARY KEY, n INTEGER NOT NULL)"
|
||||
))
|
||||
db.write(lambda c: c.execute("INSERT INTO counter(id, n) VALUES (1, 0)"))
|
||||
errors = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
for _ in range(10):
|
||||
db.transaction(
|
||||
lambda conn: conn.execute(
|
||||
"UPDATE counter SET n = n + 1 WHERE id = 1"
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert not errors, errors
|
||||
assert db.read(lambda c: c.execute("SELECT n FROM counter").fetchone()[0]) == 80
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_close_and_reinitialize():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = make_db(tmp)
|
||||
db.close()
|
||||
db.close() # 幂等
|
||||
try:
|
||||
db.write(lambda c: None)
|
||||
except DatabaseClosed:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("关闭后写入应失败")
|
||||
try:
|
||||
db.read(lambda c: None)
|
||||
except DatabaseClosed:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("关闭后读取应失败")
|
||||
|
||||
db2 = make_db(tmp)
|
||||
try:
|
||||
assert db2.schema_version == 1
|
||||
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 1
|
||||
finally:
|
||||
db2.close()
|
||||
|
||||
|
||||
def test_wal_enabled():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = make_db(tmp)
|
||||
try:
|
||||
mode = db.read(lambda c: c.execute("PRAGMA journal_mode").fetchone()[0])
|
||||
assert str(mode).lower() == "wal"
|
||||
assert db.read(lambda c: c.execute("PRAGMA foreign_keys").fetchone()[0]) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_migration_backup_on_existing_db():
|
||||
"""已有数据升级时由 Database 自动备份。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "app.db"
|
||||
# 先建一个旧库(只有 schema_version v0),Database.initialize 会升级并备份
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE legacy (x INTEGER)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
db = Database(path, backup_dir=Path(tmp) / "backup")
|
||||
try:
|
||||
applied = db.initialize()
|
||||
assert applied == [1]
|
||||
assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_write_read_transaction()
|
||||
test_concurrent_writes_are_serialized()
|
||||
test_close_and_reinitialize()
|
||||
test_wal_enabled()
|
||||
test_migration_backup_on_existing_db()
|
||||
print("OK: 数据库单写队列与迁移入口测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,297 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""收藏夹同步集成测试(用真实接口形状的 transport Mock,不联网):
|
||||
uv run python tests/test_folder_sync.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from fixtures import info_for # noqa: E402
|
||||
from reaction_board.bilibili.client import ( # noqa: E402
|
||||
BilibiliClient,
|
||||
HTTPResponse,
|
||||
Pacer,
|
||||
TransportError,
|
||||
)
|
||||
from reaction_board.config import ConfigStore # noqa: E402
|
||||
from reaction_board.database import Database # noqa: E402
|
||||
from reaction_board.repository import Repository # noqa: E402
|
||||
from reaction_board.services.folder_sync import FolderSyncService # noqa: E402
|
||||
|
||||
F1 = 4451053851
|
||||
F2 = 3700023351
|
||||
|
||||
|
||||
def http_json(payload):
|
||||
return HTTPResponse(
|
||||
200, {"content-type": "application/json"},
|
||||
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
|
||||
class FakeFavTransport:
|
||||
def __init__(self, folders, infos, *, fail_media=None):
|
||||
self.folders = {k: list(v) for k, v in folders.items()}
|
||||
self.infos = dict(infos)
|
||||
self.fail_media = fail_media
|
||||
self.ids_calls = []
|
||||
self.info_calls = []
|
||||
|
||||
def request(self, method, url, headers, data=None, timeout=30):
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
||||
if "/x/v3/fav/resource/ids" in url:
|
||||
media_id = int(query["media_id"][0])
|
||||
self.ids_calls.append(media_id)
|
||||
if self.fail_media == media_id:
|
||||
raise TransportError("ids 网络错误")
|
||||
data_rows = [
|
||||
{"id": aid, "type": 2, "bv_id": f"BV{aid}", "bvid": f"BV{aid}"}
|
||||
for aid in self.folders.get(media_id, [])
|
||||
]
|
||||
return http_json({"code": 0, "message": "0", "ttl": 1, "data": data_rows})
|
||||
if "/x/v3/fav/resource/infos" in url:
|
||||
media_id = int(query["folder_id"][0])
|
||||
resources = query["resources"][0]
|
||||
aids = [int(part.split(":")[0]) for part in resources.split(",")]
|
||||
self.info_calls.append((media_id, aids))
|
||||
rows = [self.infos[aid] for aid in aids if aid in self.infos]
|
||||
return http_json({"code": 0, "message": "0", "ttl": 1, "data": rows})
|
||||
if "/x/v3/fav/folder/created/list-all" in url:
|
||||
return http_json({
|
||||
"code": 0, "message": "0", "ttl": 1,
|
||||
"data": {"count": 0, "list": []},
|
||||
})
|
||||
raise AssertionError(f"未预期的 URL: {url}")
|
||||
|
||||
|
||||
def write_config(tmp, folders):
|
||||
data = {
|
||||
"mid": 28802351,
|
||||
"csrf": "csrf-secret",
|
||||
"cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device",
|
||||
"storage": {
|
||||
"database_path": str(Path(tmp) / "app.db"),
|
||||
"backup_dir": str(Path(tmp) / "backup"),
|
||||
},
|
||||
"folders": folders,
|
||||
"up_blacklist_seed": [999],
|
||||
"schedule": {
|
||||
"discover_minutes": 60,
|
||||
"folder_sync_minutes": 60,
|
||||
"full_folder_refresh_hours": 6,
|
||||
"snapshot_at": "00:01",
|
||||
"candidate_ttl_days": 7,
|
||||
},
|
||||
}
|
||||
path = Path(tmp) / "config.json"
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def make_service(tmp, transport, clock, *, capacity_warn=950):
|
||||
folders = [
|
||||
{"year": 2025, "media_id": F1, "title": "2025"},
|
||||
{"year": 2026, "media_id": F2, "title": "2026"},
|
||||
]
|
||||
path = write_config(tmp, folders)
|
||||
store = ConfigStore(path, env={}, require_auth=True)
|
||||
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
||||
db.initialize()
|
||||
repo = Repository(db, clock=lambda: clock[0])
|
||||
client = BilibiliClient(
|
||||
cookie="SESSDATA=secret; bvid3=device",
|
||||
csrf="csrf",
|
||||
mid=28802351,
|
||||
transport=transport,
|
||||
pacer=Pacer(0, 0),
|
||||
retries=1,
|
||||
)
|
||||
service = FolderSyncService(
|
||||
store, repo, client, clock=lambda: clock[0], capacity_warn=capacity_warn
|
||||
)
|
||||
return db, repo, service
|
||||
|
||||
|
||||
def valid(aid, *, up_mid=100, title=None):
|
||||
return info_for(aid, title=title or f"视频{aid}", up_mid=up_mid, up_name="UP")
|
||||
|
||||
|
||||
def test_initial_import_and_incremental_skip():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = FakeFavTransport(
|
||||
{F1: [1101, 1102, 1103, 1104], F2: []},
|
||||
{
|
||||
1101: valid(1101),
|
||||
1102: valid(1102, up_mid=999), # 黑名单 UP 也必须正常入库
|
||||
1103: valid(1103, title="已失效", up_mid=100),
|
||||
},
|
||||
)
|
||||
# 1103 标记为 attr=9;1104 静默缺失
|
||||
transport.infos[1103]["attr"] = 9
|
||||
clock = [1_800_000_000]
|
||||
db, repo, service = make_service(tmp, transport, clock)
|
||||
try:
|
||||
summary = service.run()
|
||||
assert summary.ok
|
||||
assert summary.added == 4
|
||||
assert summary.missing == 1
|
||||
assert summary.refreshed == 3
|
||||
assert repo.seed_blacklist([999], now=0) == 0 # 已由 service 写入
|
||||
|
||||
assert repo.get_video(1101)["status"] == "active"
|
||||
assert repo.get_video(1102)["status"] == "active" # 黑名单不影响收藏夹导入
|
||||
assert repo.get_video(1103)["status"] == "removed"
|
||||
assert repo.get_video(1104)["status"] == "removed"
|
||||
assert repo.get_video(1104)["bvid"] == "BV1104"
|
||||
assert repo.folder_active_aids(F1) == {1101, 1102, 1103, 1104}
|
||||
assert repo.is_blacklisted(999)
|
||||
|
||||
folder = repo.get_folder(F1)
|
||||
assert folder.media_count == 4
|
||||
assert folder.last_full_at == 1_800_000_000
|
||||
|
||||
# 6 小时内再次运行:没有新增,不请求 infos
|
||||
clock[0] += 60
|
||||
info_before = len(transport.info_calls)
|
||||
summary2 = service.run()
|
||||
assert summary2.added == 0
|
||||
assert summary2.refreshed == 0
|
||||
assert len(transport.info_calls) == info_before
|
||||
assert summary2.folders[0].full_refresh is False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_diff_add_and_remove():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = FakeFavTransport(
|
||||
{F1: [1101, 1102], F2: []},
|
||||
{1101: valid(1101), 1102: valid(1102)},
|
||||
)
|
||||
clock = [1_800_000_000]
|
||||
db, repo, service = make_service(tmp, transport, clock)
|
||||
try:
|
||||
service.run()
|
||||
assert repo.get_video(1101)["status"] == "active"
|
||||
assert repo.get_video(1102)["status"] == "active"
|
||||
|
||||
transport.folders[F1] = [1102, 1103]
|
||||
transport.infos[1103] = valid(1103)
|
||||
clock[0] += 3600
|
||||
summary = service.run()
|
||||
assert summary.added == 1
|
||||
assert summary.removed == 1
|
||||
assert summary.refreshed == 1
|
||||
assert repo.get_video(1101)["status"] == "unfaved"
|
||||
assert repo.get_video(1102)["status"] == "active"
|
||||
assert repo.get_video(1103)["status"] == "active"
|
||||
assert repo.folder_active_aids(F1) == {1102, 1103}
|
||||
assert repo.get_folder(F1).media_count == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_full_refresh_marks_invalid_and_missing():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = FakeFavTransport(
|
||||
{F1: [1101, 1102], F2: []},
|
||||
{1101: valid(1101), 1102: valid(1102)},
|
||||
)
|
||||
clock = [1_800_000_000]
|
||||
db, repo, service = make_service(tmp, transport, clock)
|
||||
try:
|
||||
service.run()
|
||||
assert repo.get_video(1101)["status"] == "active"
|
||||
assert repo.get_video(1102)["status"] == "active"
|
||||
|
||||
# 6 小时后:1101 失效,1102 静默缺失
|
||||
clock[0] += 6 * 3600 + 1
|
||||
transport.infos[1101]["attr"] = 9
|
||||
del transport.infos[1102]
|
||||
summary = service.run()
|
||||
assert summary.folders[0].full_refresh is True
|
||||
assert summary.missing == 1
|
||||
assert repo.get_video(1101)["status"] == "removed"
|
||||
assert repo.get_video(1102)["status"] == "removed"
|
||||
assert repo.get_folder(F1).last_full_at == clock[0]
|
||||
# 全量刷新请求了夹内全部 aid
|
||||
assert set(transport.info_calls[-1][1]) == {1101, 1102}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_capacity_warning_does_not_move_folder():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = FakeFavTransport(
|
||||
{F1: [1, 2, 3], F2: []},
|
||||
{i: valid(i) for i in (1, 2, 3)},
|
||||
)
|
||||
clock = [1_800_000_000]
|
||||
db, repo, service = make_service(tmp, transport, clock, capacity_warn=3)
|
||||
try:
|
||||
summary = service.run()
|
||||
assert summary.folders[0].warning
|
||||
assert "预警线 3" in summary.folders[0].warning
|
||||
assert "不会自动换夹" in summary.folders[0].warning
|
||||
# 视频仍在原夹,不被移动
|
||||
assert all(repo.get_video(i)["folder_id"] == F1 for i in (1, 2, 3))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_folder_error_does_not_block_other_folders():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = FakeFavTransport(
|
||||
{F1: [1101], F2: [2201]},
|
||||
{1101: valid(1101), 2201: valid(2201)},
|
||||
fail_media=F1,
|
||||
)
|
||||
clock = [1_800_000_000]
|
||||
db, repo, service = make_service(tmp, transport, clock)
|
||||
try:
|
||||
summary = service.run()
|
||||
assert summary.ok is False
|
||||
assert len(summary.errors) == 1 and str(F1) in summary.errors[0]
|
||||
assert summary.folders[0].error
|
||||
assert summary.folders[1].error is None
|
||||
assert repo.get_video(2201)["status"] == "active"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_blacklist_seed_only_once():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = FakeFavTransport({F1: [1101], F2: []}, {1101: valid(1101)})
|
||||
clock = [1_800_000_000]
|
||||
db, repo, service = make_service(tmp, transport, clock)
|
||||
try:
|
||||
first = service.run()
|
||||
assert first.blacklist_seeded == 1
|
||||
clock[0] += 60
|
||||
second = service.run()
|
||||
assert second.blacklist_seeded == 0
|
||||
assert len(repo.blacklist()) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_initial_import_and_incremental_skip()
|
||||
test_diff_add_and_remove()
|
||||
test_full_refresh_marks_invalid_and_missing()
|
||||
test_capacity_warning_does_not_move_folder()
|
||||
test_folder_error_does_not_block_other_folders()
|
||||
test_blacklist_seed_only_once()
|
||||
print("OK: 收藏夹初始导入、差集同步、失效识别与容量预警测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""JSON 日志脱敏测试(不联网): uv run python tests/test_logging.py"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.logging_json import JsonLogger, redact # noqa: E402
|
||||
|
||||
SECRET_COOKIE = "SESSDATA=abc%2C1800000000%2Cdef; bili_jct=csrf123; buvid3=device123"
|
||||
SECRET_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi"
|
||||
|
||||
|
||||
def test_redact_keys_and_nested():
|
||||
data = {
|
||||
"cookie": SECRET_COOKIE,
|
||||
"nested": {
|
||||
"csrf": "csrf123",
|
||||
"bot_token": SECRET_TOKEN,
|
||||
"authorization": "Bearer abc.def.ghi",
|
||||
"safe": "visible",
|
||||
},
|
||||
"items": [{"sessdata": "x"}, {"note": "ok"}],
|
||||
}
|
||||
out = redact(data)
|
||||
assert out["cookie"] == "***"
|
||||
assert out["nested"]["csrf"] == "***"
|
||||
assert out["nested"]["bot_token"] == "***"
|
||||
assert out["nested"]["authorization"] == "***"
|
||||
assert out["nested"]["safe"] == "visible"
|
||||
assert out["items"][0]["sessdata"] == "***"
|
||||
assert out["items"][1]["note"] == "ok"
|
||||
|
||||
|
||||
def test_redact_secrets_inside_ordinary_strings():
|
||||
text = (
|
||||
f"request failed cookie={SECRET_COOKIE} token={SECRET_TOKEN} "
|
||||
"Authorization: Bearer abc.def"
|
||||
)
|
||||
out = redact({"error": text})
|
||||
assert "abc%2C1800000000" not in out["error"]
|
||||
assert "csrf123" not in out["error"]
|
||||
assert SECRET_TOKEN not in out["error"]
|
||||
assert "***" in out["error"]
|
||||
|
||||
|
||||
def test_logger_emits_json_lines_without_secrets():
|
||||
stream = io.StringIO()
|
||||
logger = JsonLogger(stream)
|
||||
logger.info(
|
||||
"test.event",
|
||||
cookie=SECRET_COOKIE,
|
||||
message=f"bad cookie {SECRET_COOKIE}",
|
||||
nested={"bili_jct": "csrf123", "count": 2},
|
||||
list_value=[SECRET_TOKEN],
|
||||
)
|
||||
lines = [line for line in stream.getvalue().splitlines() if line]
|
||||
assert len(lines) == 1
|
||||
payload = json.loads(lines[0])
|
||||
assert payload["level"] == "info"
|
||||
assert payload["event"] == "test.event"
|
||||
assert payload["cookie"] == "***"
|
||||
assert payload["nested"]["bili_jct"] == "***"
|
||||
assert payload["list_value"] == ["***"]
|
||||
assert "SESSDATA=***" in lines[0]
|
||||
assert SECRET_TOKEN not in lines[0]
|
||||
assert "1800000000" not in lines[0]
|
||||
|
||||
|
||||
def test_logger_levels():
|
||||
stream = io.StringIO()
|
||||
logger = JsonLogger(stream)
|
||||
logger.debug("d", x=1)
|
||||
logger.warning("w", x=2)
|
||||
logger.error("e", x=3)
|
||||
rows = [json.loads(line) for line in stream.getvalue().splitlines()]
|
||||
assert [r["level"] for r in rows] == ["debug", "warning", "error"]
|
||||
assert [r["event"] for r in rows] == ["d", "w", "e"]
|
||||
|
||||
|
||||
def main():
|
||||
test_redact_keys_and_nested()
|
||||
test_redact_secrets_inside_ordinary_strings()
|
||||
test_logger_emits_json_lines_without_secrets()
|
||||
test_logger_levels()
|
||||
print("OK: JSON 日志脱敏测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SQLite 迁移测试(不联网): uv run python tests/test_migrations.py"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.migrations import ( # noqa: E402
|
||||
MIGRATIONS,
|
||||
Migration,
|
||||
MigrationError,
|
||||
current_version,
|
||||
prune_backups,
|
||||
run_migrations,
|
||||
)
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"video", "snapshot", "folder", "folder_resource", "up_blacklist",
|
||||
"up_stat", "candidate", "seen_aid", "notify", "fav_action", "kv",
|
||||
}
|
||||
|
||||
|
||||
def table_names(conn):
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
|
||||
def test_fresh_and_idempotent():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conn = sqlite3.connect(Path(tmp) / "app.db")
|
||||
try:
|
||||
applied = run_migrations(conn)
|
||||
assert applied == [1]
|
||||
assert current_version(conn) == 1
|
||||
assert EXPECTED_TABLES <= table_names(conn)
|
||||
assert "schema_version" in table_names(conn)
|
||||
# 再跑一次不应重复应用
|
||||
assert run_migrations(conn) == []
|
||||
row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()
|
||||
assert row[0] == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_backup_before_upgrade():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "app.db"
|
||||
backup_dir = Path(tmp) / "backup"
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
run_migrations(conn, backup_dir=backup_dir)
|
||||
conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')")
|
||||
conn.commit()
|
||||
|
||||
v2 = Migration(2, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
|
||||
applied = run_migrations(
|
||||
conn,
|
||||
backup_dir=backup_dir,
|
||||
now=1_800_000_000,
|
||||
migrations=MIGRATIONS + (v2,),
|
||||
)
|
||||
assert applied == [2]
|
||||
backups = list(backup_dir.glob("app-pre-v1-*.db"))
|
||||
assert len(backups) == 1
|
||||
check = sqlite3.connect(backups[0])
|
||||
try:
|
||||
assert check.execute("SELECT v FROM kv WHERE k='hello'").fetchone()[0] == "world"
|
||||
assert "probe" not in table_names(check)
|
||||
finally:
|
||||
check.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_failed_migration_rolls_back():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conn = sqlite3.connect(Path(tmp) / "app.db")
|
||||
bad = Migration(
|
||||
1,
|
||||
"broken",
|
||||
(
|
||||
"CREATE TABLE should_not_exist (id INTEGER PRIMARY KEY)",
|
||||
"CREATE TABLE broken (",
|
||||
),
|
||||
)
|
||||
try:
|
||||
try:
|
||||
run_migrations(conn, migrations=(bad,))
|
||||
except MigrationError as exc:
|
||||
assert "已回滚" in str(exc)
|
||||
else:
|
||||
raise AssertionError("坏迁移应抛错")
|
||||
assert "should_not_exist" not in table_names(conn)
|
||||
assert current_version(conn) == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_prune_backups():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
backup_dir = Path(tmp)
|
||||
old = backup_dir / "app-pre-v1-old.db"
|
||||
fresh = backup_dir / "app-pre-v1-fresh.db"
|
||||
other = backup_dir / "keep.txt"
|
||||
for path in (old, fresh, other):
|
||||
path.write_bytes(b"x")
|
||||
now = 1_800_000_000
|
||||
old_time = now - 20 * 86400
|
||||
os.utime(old, (old_time, old_time))
|
||||
os.utime(fresh, (now, now))
|
||||
removed = prune_backups(backup_dir, keep_days=14, now=now)
|
||||
assert removed == [old]
|
||||
assert fresh.exists() and other.exists()
|
||||
|
||||
|
||||
def test_validate_migration_order():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conn = sqlite3.connect(Path(tmp) / "app.db")
|
||||
try:
|
||||
try:
|
||||
run_migrations(conn, migrations=(
|
||||
Migration(2, "x", ()),
|
||||
Migration(1, "y", ()),
|
||||
))
|
||||
except ValueError as exc:
|
||||
assert "升序" in str(exc)
|
||||
else:
|
||||
raise AssertionError("乱序迁移应失败")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_fresh_and_idempotent()
|
||||
test_backup_before_upgrade()
|
||||
test_failed_migration_rolls_back()
|
||||
test_prune_backups()
|
||||
test_validate_migration_order()
|
||||
print("OK: SQLite 迁移、备份与回滚测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""build_moves / apply_move 的正确性自测(无需 pytest): python tests/test_moves.py"""
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from favsort import apply_move, build_moves, lcs_keep # noqa: E402
|
||||
|
||||
|
||||
def run_case(cur, tgt, label):
|
||||
moves = build_moves(cur, tgt)
|
||||
work = list(cur)
|
||||
for m in moves:
|
||||
assert m["pred"] is not None, f"{label}: pred 为空"
|
||||
work = apply_move(work, m)
|
||||
assert work == tgt, f"{label}: 结果不符\n cur={cur}\n tgt={tgt}\n got={work}"
|
||||
lower = len(lcs_keep(cur, tgt))
|
||||
# 不追求严格最优(多轮 LCS 会略多于下界),但不能出现病态膨胀
|
||||
assert len(moves) <= len(cur) + 8, (
|
||||
f"{label}: 步数异常 {len(moves)} (n={len(cur)}, 下界={len(cur) - lower})"
|
||||
)
|
||||
return len(moves)
|
||||
|
||||
|
||||
def main():
|
||||
random.seed(20261031)
|
||||
total = 0
|
||||
for n in range(1, 13):
|
||||
ids = [1000 + i for i in range(n)]
|
||||
for _ in range(200):
|
||||
cur = ids[:]
|
||||
tgt = ids[:]
|
||||
random.shuffle(cur)
|
||||
random.shuffle(tgt)
|
||||
run_case(cur, tgt, f"n={n}")
|
||||
total += 1
|
||||
|
||||
# 头部必须换人的用例
|
||||
cases = [
|
||||
([3, 2, 1], [1, 2, 3]),
|
||||
([5, 4, 3, 2, 1], [1, 2, 3, 4, 5]),
|
||||
([2, 3, 1], [1, 2, 3]),
|
||||
([4, 1, 2, 3], [1, 2, 3, 4]),
|
||||
]
|
||||
for cur, tgt in cases:
|
||||
run_case(cur, tgt, f"head {cur}->{tgt}")
|
||||
|
||||
# 已排序不应产生移动
|
||||
assert build_moves([1, 2, 3], [1, 2, 3]) == []
|
||||
print(f"OK: {total} 个随机用例 + {len(cases)} 个头部用例全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,243 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""收藏夹/视频状态机测试(不联网): uv run python tests/test_repository.py"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.bilibili.models import FavResource # noqa: E402
|
||||
from reaction_board.config import FolderConfig # noqa: E402
|
||||
from reaction_board.database import Database # noqa: E402
|
||||
from reaction_board.repository import Repository # noqa: E402
|
||||
|
||||
F1 = FolderConfig(year=2025, media_id=4451053851, title="2025")
|
||||
F2 = FolderConfig(year=2026, media_id=3700023351, title="2026")
|
||||
|
||||
|
||||
def resource(aid, *, attr=0, up_mid=100, title="视频", bvid=None):
|
||||
return FavResource(
|
||||
aid=aid,
|
||||
bvid=bvid or f"BV{aid}",
|
||||
title=title,
|
||||
up_mid=up_mid,
|
||||
up_name="UP",
|
||||
pubdate=1_700_000_000,
|
||||
duration=600,
|
||||
cover="http://example/cover.jpg",
|
||||
attr=attr,
|
||||
fav_time=None,
|
||||
)
|
||||
|
||||
|
||||
def setup(tmp):
|
||||
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
||||
db.initialize()
|
||||
repo = Repository(db, clock=lambda: 1_800_000_000)
|
||||
repo.ensure_folder(F1)
|
||||
repo.ensure_folder(F2)
|
||||
return db, repo
|
||||
|
||||
|
||||
def test_upsert_and_folder_resource():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
outcome = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
assert outcome.status == "active"
|
||||
assert outcome.previous_folder_id is None
|
||||
video = repo.get_video(1)
|
||||
assert video["bvid"] == "BV1"
|
||||
assert video["folder_id"] == F1.media_id
|
||||
assert video["source"] == f"fav:{F1.media_id}"
|
||||
assert video["first_seen"] == 1_800_000_000
|
||||
assert repo.folder_active_aids(F1.media_id) == {1}
|
||||
|
||||
# 幂等:重复导入不新增行、不改 first_seen
|
||||
repo.upsert_from_fav(resource(1, title="新标题"), F1.media_id, was_added=False)
|
||||
assert len(repo.list_videos()) == 1
|
||||
video = repo.get_video(1)
|
||||
assert video["title"] == "新标题"
|
||||
assert video["first_seen"] == 1_800_000_000
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_invalid_attr_and_missing():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
out = repo.upsert_from_fav(resource(2, attr=9), F1.media_id, was_added=True)
|
||||
assert out.status == "removed"
|
||||
assert repo.get_video(2)["attr"] == 9
|
||||
|
||||
# 原本有效的视频后来失效:保留原历史标题
|
||||
repo.upsert_from_fav(
|
||||
resource(4, title="原始标题"), F1.media_id, was_added=True
|
||||
)
|
||||
repo.upsert_from_fav(
|
||||
resource(4, title="已失效视频", attr=9), F1.media_id, was_added=False
|
||||
)
|
||||
video4 = repo.get_video(4)
|
||||
assert video4["status"] == "removed"
|
||||
assert video4["title"] == "原始标题"
|
||||
|
||||
status = repo.mark_missing_resource(
|
||||
3, folder_id=F1.media_id, bvid="BV3"
|
||||
)
|
||||
assert status == "removed"
|
||||
video = repo.get_video(3)
|
||||
assert video["status"] == "removed"
|
||||
assert video["bvid"] == "BV3"
|
||||
assert video["title"] == "(资源已失效)"
|
||||
assert video["attr"] == 9
|
||||
assert 3 in repo.folder_active_aids(F1.media_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_remove_from_folder_transitions():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
for aid in (1, 2):
|
||||
repo.upsert_from_fav(resource(aid), F1.media_id, was_added=True)
|
||||
# 2 同时出现在两个夹:从 F1 移除后仍 active
|
||||
repo.upsert_from_fav(resource(2), F2.media_id, was_added=True)
|
||||
status = repo.mark_resource_removed(F1.media_id, 2)
|
||||
assert status == "active"
|
||||
assert repo.get_video(2)["status"] == "active"
|
||||
|
||||
# 1 只在 F1:移除 -> unfaved,但数据保留
|
||||
status = repo.mark_resource_removed(F1.media_id, 1)
|
||||
assert status == "unfaved"
|
||||
assert repo.get_video(1)["status"] == "unfaved"
|
||||
assert repo.get_video(1)["bvid"] == "BV1"
|
||||
assert 1 not in repo.folder_active_aids(F1.media_id)
|
||||
|
||||
# 再次出现在收藏夹 -> active
|
||||
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
assert out.status == "active"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_dropped_only_revives_on_reappearing_in_folder():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
repo.set_video_status(1, "dropped")
|
||||
# 全量刷新(未重新加入)保持 dropped
|
||||
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=False)
|
||||
assert out.status == "dropped"
|
||||
# 从收藏夹移除也不改变 dropped
|
||||
repo.mark_resource_removed(F1.media_id, 1)
|
||||
assert repo.get_video(1)["status"] == "dropped"
|
||||
# 重新出现在收藏夹 -> 自动恢复 active
|
||||
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
assert out.status == "active"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_removed_stays_removed_when_folder_removed():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1, attr=9), F1.media_id, was_added=True)
|
||||
assert repo.get_video(1)["status"] == "removed"
|
||||
status = repo.mark_resource_removed(F1.media_id, 1)
|
||||
assert status == "removed"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_multi_folder_moves_primary_and_warns():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
out = repo.upsert_from_fav(resource(1), F2.media_id, was_added=True)
|
||||
assert out.moved_folder is True
|
||||
assert out.previous_folder_id == F1.media_id
|
||||
assert repo.get_video(1)["folder_id"] == F2.media_id
|
||||
# 两个夹的资源关系都保留
|
||||
assert repo.folder_active_aids(F1.media_id) == {1}
|
||||
assert repo.folder_active_aids(F2.media_id) == {1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_blacklist_seed_idempotent():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
assert repo.seed_blacklist([1, 2, 3], now=100) == 3
|
||||
assert repo.seed_blacklist([2, 3, 4], now=200) == 1
|
||||
rows = repo.blacklist()
|
||||
assert [r["mid"] for r in rows] == [1, 2, 3, 4]
|
||||
assert all(r["reason"] == "seed" for r in rows)
|
||||
assert repo.is_blacklisted(2) and not repo.is_blacklisted(99)
|
||||
repo.add_blacklist(9, name="切片号", reason="manual")
|
||||
assert repo.is_blacklisted(9)
|
||||
repo.add_blacklist(9, reason="updated")
|
||||
row = next(r for r in repo.blacklist() if r["mid"] == 9)
|
||||
assert row["name"] == "切片号"
|
||||
assert row["reason"] == "updated"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_folder_sync_metadata_and_counts():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
repo.finish_folder_sync(
|
||||
F1, media_count=1, full_refresh=True, now=1_800_000_000
|
||||
)
|
||||
row = repo.get_folder(F1.media_id)
|
||||
assert row.media_count == 1
|
||||
assert row.last_ids_at == 1_800_000_000
|
||||
assert row.last_full_at == 1_800_000_000
|
||||
|
||||
repo.finish_folder_sync(
|
||||
F1, media_count=2, full_refresh=False, now=1_800_000_100
|
||||
)
|
||||
row = repo.get_folder(F1.media_id)
|
||||
assert row.media_count == 2
|
||||
assert row.last_ids_at == 1_800_000_100
|
||||
assert row.last_full_at == 1_800_000_000
|
||||
|
||||
# 配置中移除的年度夹停用,重新加入时恢复
|
||||
repo.disable_folders_except([F1.media_id])
|
||||
assert repo.get_folder(F2.media_id).enabled is False
|
||||
repo.ensure_folder(F2)
|
||||
assert repo.get_folder(F2.media_id).enabled is True
|
||||
|
||||
counts = repo.counts()
|
||||
assert counts["video_total"] == 1
|
||||
assert counts["video_by_status"] == {"active": 1}
|
||||
assert counts["folders"] == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_upsert_and_folder_resource()
|
||||
test_invalid_attr_and_missing()
|
||||
test_remove_from_folder_transitions()
|
||||
test_dropped_only_revives_on_reappearing_in_folder()
|
||||
test_removed_stays_removed_when_folder_removed()
|
||||
test_multi_folder_moves_primary_and_warns()
|
||||
test_blacklist_seed_idempotent()
|
||||
test_folder_sync_metadata_and_counts()
|
||||
print("OK: 收藏夹/视频状态机与黑名单测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""内置调度器测试(离线): uv run python tests/test_scheduler.py"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.scheduler import ScheduledTask, Scheduler # noqa: E402
|
||||
|
||||
|
||||
def test_run_when_due_and_reschedule():
|
||||
clock = [1_000.0]
|
||||
calls = []
|
||||
|
||||
task = ScheduledTask("sync", 60, lambda: calls.append(clock[0]))
|
||||
scheduler = Scheduler([task], clock=lambda: clock[0])
|
||||
assert scheduler.run_due() and len(calls) == 1
|
||||
assert task.next_run == 1_060.0
|
||||
|
||||
clock[0] = 1_059.0
|
||||
assert scheduler.run_due() == []
|
||||
assert len(calls) == 1
|
||||
|
||||
clock[0] = 1_060.0
|
||||
results = scheduler.run_due()
|
||||
assert len(results) == 1
|
||||
assert calls == [1_000.0, 1_060.0]
|
||||
assert task.next_run == 1_120.0
|
||||
|
||||
|
||||
def test_task_error_does_not_block_other_tasks():
|
||||
clock = [100.0]
|
||||
calls = []
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("api down")
|
||||
|
||||
scheduler = Scheduler(
|
||||
[
|
||||
ScheduledTask("bad", 10, boom),
|
||||
ScheduledTask("good", 10, lambda: calls.append("ok")),
|
||||
],
|
||||
clock=lambda: clock[0],
|
||||
)
|
||||
results = scheduler.run_due()
|
||||
assert [r.ok for r in results] == [False, True]
|
||||
assert results[0].error == "api down"
|
||||
assert calls == ["ok"]
|
||||
# 失败任务仍然被重新排期
|
||||
assert all(t.next_run == 110.0 for t in scheduler.tasks)
|
||||
|
||||
|
||||
def test_run_forever_with_stop_event():
|
||||
clock = [0.0]
|
||||
calls = []
|
||||
|
||||
def tick():
|
||||
calls.append(clock[0])
|
||||
clock[0] += 10
|
||||
|
||||
stop = threading.Event()
|
||||
scheduler = Scheduler(
|
||||
[ScheduledTask("tick", 10, tick)],
|
||||
clock=lambda: clock[0],
|
||||
sleeper=lambda seconds: None,
|
||||
)
|
||||
|
||||
def stop_soon():
|
||||
while len(calls) < 3:
|
||||
pass
|
||||
stop.set()
|
||||
|
||||
waiter = threading.Thread(target=stop_soon)
|
||||
waiter.start()
|
||||
scheduler.run_forever(stop_event=stop, poll_seconds=0.001)
|
||||
waiter.join(timeout=2)
|
||||
assert len(calls) >= 3
|
||||
|
||||
|
||||
def test_max_iterations_and_validation():
|
||||
calls = []
|
||||
tick_clock = [0.0]
|
||||
|
||||
def next_time():
|
||||
tick_clock[0] += 1.0
|
||||
return tick_clock[0]
|
||||
|
||||
scheduler = Scheduler(
|
||||
[ScheduledTask("t", 1, lambda: calls.append(1))],
|
||||
clock=next_time,
|
||||
sleeper=lambda seconds: None,
|
||||
)
|
||||
scheduler.run_forever(max_iterations=2)
|
||||
assert len(calls) == 2
|
||||
|
||||
for tasks, kwargs in [
|
||||
([], {}),
|
||||
([ScheduledTask("bad", 0, lambda: None)], {}),
|
||||
([ScheduledTask("t", 1, lambda: None)], {"poll_seconds": 0}),
|
||||
]:
|
||||
try:
|
||||
s = Scheduler(tasks, sleeper=lambda seconds: None)
|
||||
s.run_forever(max_iterations=1, **kwargs)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"应当失败: {tasks}, {kwargs}")
|
||||
|
||||
|
||||
def main():
|
||||
test_run_when_due_and_reschedule()
|
||||
test_task_error_does_not_block_other_tasks()
|
||||
test_run_forever_with_stop_event()
|
||||
test_max_iterations_and_validation()
|
||||
print("OK: 内置调度器测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user