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:
kairee committed 2026-10-31 18:08:33 +08:00
commit e2cdf997af
39 files changed
+8575

No files matched your search

+163
View File
@@ -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
+171
View File
@@ -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()
+295
View File
@@ -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()
+110
View File
@@ -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()
+210
View File
@@ -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()
+142
View File
@@ -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()
+297
View File
@@ -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()
+94
View File
@@ -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()
+150
View File
@@ -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()
+57
View File
@@ -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()
+243
View File
@@ -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()
+122
View File
@@ -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()