refactor(sync): drop favorite-folder tracking and deal

This commit is contained in:
kairee committed 2026-11-01 13:59:10 +08:00
1 parent 62abbd7ea1
commit 6c4287b43a
21 files changed
+1390 -2766

No files matched your search

+4 -17
View File
@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""B 站收藏夹写客户端(``deal`` / ``sort``)。 """B 站收藏夹写客户端(``sort``)。
收藏夹重排序是本项目**唯一**使用 ``bilibili.write`` 凭据的写操作,且频率极低。
加入收藏夹(``deal``)等写操作已移除,收藏夹由人工维护。
只应使用 ``bilibili.write`` 凭据构造。 只应使用 ``bilibili.write`` 凭据构造。
""" """
@@ -39,22 +42,6 @@ class BilibiliWriteClient(BilibiliClient):
raise self._api_error(payload) raise self._api_error(payload)
return payload return payload
def deal(self, aid: int, folder_id: int) -> dict[str, Any]:
return self.post_form(
f"{BASE}/x/v3/fav/resource/deal",
{
"rid": int(aid),
"type": 2,
"add_media_ids": int(folder_id),
"del_media_ids": "",
"csrf": self.csrf,
},
referer=(
f"https://space.bilibili.com/{self.mid}/favlist"
f"?fid={int(folder_id)}&ftype=create"
),
)
def sort(self, media_id: int, sort_spec: str) -> dict[str, Any]: def sort(self, media_id: int, sort_spec: str) -> dict[str, Any]:
return self.post_form( return self.post_form(
f"{BASE}/x/v3/fav/resource/sort", f"{BASE}/x/v3/fav/resource/sort",
+40 -96
View File
@@ -2,7 +2,7 @@
"""看板命令行入口。 """看板命令行入口。
python app.py init # 建库 / 迁移 / 写入黑名单种子(离线) python app.py init # 建库 / 迁移 / 写入黑名单种子(离线)
python app.py sync # 收藏夹只读同步(首次导入或增量) python app.py ingest # 手动入库 B 站视频链接 / BV 号 / av 号(只写本地库)
python app.py snapshot # 采集当日七项快照与粉丝数 python app.py snapshot # 采集当日七项快照与粉丝数
python app.py discover # 执行一次搜索发现并写入候选池 python app.py discover # 执行一次搜索发现并写入候选池
python app.py candidates # 查看候选池(离线) python app.py candidates # 查看候选池(离线)
@@ -10,6 +10,7 @@
python app.py run # 按配置周期常驻运行(--once 只跑一次) python app.py run # 按配置周期常驻运行(--once 只跑一次)
python app.py status # 查看库内状态(离线) python app.py status # 查看库内状态(离线)
收藏夹重排序使用独立的 ``favsort.py``(唯一使用 ``bilibili.write`` 的功能)。
P2 起 ``python app.py`` 将在此基础上启动本地看板服务。 P2 起 ``python app.py`` 将在此基础上启动本地看板服务。
""" """
@@ -27,7 +28,6 @@ from .bilibili.auth_flow import AuthService
from .bilibili.client import BilibiliClient, Pacer from .bilibili.client import BilibiliClient, Pacer
from .bilibili.provider import ClientProvider from .bilibili.provider import ClientProvider
from .bilibili.search import SearchClient from .bilibili.search import SearchClient
from .bilibili.write_client import BilibiliWriteClient
from .config import ConfigError, ConfigStore from .config import ConfigError, ConfigStore
from .database import Database from .database import Database
from .logging_json import JsonLogger from .logging_json import JsonLogger
@@ -35,8 +35,7 @@ from .notify.telegram import TelegramClient
from .repository import Repository from .repository import Repository
from .scheduler import ScheduledTask, Scheduler from .scheduler import ScheduledTask, Scheduler
from .services.discovery import DiscoveryService from .services.discovery import DiscoveryService
from .services.favorites import FavoriteActionService from .services.library import LibraryService
from .services.folder_sync import FolderSyncService
from .services.snapshot import SnapshotService from .services.snapshot import SnapshotService
from .services.telegram_poller import TelegramPoller from .services.telegram_poller import TelegramPoller
from .services.up_profile import UpProfileService from .services.up_profile import UpProfileService
@@ -66,20 +65,6 @@ def _build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command", required=False) sub = parser.add_subparsers(dest="command", required=False)
sub.add_parser("init", parents=[common], help="建库、迁移并写入黑名单种子") sub.add_parser("init", parents=[common], help="建库、迁移并写入黑名单种子")
sync = sub.add_parser("sync", parents=[common], help="同步已配置的年度收藏夹")
sync.add_argument(
"--full", action="store_true", help="强制全量刷新元数据(忽略 6 小时周期)"
)
sync.add_argument(
"--allow-empty-folder",
action="store_true",
help="允许把远端 0 条视为真实清空(默认按异常跳过差集)",
)
sync.add_argument(
"--no-capture-followers",
action="store_true",
help="跳过同步末尾的 UP 粉丝采集(改用 backfill-followers 单独回填)",
)
snapshot = sub.add_parser( snapshot = sub.add_parser(
"snapshot", parents=[common], help="采集当日七项快照与粉丝数" "snapshot", parents=[common], help="采集当日七项快照与粉丝数"
) )
@@ -103,6 +88,14 @@ def _build_parser() -> argparse.ArgumentParser:
default=None, default=None,
help="只查看指定决策状态的候选", help="只查看指定决策状态的候选",
) )
ingest = sub.add_parser(
"ingest", parents=[common],
help="手动入库:B 站视频链接、BV 号或 av 号(只写本地库)",
)
ingest.add_argument(
"refs", nargs="+", metavar="REF",
help="视频链接、BV 号(BV…)、av 号(av12345)或含它们的字符串",
)
auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie") auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie")
auth.add_argument("--account", choices=("read", "write"), default="read", auth.add_argument("--account", choices=("read", "write"), default="read",
help="要授权的账号角色(默认 read)") help="要授权的账号角色(默认 read)")
@@ -112,15 +105,10 @@ def _build_parser() -> argparse.ArgumentParser:
auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态") auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态")
auth_check.add_argument("--account", choices=("read", "write", "all"), default="read", auth_check.add_argument("--account", choices=("read", "write", "all"), default="read",
help="要检查的账号角色(默认 read)") help="要检查的账号角色(默认 read)")
run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行同步与快照") run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行发现与快照")
run.add_argument( run.add_argument(
"--once", action="store_true", help="只执行一次到期任务后退出" "--once", action="store_true", help="只执行一次到期任务后退出"
) )
run.add_argument(
"--allow-empty-folder",
action="store_true",
help="允许把远端 0 条视为真实清空(默认按异常跳过差集)",
)
serve = sub.add_parser("serve", parents=[common], help="启动只读本地看板(不运行调度)") serve = sub.add_parser("serve", parents=[common], help="启动只读本地看板(不运行调度)")
serve.add_argument( serve.add_argument(
"--preview", action="store_true", "--preview", action="store_true",
@@ -209,32 +197,30 @@ def cmd_init(args, logger: JsonLogger) -> int:
return 0 return 0
def cmd_sync(args, logger: JsonLogger) -> int: def cmd_ingest(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=True) store = ConfigStore(args.config, require_auth=False)
cfg = store.get() cfg = store.get()
db = _open_db(cfg, logger) db = _open_db(cfg, logger)
try: try:
repo = Repository(db) repo = Repository(db)
client = _client(cfg.bilibili.read, logger) client = _client(cfg.bilibili.read, logger)
service = FolderSyncService( service = LibraryService(
store, store, repo, lambda: client, logger=logger
repo,
client,
logger=logger,
allow_empty_folders=args.allow_empty_folder,
capture_followers=not getattr(args, "no_capture_followers", False),
) )
started = int(time.time()) text = " ".join(args.refs)
summary = service.run(full_refresh=args.full) result = service.ingest_text(text)
payload = summary.as_dict() payload = result.as_dict()
payload["database"] = str(cfg.storage.database_path) payload["database"] = str(cfg.storage.database_path)
payload["counts"] = repo.counts()
_record_task( _record_task(
repo, "folder_sync", ok=summary.ok, started_at=started, repo, "ingest", ok=bool(result.results) and not any(
finished_at=int(time.time()), payload=payload, item.state == "error" for item in result.results
),
payload=payload,
) )
_print(payload) _print(payload)
return 0 if summary.ok else 1 return 0 if result.results and not any(
item.state == "error" for item in result.results
) else 1
finally: finally:
db.close() db.close()
@@ -403,7 +389,9 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
web_server = None web_server = None
try: try:
repo = Repository(db) repo = Repository(db)
folder_provider = ClientProvider( if cfg.up_blacklist_seed:
repo.seed_blacklist(cfg.up_blacklist_seed)
read_provider = ClientProvider(
store, "read", lambda credentials: _client(credentials, logger) store, "read", lambda credentials: _client(credentials, logger)
) )
snapshot_provider = ClientProvider( snapshot_provider = ClientProvider(
@@ -413,34 +401,22 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
credentials, logger, pacer=Pacer(1.0, 1.6), retries=1 credentials, logger, pacer=Pacer(1.0, 1.6), retries=1
), ),
) )
folder_service = FolderSyncService(
store,
repo,
client_provider=folder_provider,
logger=logger,
allow_empty_folders=args.allow_empty_folder,
)
snapshot_service = SnapshotService( snapshot_service = SnapshotService(
store, repo, client_provider=snapshot_provider, logger=logger store, repo, client_provider=snapshot_provider, logger=logger
) )
write_provider = ClientProvider( up_profile_service = UpProfileService(repo, read_provider.get, logger=logger)
library_service = LibraryService(
store, store,
"write", repo,
lambda credentials: BilibiliWriteClient( read_provider,
cookie=credentials.cookie, up_profile_service=up_profile_service,
csrf=credentials.csrf, logger=logger,
mid=credentials.mid,
logger=logger,
),
)
favorite_service = FavoriteActionService(
store, repo, write_provider, logger=logger
) )
notifier = _telegram(cfg, logger) notifier = _telegram(cfg, logger)
discovery_service = DiscoveryService( discovery_service = DiscoveryService(
store, store,
repo, repo,
SearchClient(folder_provider.get), SearchClient(read_provider.get),
notifier=notifier, notifier=notifier,
logger=logger, logger=logger,
) )
@@ -513,29 +489,6 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
logger.info("snapshot.summary", **payload) logger.info("snapshot.summary", **payload)
return summary return summary
def run_sync():
started = int(time.time())
try:
summary = folder_service.run()
except Exception as exc: # noqa: BLE001
_record_task(
repo, "folder_sync", ok=False, started_at=started,
finished_at=int(time.time()), error=str(exc),
)
raise
payload = summary.as_dict()
_record_task(
repo, "folder_sync", ok=summary.ok, started_at=started,
finished_at=int(time.time()), payload=payload,
error=None if summary.ok else ";".join(summary.errors),
)
last["folder"] = summary
logger.info("folder_sync.summary", **payload)
# 新入库/恢复的视频立即补当天快照,而不是等到次日。
if summary.added > 0:
run_snapshot()
return summary
def run_config_watch(): def run_config_watch():
before = store.revision before = store.revision
store.get() store.get()
@@ -547,11 +500,6 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
snapshot_at = f"{hh:02d}:{mm:02d}" snapshot_at = f"{hh:02d}:{mm:02d}"
scheduler = Scheduler( scheduler = Scheduler(
[ [
ScheduledTask(
"folder_sync",
float(cfg.schedule.folder_sync_minutes * 60),
run_sync,
),
ScheduledTask( ScheduledTask(
"snapshot", "snapshot",
86400.0, 86400.0,
@@ -579,7 +527,7 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
daemon=True, daemon=True,
).start() ).start()
if notifier is not None and not args.once: if notifier is not None and not args.once:
poller = TelegramPoller(notifier, favorite_service, logger=logger) poller = TelegramPoller(notifier, library_service, logger=logger)
threading.Thread( threading.Thread(
target=poller.run_forever, target=poller.run_forever,
name="telegram-poller", name="telegram-poller",
@@ -592,18 +540,15 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
else: else:
logger.info( logger.info(
"scheduler.start", "scheduler.start",
folder_sync_seconds=cfg.schedule.folder_sync_minutes * 60,
snapshot_at=snapshot_at, snapshot_at=snapshot_at,
) )
scheduler.run_forever() scheduler.run_forever()
folder_summary = last.get("folder")
snapshot_summary = last.get("snapshot") snapshot_summary = last.get("snapshot")
discover_summary = last.get("discover") discover_summary = last.get("discover")
folder_ok = folder_summary is None or getattr(folder_summary, "ok", True)
snapshot_ok = snapshot_summary is None or getattr(snapshot_summary, "completed", True) snapshot_ok = snapshot_summary is None or getattr(snapshot_summary, "completed", True)
discover_ok = discover_summary is None or not getattr(discover_summary, "errors", []) discover_ok = discover_summary is None or not getattr(discover_summary, "errors", [])
return 0 if folder_ok and snapshot_ok and discover_ok else 1 return 0 if snapshot_ok and discover_ok else 1
finally: finally:
if web_server is not None: if web_server is not None:
try: try:
@@ -703,16 +648,15 @@ def main(argv: Sequence[str] | None = None) -> int:
# 无子命令时启动完整服务:只读 Web + 调度 + Telegram。 # 无子命令时启动完整服务:只读 Web + 调度 + Telegram。
args.command = "run" args.command = "run"
args.once = False args.once = False
args.allow_empty_folder = False
return cmd_run(args, logger, serve_web=True) return cmd_run(args, logger, serve_web=True)
if args.command == "serve": if args.command == "serve":
return cmd_serve(args, logger) return cmd_serve(args, logger)
if args.command == "init": if args.command == "init":
return cmd_init(args, logger) return cmd_init(args, logger)
if args.command == "sync":
return cmd_sync(args, logger)
if args.command == "snapshot": if args.command == "snapshot":
return cmd_snapshot(args, logger) return cmd_snapshot(args, logger)
if args.command == "ingest":
return cmd_ingest(args, logger)
if args.command == "backfill-followers": if args.command == "backfill-followers":
return cmd_backfill_followers(args, logger) return cmd_backfill_followers(args, logger)
if args.command == "discover": if args.command == "discover":
-14
View File
@@ -68,8 +68,6 @@ DEFAULT_CONFIG: dict[str, Any] = {
"up_blacklist_seed": [], "up_blacklist_seed": [],
"schedule": { "schedule": {
"discover_minutes": 60, "discover_minutes": 60,
"folder_sync_minutes": 60,
"full_folder_refresh_hours": 6,
"snapshot_at": "00:01", "snapshot_at": "00:01",
"candidate_ttl_days": 7, "candidate_ttl_days": 7,
}, },
@@ -151,8 +149,6 @@ class FolderConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class ScheduleConfig: class ScheduleConfig:
discover_minutes: int discover_minutes: int
folder_sync_minutes: int
full_folder_refresh_hours: int
snapshot_at: str snapshot_at: str
candidate_ttl_days: int candidate_ttl_days: int
@@ -370,16 +366,6 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
discover_minutes=_as_int( discover_minutes=_as_int(
sched_raw.get("discover_minutes", 60), "schedule.discover_minutes", minimum=1 sched_raw.get("discover_minutes", 60), "schedule.discover_minutes", minimum=1
), ),
folder_sync_minutes=_as_int(
sched_raw.get("folder_sync_minutes", 60),
"schedule.folder_sync_minutes",
minimum=1,
),
full_folder_refresh_hours=_as_int(
sched_raw.get("full_folder_refresh_hours", 6),
"schedule.full_folder_refresh_hours",
minimum=1,
),
snapshot_at=snapshot_at, snapshot_at=snapshot_at,
candidate_ttl_days=_as_int( candidate_ttl_days=_as_int(
sched_raw.get("candidate_ttl_days", 7), sched_raw.get("candidate_ttl_days", 7),
-82
View File
@@ -1,82 +0,0 @@
# -*- coding: utf-8 -*-
"""新视频在收藏夹中的插入位置计算(纯函数)。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Sequence
@dataclass(frozen=True)
class VideoMeta:
up_mid: int | None
pubdate: int
@dataclass(frozen=True)
class InsertionPlan:
target_position: int
moves: tuple[tuple[int, int], ...] # (pred_aid, moved_aid)
@property
def needs_head_move(self) -> bool:
return len(self.moves) == 2
def plan_insertion(
current_ids: Sequence[int],
new_aid: int,
metadata: Mapping[int, VideoMeta],
new_meta: VideoMeta,
) -> InsertionPlan:
"""按“同 UP 连续、组间按最早投稿倒序”计算插入点。
``current_ids`` 可以包含 ``new_aid``(通常在末尾),函数会先排除它。
"""
ordered = [int(aid) for aid in current_ids if int(aid) != int(new_aid)]
if not ordered:
return InsertionPlan(target_position=1, moves=())
groups: list[dict] = []
for index, aid in enumerate(ordered):
meta = metadata.get(aid)
up_mid = meta.up_mid if meta else None
if up_mid is not None and groups and groups[-1]["up_mid"] == up_mid:
groups[-1]["aids"].append(aid)
else:
groups.append({"up_mid": up_mid, "aids": [aid], "start": index})
for group in groups:
keys = [
metadata[aid].pubdate
for aid in group["aids"]
if aid in metadata and metadata[aid].pubdate > 0
]
group["key"] = min(keys) if keys else 0
insert_index: int | None = None
if new_meta.up_mid is not None:
for group in groups:
if group["up_mid"] == new_meta.up_mid:
insert_index = group["start"]
break
if insert_index is None:
for group in groups:
if group["key"] < new_meta.pubdate:
insert_index = group["start"]
break
if insert_index is None:
insert_index = len(ordered)
target_position = insert_index + 1
if insert_index == 0:
first = ordered[0]
return InsertionPlan(
target_position=1,
moves=((first, int(new_aid)), (int(new_aid), first)),
)
predecessor = ordered[insert_index - 1]
return InsertionPlan(
target_position=target_position,
moves=((predecessor, int(new_aid)),),
)
+9
View File
@@ -207,6 +207,15 @@ MIGRATIONS: tuple[Migration, ...] = (
""", """,
), ),
), ),
Migration(
version=5,
name="stop favorite-folder tracking",
statements=(
# 不再追踪收藏夹后,“取消收藏”状态失去语义;历史 unfaved 行归一到 active。
# 表结构与历史数据保留,仅停止写入。
"UPDATE video SET status = 'active' WHERE status = 'unfaved'",
),
),
) )
-237
View File
@@ -1,237 +0,0 @@
# -*- coding: utf-8 -*-
"""候选收藏、忽略、拉黑动作。"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from ..bilibili.models import FavResource
from ..config import ConfigStore
from ..fav_position import VideoMeta, plan_insertion
from ..repository import Repository
from ..timeutil import shanghai_datetime
@dataclass
class ActionResult:
ok: bool
aid: int
action: str
state: str
position: int | None = None
already_in_folder: bool = False
message: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"ok": self.ok,
"aid": self.aid,
"action": self.action,
"state": self.state,
"position": self.position,
"already_in_folder": self.already_in_folder,
"message": self.message,
}
class FavoriteActionService:
def __init__(
self,
config_store: ConfigStore,
repository: Repository,
write_provider,
*,
clock=time.time,
logger=None,
) -> None:
self.config_store = config_store
self.repo = repository
self.write_provider = write_provider
self._clock = clock
self.logger = logger
def _now(self) -> int:
return int(self._clock())
def _log(self, level: str, event: str, **fields) -> None:
if self.logger is not None:
getattr(self.logger, level)(event, **fields)
def _target_folder(self, candidate: dict[str, Any]):
cfg = self.config_store.get()
up_mid = candidate.get("up_mid")
if isinstance(up_mid, int) and not isinstance(up_mid, bool):
folder_id = self.repo.folder_for_up(up_mid)
if folder_id is not None:
row = self.repo.get_folder(folder_id)
if row is not None and row.enabled:
return row
year = shanghai_datetime(self._clock()).year
folder = cfg.folder_for_year(year)
if folder is not None:
return folder
latest = cfg.latest_folder()
if latest is not None:
return latest
raise RuntimeError("没有可用的目标收藏夹,请检查 folders 配置")
def favorite(self, aid: int) -> ActionResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return ActionResult(
ok=False, aid=aid, action="favorite", state="missing",
message="候选不存在或已处理",
)
try:
folder = self._target_folder(candidate)
except Exception as exc: # noqa: BLE001
return ActionResult(
ok=False, aid=aid, action="favorite", state="failed",
message=str(exc),
)
token, state = self.repo.begin_fav_action(
aid, folder.media_id, now=self._now()
)
if state == "done":
action = self.repo.get_fav_action(token) or {}
return ActionResult(
ok=True, aid=aid, action="favorite", state="done",
position=action.get("position"),
message="已在收藏夹",
)
if state == "busy":
return ActionResult(
ok=False, aid=aid, action="favorite", state="busy",
message="该候选正在处理中,请稍后重试",
)
already = False
try:
client = self.write_provider.get()
ids = client.fav_ids(folder.media_id)
if aid in {item.aid for item in ids}:
already = True
position = [item.aid for item in ids].index(aid) + 1
else:
client.deal(aid, folder.media_id)
ids = client.fav_ids(folder.media_id)
aid_order = [item.aid for item in ids]
if aid not in aid_order:
raise RuntimeError("deal 成功但回读收藏夹未找到该视频")
metadata = {
item_aid: VideoMeta(
up_mid=(info.get("up_mid") if info else None),
pubdate=int((info or {}).get("pubdate") or 0),
)
for item_aid, info in self.repo.video_metadata(aid_order).items()
}
plan = plan_insertion(
aid_order,
aid,
metadata,
VideoMeta(
up_mid=candidate.get("up_mid"),
pubdate=int(candidate.get("pubdate") or 0),
),
)
type_map = {item.aid: item.type for item in ids}
for predecessor, moved in plan.moves:
client.sort(
folder.media_id,
f"{predecessor}:{type_map.get(predecessor, 2)}:"
f"{moved}:{type_map.get(moved, 2)}",
)
final_order = [item.aid for item in client.fav_ids(folder.media_id)]
if aid not in final_order:
raise RuntimeError("排序后回读收藏夹未找到该视频")
position = final_order.index(aid) + 1
if position != plan.target_position:
raise RuntimeError(
f"回读位置不符:期望 #{plan.target_position},实际 #{position}"
)
resource = FavResource(
aid=aid,
bvid=candidate.get("bvid"),
title=str(candidate.get("title") or "(无标题)"),
up_mid=candidate.get("up_mid"),
up_name=candidate.get("up_name"),
pubdate=int(candidate.get("pubdate") or 0),
duration=None,
cover=candidate.get("cover"),
attr=0,
fav_time=None,
)
self.repo.upsert_from_fav(
resource, folder.media_id, was_added=True, now=self._now()
)
self.repo.delete_candidate(aid)
self.repo.finish_fav_action(token, position=position, now=self._now())
self._log(
"info", "favorite.done",
aid=aid, folder_id=folder.media_id, position=position,
already=already,
)
self.repo.bump_cache_version("videos")
return ActionResult(
ok=True, aid=aid, action="favorite", state="done",
position=position, already_in_folder=already,
message="已在收藏夹" if already else "已加入收藏夹",
)
except Exception as exc: # noqa: BLE001 失败保留候选,允许重试
self.repo.fail_fav_action(token, error=str(exc), now=self._now())
self._log(
"error", "favorite.failed",
aid=aid, folder_id=folder.media_id, error=str(exc),
)
return ActionResult(
ok=False, aid=aid, action="favorite", state="failed",
message=str(exc),
)
def ignore(self, aid: int) -> ActionResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return ActionResult(
ok=False, aid=aid, action="ignore", state="missing",
message="候选不存在或已处理",
)
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
self.repo.bump_cache_version("videos")
return ActionResult(
ok=True, aid=aid, action="ignore", state="rejected",
message="已忽略",
)
def blacklist(self, aid: int) -> ActionResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return ActionResult(
ok=False, aid=aid, action="blacklist", state="missing",
message="候选不存在或已处理",
)
up_mid = candidate.get("up_mid")
if not isinstance(up_mid, int) or isinstance(up_mid, bool):
return ActionResult(
ok=False, aid=aid, action="blacklist", state="failed",
message="候选缺少 UP mid,无法拉黑",
)
self.repo.add_blacklist(
up_mid,
name=candidate.get("up_name"),
reason="telegram",
now=self._now(),
)
rejected = self.repo.reject_pending_for_up(up_mid, now=self._now())
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
self.repo.bump_cache_version("videos")
return ActionResult(
ok=True, aid=aid, action="blacklist", state="rejected",
message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选",
)
-335
View File
@@ -1,335 +0,0 @@
# -*- coding: utf-8 -*-
"""收藏夹同步(库的真相源)。
每次运行:
1. 读取每个配置年度夹的全部 aid;
2. 与本地 ``folder_resource`` 做差集;
3. 新增资源按 200 条分块拉取 ``infos`` 并入库;
4. 从收藏夹移除的资源标记 ``unfaved``(不删除历史);
5. 每 ``full_folder_refresh_hours`` 小时全量刷新一次元数据并识别失效资源;
6. 容量接近上限时预警,但绝不自动换夹。
黑名单只在发现流程生效,本服务不读取 ``up_blacklist``。
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from ..config import AppConfig, ConfigStore, FolderConfig
from ..repository import Repository
from .up_profile import UpProfileService
CAPACITY_WARN = 950
@dataclass
class FolderSyncResult:
media_id: int
year: int
title: str
remote_count: int = 0
added: int = 0
removed: int = 0
refreshed: int = 0
missing: int = 0
full_refresh: bool = False
warning: str | None = None
error: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"media_id": self.media_id,
"year": self.year,
"title": self.title,
"remote_count": self.remote_count,
"added": self.added,
"removed": self.removed,
"refreshed": self.refreshed,
"missing": self.missing,
"full_refresh": self.full_refresh,
"warning": self.warning,
"error": self.error,
}
@dataclass
class SyncSummary:
started_at: int
finished_at: int = 0
folders: list[FolderSyncResult] = field(default_factory=list)
blacklist_seeded: int = 0
followers_updated: int = 0
followers_failed: int = 0
@property
def added(self) -> int:
return sum(f.added for f in self.folders)
@property
def removed(self) -> int:
return sum(f.removed for f in self.folders)
@property
def refreshed(self) -> int:
return sum(f.refreshed for f in self.folders)
@property
def missing(self) -> int:
return sum(f.missing for f in self.folders)
@property
def errors(self) -> list[str]:
return [f"folder {f.media_id}: {f.error}" for f in self.folders if f.error]
@property
def warnings(self) -> list[str]:
return [f"folder {f.media_id}: {f.warning}" for f in self.folders if f.warning]
@property
def ok(self) -> bool:
return not self.errors
def as_dict(self) -> dict[str, Any]:
return {
"started_at": self.started_at,
"finished_at": self.finished_at,
"elapsed": max(0, self.finished_at - self.started_at),
"added": self.added,
"removed": self.removed,
"refreshed": self.refreshed,
"missing": self.missing,
"blacklist_seeded": self.blacklist_seeded,
"followers_updated": self.followers_updated,
"followers_failed": self.followers_failed,
"folders": [f.as_dict() for f in self.folders],
"errors": self.errors,
"warnings": self.warnings,
"ok": self.ok,
}
class FolderSyncService:
def __init__(
self,
config_store: ConfigStore,
repository: Repository,
client=None,
*,
client_provider=None,
clock=time.time,
logger=None,
capacity_warn: int = CAPACITY_WARN,
allow_empty_folders: bool = False,
up_profile_service: UpProfileService | None = None,
capture_followers: bool = True,
) -> None:
if client is None and client_provider is None:
raise ValueError("必须提供 client 或 client_provider")
self.config_store = config_store
self.repo = repository
self.client = client
self.client_provider = client_provider
self._clock = clock
self.logger = logger
self.capacity_warn = capacity_warn
self.allow_empty_folders = allow_empty_folders
self.capture_followers = capture_followers
self.up_profile = up_profile_service or UpProfileService(
repository, self._client, clock=clock, logger=logger
)
def _client(self):
if self.client_provider is not None:
return self.client_provider.get()
return self.client
def _now(self) -> int:
return int(self._clock())
def _log(self, level: str, event: str, **fields) -> None:
if self.logger is not None:
getattr(self.logger, level)(event, **fields)
def run(self, *, full_refresh: bool = False) -> SyncSummary:
cfg = self.config_store.get()
client = self._client()
now = self._now()
summary = SyncSummary(started_at=now)
self._log(
"info", "folder_sync.start", folders=len(cfg.folders), full=full_refresh
)
for folder in cfg.folders:
self.repo.ensure_folder(folder, now=now)
self.repo.disable_folders_except(f.media_id for f in cfg.folders)
if cfg.up_blacklist_seed:
summary.blacklist_seeded = self.repo.seed_blacklist(
cfg.up_blacklist_seed, now=now
)
for folder in cfg.folders:
try:
result = self._sync_folder(
client, cfg, folder, now=now, force_full=full_refresh
)
except Exception as exc: # noqa: BLE001 单个夹失败不阻塞其它夹
result = FolderSyncResult(
media_id=folder.media_id,
year=folder.year,
title=folder.title,
error=str(exc),
)
self._log(
"error",
"folder_sync.folder_error",
media_id=folder.media_id,
error=str(exc),
)
else:
self._log("info", "folder_sync.folder", **result.as_dict())
if result.warning:
self._log(
"warning",
"folder_sync.capacity",
media_id=folder.media_id,
warning=result.warning,
)
summary.folders.append(result)
if self.capture_followers:
try:
missing = self.repo.up_mids_missing_follower()
if missing:
capture = self.up_profile.capture(
[int(row["up_mid"]) for row in missing],
names={
int(row["up_mid"]): str(row.get("up_name") or "")
for row in missing
},
live_since=summary.started_at - 3600,
)
summary.followers_updated = capture.updated_videos
summary.followers_failed = capture.failed
except Exception as exc: # noqa: BLE001 粉丝采集失败不影响收藏夹同步
summary.followers_failed += 1
self._log(
"warning", "folder_sync.followers_failed", error=str(exc)
)
summary.finished_at = self._now()
self._log(
"info" if summary.ok else "error",
"folder_sync.done",
added=summary.added,
removed=summary.removed,
refreshed=summary.refreshed,
missing=summary.missing,
errors=len(summary.errors),
elapsed=max(0, summary.finished_at - summary.started_at),
)
if summary.added or summary.removed or summary.refreshed or summary.followers_updated:
self.repo.bump_cache_version("videos")
return summary
def _sync_folder(
self,
client,
cfg: AppConfig,
folder: FolderConfig,
*,
now: int,
force_full: bool,
) -> FolderSyncResult:
row = self.repo.get_folder(folder.media_id)
full_due = (
force_full
or row is None
or row.last_full_at is None
or now - int(row.last_full_at)
>= cfg.schedule.full_folder_refresh_hours * 3600
)
resources = client.fav_ids(folder.media_id)
remote_map = {item.aid: item for item in resources if item.type == 2}
remote = set(remote_map)
local = self.repo.folder_active_aids(folder.media_id)
added = remote - local
removed = local - remote
if not remote and local and not self.allow_empty_folders:
raise RuntimeError(
f"收藏夹 {folder.media_id} 远端返回 0 条,但本地仍有 {len(local)} 条;"
f"疑似接口异常,已跳过差集。确认收藏夹确实被清空后,"
f"可使用 --allow-empty-folder 重试"
)
result = FolderSyncResult(
media_id=folder.media_id,
year=folder.year,
title=folder.title or (row.title if row else ""),
remote_count=len(remote),
added=len(added),
removed=len(removed),
full_refresh=full_due,
)
targets = set(added)
if full_due:
targets |= remote
if targets:
infos = client.fav_infos(
folder.media_id,
sorted(targets),
folder_mid=cfg.bilibili.read.mid,
)
for aid, resource in infos.items.items():
if aid not in remote:
continue
outcome = self.repo.upsert_from_fav(
resource, folder.media_id, was_added=aid in added, now=now
)
if aid in added:
# 人工已加入收藏夹:候选池不再保留同一条视频
self.repo.delete_candidate(aid)
if outcome.moved_folder:
self._log(
"warning",
"folder_sync.multi_folder",
aid=aid,
previous_folder_id=outcome.previous_folder_id,
folder_id=folder.media_id,
)
for aid in infos.missing:
source = remote_map.get(aid)
self.repo.mark_missing_resource(
aid,
folder_id=folder.media_id,
bvid=source.bvid if source else None,
now=now,
)
result.refreshed = len(infos.items)
result.missing = len(infos.missing)
for aid in removed:
self.repo.mark_resource_removed(folder.media_id, aid, now=now)
self.repo.touch_folder_resources(folder.media_id, now=now)
self.repo.finish_folder_sync(
folder,
media_count=len(remote),
full_refresh=full_due,
now=now,
)
if len(remote) >= self.capacity_warn:
result.warning = (
f"收藏夹「{result.title or folder.media_id}」已有 {len(remote)} 条,"
f"达到预警线 {self.capacity_warn};请人工准备下一年度收藏夹并更新配置,"
f"系统不会自动换夹"
)
return result
-3
View File
@@ -1611,12 +1611,10 @@ class DashboardQueries:
now = self._now() now = self._now()
next_runs = { next_runs = {
"discover": int(now + cfg.schedule.discover_minutes * 60), "discover": int(now + cfg.schedule.discover_minutes * 60),
"folder_sync": int(now + cfg.schedule.folder_sync_minutes * 60),
"snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)), "snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)),
} }
for name, key, interval_minutes in ( for name, key, interval_minutes in (
("discover", "discovery", cfg.schedule.discover_minutes), ("discover", "discovery", cfg.schedule.discover_minutes),
("folder_sync", "folder_sync", cfg.schedule.folder_sync_minutes),
): ):
record = tasks.get(key) record = tasks.get(key)
if isinstance(record, dict) and record.get("finished_at"): if isinstance(record, dict) and record.get("finished_at"):
@@ -1660,7 +1658,6 @@ class DashboardQueries:
"preview": self._kv(conn, "preview:seeded") == "1", "preview": self._kv(conn, "preview:seeded") == "1",
"schedule": { "schedule": {
"discover_minutes": cfg.schedule.discover_minutes, "discover_minutes": cfg.schedule.discover_minutes,
"folder_sync_minutes": cfg.schedule.folder_sync_minutes,
"snapshot_at": cfg.schedule.snapshot_at, "snapshot_at": cfg.schedule.snapshot_at,
"candidate_ttl_days": cfg.schedule.candidate_ttl_days, "candidate_ttl_days": cfg.schedule.candidate_ttl_days,
}, },
@@ -30,7 +30,7 @@ function candidateCard(item) {
</div> </div>
<div class="candidate-actions"> <div class="candidate-actions">
${item.url ? `<a class="button ghost" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">打开 B 站</a>` : ""} ${item.url ? `<a class="button ghost" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">打开 B 站</a>` : ""}
<span class="readonly-note">请在 Telegram 中收藏 / 忽略 / 拉黑</span> <span class="readonly-note">请在 Telegram 中入库 / 忽略 / 拉黑</span>
</div> </div>
</div> </div>
</article>`; </article>`;
@@ -51,7 +51,7 @@ export async function renderCandidates(root) {
${data.items.length ? `<div class="notice-row"> ${data.items.length ? `<div class="notice-row">
<strong>候选处理</strong> <strong>候选处理</strong>
<span>${data.telegram_enabled ? "请在 Telegram 消息中收藏、忽略或拉黑。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span> <span>${data.telegram_enabled ? "请在 Telegram 消息中入库、忽略或拉黑。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span>
</div>` : ""} </div>` : ""}
<section class="panel"> <section class="panel">
+3 -3
View File
@@ -30,7 +30,8 @@ function heatStrip(items) {
function capacity(folders) { function capacity(folders) {
if (!folders.length) return ""; if (!folders.length) return "";
return `<section class="panel capacity-panel"> return `<section class="panel capacity-panel">
${sectionHeader("年度收藏夹")} ${sectionHeader("历史收藏夹")}
<p class="section-note">系统已不再同步收藏夹;下列统计仅保留历史来源。</p>
${folders.map((folder) => ` ${folders.map((folder) => `
<div class="capacity-row"> <div class="capacity-row">
<div class="capacity-label"> <div class="capacity-label">
@@ -39,7 +40,6 @@ function capacity(folders) {
</div> </div>
<div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%" <div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%"
class="${folder.warning ? "warning" : ""}"></span></div> class="${folder.warning ? "warning" : ""}"></span></div>
${folder.warning ? `<p class="warning-text">接近容量上限,请准备新建年度收藏夹。</p>` : ""}
</div>`).join("")} </div>`).join("")}
</section>`; </section>`;
} }
@@ -95,7 +95,7 @@ export async function renderHome(root) {
${addedNote} ${addedNote}
<div class="video-list"> <div class="video-list">
${added.items.length ? added.items.map((item) => videoCard(item)).join("") ${added.items.length ? added.items.map((item) => videoCard(item)).join("")
: emptyState("最近 7 天没有新入库视频", "收藏夹同步或搜索确认后会出现在这里。")} : emptyState("最近 7 天没有新入库视频", "搜索发现确认或 Telegram 私聊手动入库后会出现在这里。")}
</div> </div>
</section> </section>
+5 -6
View File
@@ -47,7 +47,6 @@ export async function renderSystem(root) {
<div><span>采集成功</span><strong>${formatCount(health.ok)}</strong></div> <div><span>采集成功</span><strong>${formatCount(health.ok)}</strong></div>
<div><span>采集失败</span><strong class="${health.failed ? "danger" : ""}">${formatCount(health.failed)}</strong></div> <div><span>采集失败</span><strong class="${health.failed ? "danger" : ""}">${formatCount(health.failed)}</strong></div>
<div><span>在库</span><strong>${formatCount(counts.active)}</strong></div> <div><span>在库</span><strong>${formatCount(counts.active)}</strong></div>
<div><span>已取消收藏</span><strong>${formatCount(counts.unfaved)}</strong></div>
<div><span>已失效</span><strong>${formatCount(counts.removed)}</strong></div> <div><span>已失效</span><strong>${formatCount(counts.removed)}</strong></div>
<div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div> <div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div>
</div> </div>
@@ -60,16 +59,16 @@ export async function renderSystem(root) {
<section class="panel"> <section class="panel">
${sectionHeader("预计下次运行")} ${sectionHeader("预计下次运行")}
<div class="status-grid"> <div class="status-grid">
<div><span>收藏夹同步</span><strong>${escapeHtml(formatDateTime(next.folder_sync))}</strong></div>
<div><span>搜索发现</span><strong>${escapeHtml(formatDateTime(next.discover))}</strong></div> <div><span>搜索发现</span><strong>${escapeHtml(formatDateTime(next.discover))}</strong></div>
<div><span>每日快照</span><strong>${escapeHtml(formatDateTime(next.snapshot))}</strong></div> <div><span>每日快照</span><strong>${escapeHtml(formatDateTime(next.snapshot))}</strong></div>
<div><span>调度参数</span><strong>${system.schedule.discover_minutes} / ${system.schedule.folder_sync_minutes} 分钟</strong></div> <div><span>发现周期</span><strong>${system.schedule.discover_minutes} 分钟</strong></div>
</div> </div>
<p class="section-note">预计时间按配置和最近任务完成时间推算;实际以常驻进程为准。</p> <p class="section-note">预计时间按配置和最近任务完成时间推算;实际以常驻进程为准。</p>
</section> </section>
<section class="panel"> <section class="panel">
${sectionHeader("收藏夹容量")} ${sectionHeader("历史收藏夹")}
<p class="section-note">系统已不再同步收藏夹;下列统计仅保留历史来源。收藏夹由人工维护,重排序使用 favsort.py。</p>
${(system.folders || []).map((folder) => ` ${(system.folders || []).map((folder) => `
<div class="capacity-row"> <div class="capacity-row">
<div class="capacity-label"> <div class="capacity-label">
@@ -78,8 +77,8 @@ export async function renderSystem(root) {
</div> </div>
<div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%" <div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%"
class="${folder.warning ? "warning" : ""}"></span></div> class="${folder.warning ? "warning" : ""}"></span></div>
<p class="section-note">最近同步 ${escapeHtml(formatDateTime(folder.last_ids_at))}</p> <p class="section-note">历史快照 ${escapeHtml(formatDateTime(folder.last_ids_at))}</p>
</div>`).join("") || emptyState("未配置收藏夹")} </div>`).join("") || emptyState("无历史收藏夹记录")}
</section> </section>
<section class="panel"> <section class="panel">
+352 -352
View File
@@ -1,352 +1,352 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""CLI 入口测试(离线): uv run python tests/test_cli.py""" """CLI 入口测试(离线): uv run python tests/test_cli.py"""
import contextlib import contextlib
import io import io
import json import json
import os import os
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.bilibili.models import LoginState # noqa: E402 from reaction_board.bilibili.models import LoginState # noqa: E402
from reaction_board.cli import main # noqa: E402 from reaction_board.cli import main # noqa: E402
from reaction_board.database import Database # noqa: E402 from reaction_board.database import Database # noqa: E402
from reaction_board.repository import Repository # noqa: E402 from reaction_board.repository import Repository # noqa: E402
import reaction_board.cli as cli # noqa: E402 import reaction_board.cli as cli # noqa: E402
def write_config(tmp, *, cookie="SESSDATA=x; buvid3=y", csrf="csrf"): def write_config(tmp, *, cookie="SESSDATA=x; buvid3=y", csrf="csrf"):
path = Path(tmp) / "config.json" path = Path(tmp) / "config.json"
path.write_text(json.dumps({ path.write_text(json.dumps({
"bilibili": { "bilibili": {
"read": {"mid": 1, "csrf": csrf, "cookie": cookie}, "read": {"mid": 1, "csrf": csrf, "cookie": cookie},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"}, "write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
}, },
"storage": { "storage": {
"database_path": str(Path(tmp) / "app.db"), "database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"), "backup_dir": str(Path(tmp) / "backup"),
}, },
"folders": [ "folders": [
{"year": 2025, "media_id": 11, "title": "2025"}, {"year": 2025, "media_id": 11, "title": "2025"},
{"year": 2026, "media_id": 22, "title": "2026"}, {"year": 2026, "media_id": 22, "title": "2026"},
], ],
"up_blacklist_seed": [101, 202], "up_blacklist_seed": [101, 202],
}, ensure_ascii=False), encoding="utf-8") }, ensure_ascii=False), encoding="utf-8")
return path return path
def run_cli(argv): def run_cli(argv):
out = io.StringIO() out = io.StringIO()
with contextlib.redirect_stdout(out): with contextlib.redirect_stdout(out):
code = main(argv) code = main(argv)
return code, out.getvalue() return code, out.getvalue()
def test_init_and_status(): def test_init_and_status():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp) path = write_config(tmp)
code, output = run_cli(["init", "--config", str(path)]) code, output = run_cli(["init", "--config", str(path)])
assert code == 0 assert code == 0
assert '"schema_version": 4' in output assert '"schema_version": 5' in output
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
try: try:
repo = Repository(db) repo = Repository(db)
assert len(repo.list_folders()) == 2 assert len(repo.list_folders()) == 2
assert [r["mid"] for r in repo.blacklist()] == [101, 202] assert [r["mid"] for r in repo.blacklist()] == [101, 202]
assert repo.counts()["video_total"] == 0 assert repo.counts()["video_total"] == 0
finally: finally:
db.close() db.close()
code, output = run_cli(["status", "--config", str(path)]) code, output = run_cli(["status", "--config", str(path)])
assert code == 0 assert code == 0
assert '"configured_folders"' in output assert '"configured_folders"' in output
assert '"media_count": null' in output assert '"media_count": null' in output
assert '"video_total": 0' in output assert '"video_total": 0' in output
def test_missing_config(): def test_missing_config():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
code, output = run_cli(["init", "--config", str(Path(tmp) / "nope.json")]) code, output = run_cli(["init", "--config", str(Path(tmp) / "nope.json")])
assert code == 2 assert code == 2
payload = json.loads(output.strip().splitlines()[-1]) payload = json.loads(output.strip().splitlines()[-1])
assert payload["level"] == "error" assert payload["level"] == "error"
assert "缺少配置文件" in payload["error"] assert "缺少配置文件" in payload["error"]
def test_sync_requires_auth_without_network(): def test_ingest_without_refs_is_offline():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="") path = write_config(tmp, cookie="", csrf="")
code, output = run_cli(["sync", "--config", str(path)]) code, output = run_cli(["ingest", "--config", str(path), "没有视频"])
assert code == 2 assert code == 1
payload = json.loads(output.strip().splitlines()[-1]) data = json.loads(output[output.rindex("{\n"):])
assert "cookie" in payload["error"] assert data["refs"] == 0
assert not (Path(tmp) / "app.db").exists() assert data["results"] == []
def test_snapshot_offline_empty_db(): def test_snapshot_offline_empty_db():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="") path = write_config(tmp, cookie="", csrf="")
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
data["folders"] = [] data["folders"] = []
data["track_fans"] = [] data["track_fans"] = []
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
code, output = run_cli(["snapshot", "--config", str(path)]) code, output = run_cli(["snapshot", "--config", str(path)])
assert code == 0 assert code == 0
assert '"completed": true' in output assert '"completed": true' in output
assert '"total": 0' in output assert '"total": 0' in output
assert len(list((Path(tmp) / "backup").glob("app-*.db"))) == 1 assert len(list((Path(tmp) / "backup").glob("app-*.db"))) == 1
def test_auth_check_without_cookie(): def test_auth_check_without_cookie():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="") path = write_config(tmp, cookie="", csrf="")
code, output = run_cli(["auth-check", "--config", str(path)]) code, output = run_cli(["auth-check", "--config", str(path)])
assert code == 1 assert code == 1
payload = json.loads(output) payload = json.loads(output)
assert payload["is_login"] is False assert payload["is_login"] is False
assert "未配置 cookie" in payload["message"] assert "未配置 cookie" in payload["message"]
def test_auth_check_all_and_auth_dispatch(): def test_auth_check_all_and_auth_dispatch():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="") path = write_config(tmp, cookie="", csrf="")
code, output = run_cli(["auth-check", "--account", "all", "--config", str(path)]) code, output = run_cli(["auth-check", "--account", "all", "--config", str(path)])
assert code == 1 assert code == 1
payload = json.loads(output) payload = json.loads(output)
assert payload["ok"] is False assert payload["ok"] is False
assert [item["account"] for item in payload["accounts"]] == ["read", "write"] assert [item["account"] for item in payload["accounts"]] == ["read", "write"]
original = cli.AuthService original = cli.AuthService
calls = [] calls = []
class FakeAuthService: class FakeAuthService:
def __init__(self, path, *, account, logger): def __init__(self, path, *, account, logger):
calls.append((str(path), account)) calls.append((str(path), account))
def run(self, *, timeout, no_browser): def run(self, *, timeout, no_browser):
assert timeout == 9 and no_browser is True assert timeout == 9 and no_browser is True
return 7 return 7
cli.AuthService = FakeAuthService cli.AuthService = FakeAuthService
try: try:
code, _ = run_cli([ code, _ = run_cli([
"auth", "--account", "write", "--timeout", "9", "--no-browser", "--config", str(path), "auth", "--account", "write", "--timeout", "9", "--no-browser", "--config", str(path),
]) ])
finally: finally:
cli.AuthService = original cli.AuthService = original
assert code == 7 assert code == 7
assert calls == [(str(path), "write")] assert calls == [(str(path), "write")]
def test_run_once_offline(): def test_run_once_offline():
class FakeClient: class FakeClient:
def check_login(self): def check_login(self):
return LoginState(True, 1, "tester", 0, "OK") return LoginState(True, 1, "tester", 0, "OK")
class FakeDiscovery: class FakeDiscovery:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
pass pass
def run(self): def run(self):
from reaction_board.services.discovery import DiscoverySummary from reaction_board.services.discovery import DiscoverySummary
return DiscoverySummary(started_at=1, finished_at=1) return DiscoverySummary(started_at=1, finished_at=1)
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp) path = write_config(tmp)
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
data["folders"] = [] data["folders"] = []
data["track_fans"] = [] data["track_fans"] = []
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
original = cli._client original = cli._client
original_discovery = cli.DiscoveryService original_discovery = cli.DiscoveryService
selected = [] selected = []
def fake_client(credentials, *args, **kwargs): def fake_client(credentials, *args, **kwargs):
selected.append(credentials) selected.append(credentials)
return FakeClient() return FakeClient()
cli._client = fake_client cli._client = fake_client
cli.DiscoveryService = FakeDiscovery cli.DiscoveryService = FakeDiscovery
try: try:
code, output = run_cli(["run", "--once", "--config", str(path)]) code, output = run_cli(["run", "--once", "--config", str(path)])
finally: finally:
cli._client = original cli._client = original
cli.DiscoveryService = original_discovery cli.DiscoveryService = original_discovery
assert code == 0 assert code == 0
assert '"name": "folder_sync"' in output assert '"name": "snapshot"' in output
assert '"name": "snapshot"' in output assert '"name": "folder_sync"' not in output
assert selected and all(credentials.mid == 1 for credentials in selected) assert selected and all(credentials.mid == 1 for credentials in selected)
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
try: try:
repo = Repository(db) repo = Repository(db)
for name in ("folder_sync", "snapshot", "discovery", "candidate_cleanup"): for name in ("snapshot", "discovery", "candidate_cleanup"):
assert repo.kv_get(f"task:last:{name}"), name assert repo.kv_get(f"task:last:{name}"), name
finally: finally:
db.close() db.close()
def test_serve_preview_guard(): def test_serve_preview_guard():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp) path = write_config(tmp)
assert run_cli(["init", "--config", str(path)])[0] == 0 assert run_cli(["init", "--config", str(path)])[0] == 0
db_path = Path(tmp) / "app.db" db_path = Path(tmp) / "app.db"
try: try:
cli._assert_preview_database(db_path) cli._assert_preview_database(db_path)
except cli.ConfigError as exc: except cli.ConfigError as exc:
assert "preview:seeded" in str(exc) assert "preview:seeded" in str(exc)
else: else:
raise AssertionError("普通数据库不应通过 --preview 校验") raise AssertionError("普通数据库不应通过 --preview 校验")
db = Database(db_path) db = Database(db_path)
db.initialize() db.initialize()
try: try:
Repository(db).kv_set("preview:seeded", "1") Repository(db).kv_set("preview:seeded", "1")
finally: finally:
db.close() db.close()
cli._assert_preview_database(db_path) cli._assert_preview_database(db_path)
try: try:
cli._assert_preview_database(Path(tmp) / "missing.db") cli._assert_preview_database(Path(tmp) / "missing.db")
except cli.ConfigError as exc: except cli.ConfigError as exc:
assert "不存在" in str(exc) assert "不存在" in str(exc)
else: else:
raise AssertionError("缺失数据库应报错") raise AssertionError("缺失数据库应报错")
def test_default_command_starts_web_and_scheduler(): def test_default_command_starts_web_and_scheduler():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp) path = write_config(tmp)
calls = [] calls = []
original = cli.cmd_run original = cli.cmd_run
def fake_run(args, logger, *, serve_web=False): def fake_run(args, logger, *, serve_web=False):
calls.append((args.command, serve_web, args.once, args.allow_empty_folder)) calls.append((args.command, serve_web, args.once))
return 0 return 0
cli.cmd_run = fake_run cli.cmd_run = fake_run
try: try:
code = main(["--config", str(path)]) code = main(["--config", str(path)])
finally: finally:
cli.cmd_run = original cli.cmd_run = original
assert code == 0 assert code == 0
assert calls == [("run", True, False, False)] assert calls == [("run", True, False)]
def test_snapshot_backup_failure_returns_error_and_retries(): def test_snapshot_backup_failure_returns_error_and_retries():
import reaction_board.services.snapshot as snapshot_module import reaction_board.services.snapshot as snapshot_module
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="") path = write_config(tmp, cookie="", csrf="")
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
data["folders"] = [] data["folders"] = []
data["track_fans"] = [] data["track_fans"] = []
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
original = snapshot_module.backup_daily original = snapshot_module.backup_daily
def fail_backup(*args, **kwargs): def fail_backup(*args, **kwargs):
raise OSError("disk full") raise OSError("disk full")
snapshot_module.backup_daily = fail_backup snapshot_module.backup_daily = fail_backup
try: try:
code, output = run_cli(["snapshot", "--config", str(path)]) code, output = run_cli(["snapshot", "--config", str(path)])
finally: finally:
snapshot_module.backup_daily = original snapshot_module.backup_daily = original
assert code == 1 assert code == 1
marker = output.find("{\n") marker = output.find("{\n")
assert marker >= 0, output assert marker >= 0, output
payload = json.loads(output[marker:]) payload = json.loads(output[marker:])
assert payload["completed"] is True assert payload["completed"] is True
assert "disk full" in payload["backup_error"] assert "disk full" in payload["backup_error"]
code, output = run_cli(["snapshot", "--config", str(path)]) code, output = run_cli(["snapshot", "--config", str(path)])
assert code == 0 assert code == 0
marker = output.find("{\n") marker = output.find("{\n")
payload = json.loads(output[marker:]) payload = json.loads(output[marker:])
assert payload["backup_error"] is None assert payload["backup_error"] is None
assert Path(payload["backup_path"]).exists() assert Path(payload["backup_path"]).exists()
def test_candidates_and_discover_commands(): def test_candidates_and_discover_commands():
from reaction_board.services.discovery import DiscoverySummary from reaction_board.services.discovery import DiscoverySummary
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp) path = write_config(tmp)
assert run_cli(["init", "--config", str(path)])[0] == 0 assert run_cli(["init", "--config", str(path)])[0] == 0
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
try: try:
repo = Repository(db) repo = Repository(db)
repo.upsert_candidate( repo.upsert_candidate(
aid=123, bvid="BV123", title="咻咻满 reaction", aid=123, bvid="BV123", title="咻咻满 reaction",
up_mid=100, up_name="UP", pubdate=1, up_mid=100, up_name="UP", pubdate=1,
cover=None, score=3, channel="search:test#p1", cover=None, score=3, channel="search:test#p1",
) )
finally: finally:
db.close() db.close()
code, output = run_cli(["candidates", "--config", str(path)]) code, output = run_cli(["candidates", "--config", str(path)])
assert code == 0 assert code == 0
assert '"count": 1' in output assert '"count": 1' in output
assert '"aid": 123' in output assert '"aid": 123' in output
class FakeDiscovery: class FakeDiscovery:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
pass pass
def run(self): def run(self):
return DiscoverySummary( return DiscoverySummary(
started_at=1, finished_at=2, candidates=3, notified=1 started_at=1, finished_at=2, candidates=3, notified=1
) )
original = cli.DiscoveryService original = cli.DiscoveryService
cli.DiscoveryService = FakeDiscovery cli.DiscoveryService = FakeDiscovery
try: try:
code, output = run_cli(["discover", "--config", str(path)]) code, output = run_cli(["discover", "--config", str(path)])
finally: finally:
cli.DiscoveryService = original cli.DiscoveryService = original
assert code == 0 assert code == 0
assert '"candidates": 3' in output assert '"candidates": 3' in output
assert '"notified": 1' in output assert '"notified": 1' in output
def test_backfill_followers_dry_run(): def test_backfill_followers_dry_run():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="") path = write_config(tmp, cookie="", csrf="")
code, output = run_cli([ code, output = run_cli([
"backfill-followers", "--dry-run", "--config", str(path), "backfill-followers", "--dry-run", "--config", str(path),
]) ])
assert code == 0 assert code == 0
assert '"requested": 0' in output assert '"requested": 0' in output
assert '"failed": 0' in output assert '"failed": 0' in output
assert '"dry_run": true' in output assert '"dry_run": true' in output
assert '"total": 0' in output assert '"total": 0' in output
def main_test(): def main_test():
test_init_and_status() test_init_and_status()
test_missing_config() test_missing_config()
test_sync_requires_auth_without_network() test_ingest_without_refs_is_offline()
test_snapshot_offline_empty_db() test_snapshot_offline_empty_db()
test_auth_check_without_cookie() test_auth_check_without_cookie()
test_auth_check_all_and_auth_dispatch() test_auth_check_all_and_auth_dispatch()
test_run_once_offline() test_run_once_offline()
test_serve_preview_guard() test_serve_preview_guard()
test_default_command_starts_web_and_scheduler() test_default_command_starts_web_and_scheduler()
test_snapshot_backup_failure_returns_error_and_retries() test_snapshot_backup_failure_returns_error_and_retries()
test_candidates_and_discover_commands() test_candidates_and_discover_commands()
test_backfill_followers_dry_run() test_backfill_followers_dry_run()
print("OK: CLI init/sync/snapshot/auth-check/run/status/backfill/错误路径测试全部通过") print("OK: CLI init/sync/snapshot/auth-check/run/status/backfill/错误路径测试全部通过")
if __name__ == "__main__": if __name__ == "__main__":
main_test() main_test()
+142 -142
View File
@@ -1,142 +1,142 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""数据库单写队列测试(不联网): uv run python tests/test_database.py""" """数据库单写队列测试(不联网): uv run python tests/test_database.py"""
import os import os
import sqlite3 import sqlite3
import sys import sys
import tempfile import tempfile
import threading import threading
from pathlib import Path from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.database import Database, DatabaseClosed # noqa: E402 from reaction_board.database import Database, DatabaseClosed # noqa: E402
def make_db(tmp): def make_db(tmp):
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup") db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize() db.initialize()
return db return db
def test_write_read_transaction(): def test_write_read_transaction():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db = make_db(tmp) db = make_db(tmp)
try: try:
assert db.schema_version == 4 assert db.schema_version == 5
db.write(lambda c: c.execute("INSERT INTO kv(k, v) VALUES ('a', '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" assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1"
def boom(conn): def boom(conn):
conn.execute("UPDATE kv SET v='2' WHERE k='a'") conn.execute("UPDATE kv SET v='2' WHERE k='a'")
raise RuntimeError("boom") raise RuntimeError("boom")
try: try:
db.transaction(boom) db.transaction(boom)
except RuntimeError as exc: except RuntimeError as exc:
assert str(exc) == "boom" assert str(exc) == "boom"
else: else:
raise AssertionError("事务应抛错") raise AssertionError("事务应抛错")
assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1" assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1"
finally: finally:
db.close() db.close()
def test_concurrent_writes_are_serialized(): def test_concurrent_writes_are_serialized():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db = make_db(tmp) db = make_db(tmp)
try: try:
db.write(lambda c: c.execute( db.write(lambda c: c.execute(
"CREATE TABLE counter (id INTEGER PRIMARY KEY, n INTEGER NOT NULL)" "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)")) db.write(lambda c: c.execute("INSERT INTO counter(id, n) VALUES (1, 0)"))
errors = [] errors = []
def worker(): def worker():
try: try:
for _ in range(10): for _ in range(10):
db.transaction( db.transaction(
lambda conn: conn.execute( lambda conn: conn.execute(
"UPDATE counter SET n = n + 1 WHERE id = 1" "UPDATE counter SET n = n + 1 WHERE id = 1"
) )
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
errors.append(exc) errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(8)] threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads: for t in threads:
t.start() t.start()
for t in threads: for t in threads:
t.join() t.join()
assert not errors, errors assert not errors, errors
assert db.read(lambda c: c.execute("SELECT n FROM counter").fetchone()[0]) == 80 assert db.read(lambda c: c.execute("SELECT n FROM counter").fetchone()[0]) == 80
finally: finally:
db.close() db.close()
def test_close_and_reinitialize(): def test_close_and_reinitialize():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db = make_db(tmp) db = make_db(tmp)
db.close() db.close()
db.close() # 幂等 db.close() # 幂等
try: try:
db.write(lambda c: None) db.write(lambda c: None)
except DatabaseClosed: except DatabaseClosed:
pass pass
else: else:
raise AssertionError("关闭后写入应失败") raise AssertionError("关闭后写入应失败")
try: try:
db.read(lambda c: None) db.read(lambda c: None)
except DatabaseClosed: except DatabaseClosed:
pass pass
else: else:
raise AssertionError("关闭后读取应失败") raise AssertionError("关闭后读取应失败")
db2 = make_db(tmp) db2 = make_db(tmp)
try: try:
assert db2.schema_version == 4 assert db2.schema_version == 5
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 4 assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 5
finally: finally:
db2.close() db2.close()
def test_wal_enabled(): def test_wal_enabled():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db = make_db(tmp) db = make_db(tmp)
try: try:
mode = db.read(lambda c: c.execute("PRAGMA journal_mode").fetchone()[0]) mode = db.read(lambda c: c.execute("PRAGMA journal_mode").fetchone()[0])
assert str(mode).lower() == "wal" assert str(mode).lower() == "wal"
assert db.read(lambda c: c.execute("PRAGMA foreign_keys").fetchone()[0]) == 1 assert db.read(lambda c: c.execute("PRAGMA foreign_keys").fetchone()[0]) == 1
finally: finally:
db.close() db.close()
def test_migration_backup_on_existing_db(): def test_migration_backup_on_existing_db():
"""已有数据升级时由 Database 自动备份。""" """已有数据升级时由 Database 自动备份。"""
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "app.db" path = Path(tmp) / "app.db"
# 先建一个旧库(只有 schema_version v0),Database.initialize 会升级并备份 # 先建一个旧库(只有 schema_version v0),Database.initialize 会升级并备份
conn = sqlite3.connect(path) conn = sqlite3.connect(path)
conn.execute("CREATE TABLE legacy (x INTEGER)") conn.execute("CREATE TABLE legacy (x INTEGER)")
conn.commit() conn.commit()
conn.close() conn.close()
db = Database(path, backup_dir=Path(tmp) / "backup") db = Database(path, backup_dir=Path(tmp) / "backup")
try: try:
applied = db.initialize() applied = db.initialize()
assert applied == [1, 2, 3, 4] assert applied == [1, 2, 3, 4, 5]
assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1 assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1
finally: finally:
db.close() db.close()
def main(): def main():
test_write_read_transaction() test_write_read_transaction()
test_concurrent_writes_are_serialized() test_concurrent_writes_are_serialized()
test_close_and_reinitialize() test_close_and_reinitialize()
test_wal_enabled() test_wal_enabled()
test_migration_backup_on_existing_db() test_migration_backup_on_existing_db()
print("OK: 数据库单写队列与迁移入口测试全部通过") print("OK: 数据库单写队列与迁移入口测试全部通过")
if __name__ == "__main__": if __name__ == "__main__":
main() main()
-80
View File
@@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
"""新视频插入位置纯函数测试: uv run python tests/test_fav_position.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")
from reaction_board.fav_position import VideoMeta, plan_insertion # noqa: E402
def meta(up_mid, pubdate):
return VideoMeta(up_mid=up_mid, pubdate=pubdate)
def test_empty_and_single():
assert plan_insertion([], 9, {}, meta(100, 200)).moves == ()
assert plan_insertion([], 9, {}, meta(100, 200)).target_position == 1
plan = plan_insertion([1], 9, {1: meta(200, 50)}, meta(100, 200))
assert plan.target_position == 1
assert plan.moves == ((1, 9), (9, 1))
def test_same_up_group():
current = [1, 2, 3, 4]
metadata = {
1: meta(100, 100),
2: meta(100, 90),
3: meta(200, 80),
4: meta(200, 70),
}
# 同 UP 组在中间:插到组头
plan = plan_insertion(current, 9, metadata, meta(200, 95))
assert plan.target_position == 3
assert plan.moves == ((2, 9),)
# 同 UP 组在首位:两次移动
plan = plan_insertion(current, 9, metadata, meta(100, 200))
assert plan.target_position == 1
assert plan.moves == ((1, 9), (9, 1))
def test_new_group_by_earliest_pubdate():
current = [1, 2, 3, 4]
metadata = {
1: meta(100, 100),
2: meta(100, 90),
3: meta(200, 80),
4: meta(200, 70),
}
# 新 UP 的组键 85,应插到组键 70 的组之前
plan = plan_insertion(current, 9, metadata, meta(300, 85))
assert plan.target_position == 3
assert plan.moves == ((2, 9),)
# 比所有组都旧:追加到末尾
plan = plan_insertion(current, 9, metadata, meta(300, 60))
assert plan.target_position == 5
assert plan.moves == ((4, 9),)
def test_excludes_new_aid_if_present():
current = [1, 9, 2]
metadata = {1: meta(100, 100), 2: meta(100, 90)}
plan = plan_insertion(current, 9, metadata, meta(100, 95))
assert plan.target_position == 1
assert plan.moves == ((1, 9), (9, 1))
def main():
test_empty_and_single()
test_same_up_group()
test_new_group_by_earliest_pubdate()
test_excludes_new_aid_if_present()
print("OK: 新视频插入位置计算测试全部通过")
if __name__ == "__main__":
main()
-211
View File
@@ -1,211 +0,0 @@
# -*- coding: utf-8 -*-
"""收藏/忽略/拉黑动作测试(假写客户端,不联网): uv run python tests/test_favorites.py"""
import json
import os
import sys
import tempfile
from dataclasses import dataclass
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.client import BilibiliAPIError # noqa: E402
from reaction_board.bilibili.models import FavResource # noqa: E402
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.favorites import FavoriteActionService # noqa: E402
NOW = 1_793_444_393
FOLDER_ID = 3700023351
@dataclass
class FakeFavId:
aid: int
type: int = 2
class FakeWriteClient:
def __init__(self, order, *, fail_deal=False):
self.order = list(order)
self.fail_deal = fail_deal
self.dealt = []
self.sorts = []
def fav_ids(self, media_id):
return [FakeFavId(aid) for aid in self.order]
def deal(self, aid, folder_id):
if self.fail_deal:
raise BilibiliAPIError(-1, "收藏夹已满")
self.dealt.append((aid, folder_id))
self.order.append(aid)
def sort(self, media_id, spec):
predecessor, _, moved, _ = (int(part) for part in spec.split(":"))
self.sorts.append(spec)
self.order.remove(moved)
index = self.order.index(predecessor)
self.order.insert(index + 1, moved)
class FakeProvider:
def __init__(self, client):
self.client = client
def get(self):
return self.client
def write_config(tmp):
data = {
"bilibili": {
"read": {"mid": 1, "csrf": "r", "cookie": "read-cookie"},
"write": {"mid": 2, "csrf": "w", "cookie": "write-cookie"},
},
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),
},
"folders": [{"year": 2026, "media_id": FOLDER_ID, "title": "2026"}],
"discover": {"keywords": ["x"], "pages": 1, "window_days": 14},
}
path = Path(tmp) / "config.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return path
def add_video(repo, aid, up_mid, pubdate):
repo.upsert_from_fav(
FavResource(
aid=aid, bvid=f"BV{aid}", title=f"视频{aid}", up_mid=up_mid,
up_name=f"UP{up_mid}", pubdate=pubdate, duration=600,
cover=None, attr=0, fav_time=None,
),
FOLDER_ID,
was_added=True,
)
def add_candidate(repo, aid, up_mid=100, pubdate=NOW - 3600):
return repo.upsert_candidate(
aid=aid,
bvid=f"BV{aid}",
title=f"咻咻满 reaction {aid}",
up_mid=up_mid,
up_name=f"UP{up_mid}",
pubdate=pubdate,
cover="https://example/cover.jpg",
score=3,
channel="search:test#p1",
now=NOW,
)
def setup(tmp, order, *, fail_deal=False):
path = write_config(tmp)
store = ConfigStore(path, env={})
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: NOW)
add_video(repo, 1, 100, 1_792_000_000)
add_video(repo, 2, 200, 1_791_000_000)
add_candidate(repo, 9, up_mid=100, pubdate=1_793_000_000)
client = FakeWriteClient(order, fail_deal=fail_deal)
service = FavoriteActionService(
store, repo, FakeProvider(client), clock=lambda: NOW
)
return db, repo, client, service
def test_favorite_inserts_and_positions():
with tempfile.TemporaryDirectory() as tmp:
db, repo, client, service = setup(tmp, [1, 2])
try:
result = service.favorite(9)
assert result.ok is True
assert result.position == 1
assert result.already_in_folder is False
assert client.dealt == [(9, FOLDER_ID)]
assert client.sorts == ["1:2:9:2", "9:2:1:2"]
assert client.order == [9, 1, 2]
assert repo.get_candidate(9) is None
video = repo.get_video(9)
assert video["status"] == "active"
assert video["folder_id"] == FOLDER_ID
action = repo.get_fav_action("fav:9")
assert action["state"] == "done"
assert action["position"] == 1
finally:
db.close()
def test_favorite_already_in_folder_skips_writes():
with tempfile.TemporaryDirectory() as tmp:
db, repo, client, service = setup(tmp, [1, 9, 2])
try:
result = service.favorite(9)
assert result.ok is True
assert result.already_in_folder is True
assert result.position == 2
assert client.dealt == []
assert client.sorts == []
assert repo.get_candidate(9) is None
finally:
db.close()
def test_favorite_failure_keeps_candidate_and_allows_retry():
with tempfile.TemporaryDirectory() as tmp:
db, repo, client, service = setup(tmp, [1, 2], fail_deal=True)
try:
result = service.favorite(9)
assert result.ok is False
assert "收藏夹已满" in result.message
assert repo.get_candidate(9) is not None
action = repo.get_fav_action("fav:9")
assert action["state"] == "failed"
assert "收藏夹已满" in action["err"]
client.fail_deal = False
retry = service.favorite(9)
assert retry.ok is True
assert retry.position == 1
assert repo.get_candidate(9) is None
finally:
db.close()
def test_ignore_and_blacklist():
with tempfile.TemporaryDirectory() as tmp:
db, repo, client, service = setup(tmp, [1, 2])
try:
add_candidate(repo, 11, up_mid=300)
add_candidate(repo, 12, up_mid=300)
ignored = service.ignore(11)
assert ignored.ok is True
assert repo.get_candidate(11)["decision"] == "rejected"
result = service.blacklist(12)
assert result.ok is True
assert repo.is_blacklisted(300)
assert repo.get_candidate(12)["decision"] == "rejected"
# 已有视频不受拉黑影响
assert repo.get_video(1)["status"] == "active"
finally:
db.close()
def main():
test_favorite_inserts_and_positions()
test_favorite_already_in_folder_skips_writes()
test_favorite_failure_keeps_candidate_and_allows_retry()
test_ignore_and_blacklist()
print("OK: 收藏、忽略、拉黑与失败重试测试全部通过")
if __name__ == "__main__":
main()
-373
View File
@@ -1,373 +0,0 @@
# -*- 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, omit_ids_data=None):
self.folders = {k: list(v) for k, v in folders.items()}
self.infos = dict(infos)
self.fail_media = fail_media
self.omit_ids_data = omit_ids_data
self.ids_calls = []
self.info_calls = []
self.relation_calls = []
def request(self, method, url, headers, data=None, timeout=30):
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
if "/x/relation/stat" in url:
mid = int(query["vmid"][0])
self.relation_calls.append(mid)
return http_json({
"code": 0, "message": "0", "ttl": 1,
"data": {"mid": mid, "following": 5, "follower": 1000 + mid},
})
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 网络错误")
if self.omit_ids_data == media_id:
return http_json({"code": 0, "message": "OK", "ttl": 1})
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 = {
"bilibili": {
"read": {"mid": 28802351, "csrf": "csrf-secret", "cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device"},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
},
"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:
repo.upsert_candidate(
aid=1101, bvid="BV1101", title="咻咻满 reaction",
up_mid=100, up_name="UP", pubdate=1_799_000_000,
cover=None, score=3, channel="search:test#p1",
now=1_799_999_000,
)
summary = service.run()
assert summary.ok
assert summary.added == 4
assert summary.missing == 1
assert summary.refreshed == 3
# 人工加入收藏夹后,同 aid 候选自动移除
assert repo.get_candidate(1101) is None
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)
# 新入库视频记录入库时粉丝数;失效视频不再采集
assert summary.followers_updated == 2
assert summary.followers_failed == 0
assert repo.get_video(1101)["up_follower"] == 1100
assert repo.get_video(1101)["up_follower_backfilled"] == 0
assert repo.get_video(1102)["up_follower"] == 1999
assert sorted(transport.relation_calls) == [100, 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)
relation_before = len(transport.relation_calls)
summary2 = service.run()
assert summary2.added == 0
assert summary2.refreshed == 0
assert len(transport.info_calls) == info_before
assert len(transport.relation_calls) == relation_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 test_empty_remote_requires_explicit_opt_in():
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()
transport.folders[F1] = []
clock[0] += 3600
summary = service.run()
assert summary.ok is False
assert "疑似接口异常" in summary.folders[0].error
assert repo.get_video(1101)["status"] == "active"
assert repo.folder_active_aids(F1) == {1101, 1102}
service.allow_empty_folders = True
summary = service.run()
assert summary.ok is True
assert repo.get_video(1101)["status"] == "unfaved"
assert repo.folder_active_aids(F1) == set()
finally:
db.close()
def test_missing_ids_data_aborts_folder():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101], F2: []},
{1101: valid(1101)},
omit_ids_data=F1,
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
summary = service.run()
assert summary.ok is False
assert "data" in summary.folders[0].error
assert repo.get_video(1101) is None
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()
test_empty_remote_requires_explicit_opt_in()
test_missing_ids_data_aborts_folder()
print("OK: 收藏夹初始导入、差集同步、失效识别与容量预警测试全部通过")
if __name__ == "__main__":
main()
+30 -7
View File
@@ -37,8 +37,8 @@ def test_fresh_and_idempotent():
conn = sqlite3.connect(Path(tmp) / "app.db") conn = sqlite3.connect(Path(tmp) / "app.db")
try: try:
applied = run_migrations(conn) applied = run_migrations(conn)
assert applied == [1, 2, 3, 4] assert applied == [1, 2, 3, 4, 5]
assert current_version(conn) == 4 assert current_version(conn) == 5
assert EXPECTED_TABLES <= table_names(conn) assert EXPECTED_TABLES <= table_names(conn)
assert "schema_version" in table_names(conn) assert "schema_version" in table_names(conn)
columns = { columns = {
@@ -66,7 +66,7 @@ def test_fresh_and_idempotent():
# 再跑一次不应重复应用 # 再跑一次不应重复应用
assert run_migrations(conn) == [] assert run_migrations(conn) == []
row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone() row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()
assert row[0] == 4 assert row[0] == 5
finally: finally:
conn.close() conn.close()
@@ -81,15 +81,15 @@ def test_backup_before_upgrade():
conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')") conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')")
conn.commit() conn.commit()
v5 = Migration(5, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",)) v6 = Migration(6, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
applied = run_migrations( applied = run_migrations(
conn, conn,
backup_dir=backup_dir, backup_dir=backup_dir,
now=1_800_000_000, now=1_800_000_000,
migrations=MIGRATIONS + (v5,), migrations=MIGRATIONS + (v6,),
) )
assert applied == [5] assert applied == [6]
backups = list(backup_dir.glob("app-pre-v4-*.db")) backups = list(backup_dir.glob("app-pre-v5-*.db"))
assert len(backups) == 1 assert len(backups) == 1
check = sqlite3.connect(backups[0]) check = sqlite3.connect(backups[0])
try: try:
@@ -159,12 +159,35 @@ def test_validate_migration_order():
conn.close() conn.close()
def test_v5_normalizes_unfaved():
with tempfile.TemporaryDirectory() as tmp:
conn = sqlite3.connect(Path(tmp) / "app.db")
try:
run_migrations(conn, migrations=MIGRATIONS[:4])
conn.execute(
"INSERT INTO video(aid, title, pubdate, status, first_seen) "
"VALUES (1, 't', 1, 'unfaved', 1)"
)
conn.execute(
"INSERT INTO video(aid, title, pubdate, status, first_seen) "
"VALUES (2, 't', 1, 'dropped', 1)"
)
conn.commit()
applied = run_migrations(conn)
assert applied == [5]
rows = dict(conn.execute("SELECT aid, status FROM video").fetchall())
assert rows == {1: "active", 2: "dropped"}
finally:
conn.close()
def main(): def main():
test_fresh_and_idempotent() test_fresh_and_idempotent()
test_backup_before_upgrade() test_backup_before_upgrade()
test_failed_migration_rolls_back() test_failed_migration_rolls_back()
test_prune_backups() test_prune_backups()
test_validate_migration_order() test_validate_migration_order()
test_v5_normalizes_unfaved()
print("OK: SQLite 迁移、备份与回滚测试全部通过") print("OK: SQLite 迁移、备份与回滚测试全部通过")
+491 -492
View File
@@ -1,492 +1,491 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""只读查询层测试(离线): uv run python tests/test_web_queries.py""" """只读查询层测试(离线): uv run python tests/test_web_queries.py"""
import os import os
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.config import ConfigStore # noqa: E402 from reaction_board.config import ConfigStore # noqa: E402
from reaction_board.database import Database # noqa: E402 from reaction_board.database import Database # noqa: E402
from reaction_board.repository import Repository # noqa: E402 from reaction_board.repository import Repository # noqa: E402
from reaction_board.timeutil import day_bounds # noqa: E402 from reaction_board.timeutil import day_bounds # noqa: E402
from reaction_board.web.cache import DASHBOARD_CACHE # noqa: E402 from reaction_board.web.cache import DASHBOARD_CACHE # noqa: E402
from reaction_board.web.queries import DashboardQueries # noqa: E402 from reaction_board.web.queries import DashboardQueries # noqa: E402
from tests.web_fixtures import DATES, build_db, write_config # noqa: E402 from tests.web_fixtures import DATES, build_db, write_config # noqa: E402
FIXED_NOW = 1_793_459_000 # 2026-10-31 前后 FIXED_NOW = 1_793_459_000 # 2026-10-31 前后
def setup(tmp: str): def setup(tmp: str):
build_db(tmp) build_db(tmp)
config = write_config(tmp) config = write_config(tmp)
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
store = ConfigStore(config) store = ConfigStore(config)
queries = DashboardQueries(db, store, clock=lambda: FIXED_NOW) queries = DashboardQueries(db, store, clock=lambda: FIXED_NOW)
return db, queries return db, queries
def test_overview(): def test_overview():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
overview = q.overview() overview = q.overview()
assert overview["data_date"] == DATES[-1] assert overview["data_date"] == DATES[-1]
assert overview["snapshot_days"] == 3 assert overview["snapshot_days"] == 3
assert overview["is_first_day"] is False assert overview["is_first_day"] is False
assert overview["initial_import_count"] == 2 assert overview["initial_import_count"] == 2
assert overview["counts"]["active"] == 2 assert overview["counts"]["active"] == 2
assert overview["counts"]["unfaved"] == 1 assert overview["counts"]["unfaved"] == 1
assert overview["counts"]["removed"] == 1 assert overview["counts"]["removed"] == 1
assert overview["counts"]["active_ups"] == 2 assert overview["counts"]["active_ups"] == 2
assert overview["counts"]["candidates_pending"] == 1 assert overview["counts"]["candidates_pending"] == 1
assert overview["totals"]["view"] == 1200 + 5200 + 120 assert overview["totals"]["view"] == 1200 + 5200 + 120
assert overview["interactions"] > 0 assert overview["interactions"] > 0
assert overview["view_delta"] == (100 + 100 + 0) # 101/102 的日增量 assert overview["view_delta"] == (100 + 100 + 0) # 101/102 的日增量
assert overview["snapshot_health"]["total"] == 3 assert overview["snapshot_health"]["total"] == 3
assert overview["folders"][0]["media_count"] == 3 assert overview["folders"][0]["media_count"] == 3
assert overview["folders"][0]["ratio"] < 1 assert overview["folders"][0]["ratio"] < 1
assert overview["fans"]["follower"] == 1020 assert overview["fans"]["follower"] == 1020
assert overview["fans"]["delta_1d"] == 10 assert overview["fans"]["delta_1d"] == 10
assert overview["preview"] is False assert overview["preview"] is False
assert overview["years"] == [2026] assert overview["years"] == [2026]
assert "snapshot" in overview["tasks"] assert "snapshot" in overview["tasks"]
finally: finally:
db.close() db.close()
def test_video_listing_filters_and_sorting(): def test_video_listing_filters_and_sorting():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
heat = q.list_videos(sort="heat") heat = q.list_videos(sort="heat")
aids = [item["aid"] for item in heat["items"]] aids = [item["aid"] for item in heat["items"]]
assert 104 not in aids, "removed 视频不应进入榜单" assert 104 not in aids, "removed 视频不应进入榜单"
# 综合热度榜应用最小播放门槛 2000:101(1200)与 103(120)被排除 # 综合热度榜应用最小播放门槛 2000:101(1200)与 103(120)被排除
assert aids == [102], aids assert aids == [102], aids
assert heat["items"][0]["heat_percentile"] == 100.0 assert heat["items"][0]["heat_percentile"] == 100.0
views = q.list_videos(sort="view") views = q.list_videos(sort="view")
assert [item["aid"] for item in views["items"]] == [102, 101, 103] assert [item["aid"] for item in views["items"]] == [102, 101, 103]
interactions = q.list_videos(sort="interaction") interactions = q.list_videos(sort="interaction")
assert interactions["items"][0]["aid"] == 101 assert interactions["items"][0]["aid"] == 101
added = q.list_videos(sort="added", within_days=1) added = q.list_videos(sort="added", within_days=1)
assert [item["aid"] for item in added["items"]] == [103] assert [item["aid"] for item in added["items"]] == [103]
published = q.list_videos(sort="published", within_days=2) published = q.list_videos(sort="published", within_days=2)
assert {item["aid"] for item in published["items"]} == {101, 103} assert {item["aid"] for item in published["items"]} == {101, 103}
searched = q.list_videos(sort="view", q="UP B") searched = q.list_videos(sort="view", q="UP B")
assert [item["aid"] for item in searched["items"]] == [103] assert [item["aid"] for item in searched["items"]] == [103]
searched = q.list_videos(sort="view", q="视频 102") searched = q.list_videos(sort="view", q="视频 102")
assert [item["aid"] for item in searched["items"]] == [102] assert [item["aid"] for item in searched["items"]] == [102]
folder = q.list_videos(sort="view", folder_id=3700023351) folder = q.list_videos(sort="view", folder_id=3700023351)
assert folder["total"] == 3 assert folder["total"] == 3
folder = q.list_videos(sort="view", folder_id=999) folder = q.list_videos(sort="view", folder_id=999)
assert folder["total"] == 0 assert folder["total"] == 0
page = q.list_videos(sort="view", page=2, page_size=1) page = q.list_videos(sort="view", page=2, page_size=1)
assert page["total"] == 3 assert page["total"] == 3
assert page["pages"] == 3 assert page["pages"] == 3
assert [item["aid"] for item in page["items"]] == [101] assert [item["aid"] for item in page["items"]] == [101]
finally: finally:
db.close() db.close()
def test_growth_and_cold_start(): def test_growth_and_cold_start():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
growth = q.list_videos(sort="growth", growth_days=2) growth = q.list_videos(sort="growth", growth_days=2)
assert growth["available"] is True assert growth["available"] is True
assert {item["aid"] for item in growth["items"]} == {101, 102} assert {item["aid"] for item in growth["items"]} == {101, 102}
first = next(item for item in growth["items"] if item["aid"] == 101) first = next(item for item in growth["items"] if item["aid"] == 101)
assert first["growth"]["available"] is True assert first["growth"]["available"] is True
assert first["growth"]["delta"] == 200 assert first["growth"]["delta"] == 200
assert first["growth"]["gap_days"] == 2 assert first["growth"]["gap_days"] == 2
rate = q.list_videos(sort="growth_rate", growth_days=2) rate = q.list_videos(sort="growth_rate", growth_days=2)
assert {item["aid"] for item in rate["items"]} == {101, 102} assert {item["aid"] for item in rate["items"]} == {101, 102}
# 只有一个快照日时增长不可用,不能返回假 0 # 只有一个快照日时增长不可用,不能返回假 0
for day in DATES[:-1]: for day in DATES[:-1]:
db.write(lambda conn, day=day: conn.execute( db.write(lambda conn, day=day: conn.execute(
"DELETE FROM snapshot WHERE stat_date = ?", (day,) "DELETE FROM snapshot WHERE stat_date = ?", (day,)
)) ))
DASHBOARD_CACHE.clear() DASHBOARD_CACHE.clear()
cold = q.list_videos(sort="growth", growth_days=2) cold = q.list_videos(sort="growth", growth_days=2)
assert cold["available"] is False assert cold["available"] is False
assert cold["items"] == [] assert cold["items"] == []
assert "两天" in cold["reason"] assert "两天" in cold["reason"]
finally: finally:
db.close() db.close()
def test_video_detail_and_trend(): def test_video_detail_and_trend():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
detail = q.video_detail(101) detail = q.video_detail(101)
assert detail["aid"] == 101 assert detail["aid"] == 101
assert detail["metrics"]["view"] == 1200 assert detail["metrics"]["view"] == 1200
assert detail["growth"]["delta"] == 100 assert detail["growth"]["delta"] == 100
assert detail["snapshot_count"] == 3 assert detail["snapshot_count"] == 3
assert detail["folder_title"] == "2026" assert detail["folder_title"] == "2026"
assert detail["url"].endswith("BV101") assert detail["url"].endswith("BV101")
assert q.video_detail(999999) is None assert q.video_detail(999999) is None
trend = q.video_trend(101, days=3) trend = q.video_trend(101, days=3)
assert trend["dates"] == list(DATES) assert trend["dates"] == list(DATES)
assert trend["series"]["view"] == [1000, 1100, 1200] assert trend["series"]["view"] == [1000, 1100, 1200]
assert trend["available"] is True assert trend["available"] is True
assert len(trend["points"]) == 3 assert len(trend["points"]) == 3
# 请求区间早于首次快照时,从首次快照开始,不制造空白历史 # 请求区间早于首次快照时,从首次快照开始,不制造空白历史
wide = q.video_trend(101, days=30) wide = q.video_trend(101, days=30)
assert wide["dates"] == list(DATES) assert wide["dates"] == list(DATES)
# 删除中间一天,折线在缺失日期应为 None,而不是插值 # 删除中间一天,折线在缺失日期应为 None,而不是插值
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"DELETE FROM snapshot WHERE aid=101 AND stat_date=?", "DELETE FROM snapshot WHERE aid=101 AND stat_date=?",
("2026-10-30",), ("2026-10-30",),
)) ))
trend = q.video_trend(101, days=3) trend = q.video_trend(101, days=3)
assert trend["series"]["view"] == [1000, None, 1200] assert trend["series"]["view"] == [1000, None, 1200]
# 失败快照也视为缺失 # 失败快照也视为缺失
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"UPDATE snapshot SET ok=0, view=NULL WHERE aid=101 AND stat_date=?", "UPDATE snapshot SET ok=0, view=NULL WHERE aid=101 AND stat_date=?",
(DATES[-1],), (DATES[-1],),
)) ))
trend = q.video_trend(101, days=3) trend = q.video_trend(101, days=3)
assert trend["series"]["view"][-1] is None assert trend["series"]["view"][-1] is None
finally: finally:
db.close() db.close()
def test_up_queries(): def test_up_queries():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
ups = q.list_ups() ups = q.list_ups()
assert ups["sort"] == "avg_views" assert ups["sort"] == "avg_views"
assert ups["total"] == 2 assert ups["total"] == 2
assert ups["items"][0]["up_mid"] == 1 assert ups["items"][0]["up_mid"] == 1
assert ups["items"][0]["video_count"] == 2 assert ups["items"][0]["video_count"] == 2
assert ups["items"][0]["total_views"] == 6400 assert ups["items"][0]["total_views"] == 6400
assert ups["items"][0]["avg_views"] == 3200.0 assert ups["items"][0]["avg_views"] == 3200.0
one = q.list_ups(min_videos=2) one = q.list_ups(min_videos=2)
assert one["total"] == 1 assert one["total"] == 1
detail = q.up_detail(1) detail = q.up_detail(1)
assert detail["video_count"] == 2 assert detail["video_count"] == 2
assert detail["total_views"] == 6400 assert detail["total_views"] == 6400
assert detail["blacklisted"] is False assert detail["blacklisted"] is False
assert detail["series"]["dates"] == list(DATES) assert detail["series"]["dates"] == list(DATES)
assert detail["series"]["views"] == [6000, 6200, 6400] assert detail["series"]["views"] == [6000, 6200, 6400]
assert detail["space_url"].endswith("/1") assert detail["space_url"].endswith("/1")
blacklisted = q.up_detail(2) blacklisted = q.up_detail(2)
assert blacklisted["blacklisted"] is True assert blacklisted["blacklisted"] is True
assert blacklisted["video_count"] == 1 assert blacklisted["video_count"] == 1
assert q.up_detail(999999) is None assert q.up_detail(999999) is None
finally: finally:
db.close() db.close()
def test_query_cache_invalidation(): def test_query_cache_invalidation():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
DASHBOARD_CACHE.clear() DASHBOARD_CACHE.clear()
first = q.overview() first = q.overview()
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"UPDATE snapshot SET \"view\" = 999999 WHERE aid=101 AND stat_date=?", "UPDATE snapshot SET \"view\" = 999999 WHERE aid=101 AND stat_date=?",
(DATES[-1],), (DATES[-1],),
)) ))
cached = q.overview() cached = q.overview()
assert cached["totals"]["view"] == first["totals"]["view"] assert cached["totals"]["view"] == first["totals"]["view"]
repo = Repository(db) repo = Repository(db)
repo.bump_cache_version("videos") repo.bump_cache_version("videos")
refreshed = q.overview() refreshed = q.overview()
assert refreshed["totals"]["view"] > first["totals"]["view"] assert refreshed["totals"]["view"] > first["totals"]["view"]
fans_first = q.fans(days=3) fans_first = q.fans(days=3)
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"UPDATE up_stat SET follower=999999 WHERE mid=37754047 AND stat_date=?", "UPDATE up_stat SET follower=999999 WHERE mid=37754047 AND stat_date=?",
(DATES[-1],), (DATES[-1],),
)) ))
fans_cached = q.fans(days=3) fans_cached = q.fans(days=3)
assert fans_cached["follower"] == fans_first["follower"] assert fans_cached["follower"] == fans_first["follower"]
repo.bump_cache_version("fans") repo.bump_cache_version("fans")
fans_refreshed = q.fans(days=3) fans_refreshed = q.fans(days=3)
assert fans_refreshed["follower"] == 999999 assert fans_refreshed["follower"] == 999999
finally: finally:
db.close() db.close()
def test_fans_custom_range_modes(): def test_fans_custom_range_modes():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
daily = q.fans(start="2026-10-29", end="2026-10-31") daily = q.fans(start="2026-10-29", end="2026-10-31")
assert daily["mode"] == "daily" assert daily["mode"] == "daily"
assert daily["range_start"] == "2026-10-29" assert daily["range_start"] == "2026-10-29"
assert daily["range_end"] == "2026-10-31" assert daily["range_end"] == "2026-10-31"
assert daily["dates"] == list(DATES) assert daily["dates"] == list(DATES)
assert daily["series"] == [1000, 1010, 1020] assert daily["series"] == [1000, 1010, 1020]
# 超过一年触发按月峰值模式 # 超过一年触发按月峰值模式
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"INSERT OR REPLACE INTO up_stat" "INSERT OR REPLACE INTO up_stat"
"(stat_date, mid, follower, following, captured_at, ok) " "(stat_date, mid, follower, following, captured_at, ok) "
"VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)" "VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)"
)) ))
DASHBOARD_CACHE.clear() DASHBOARD_CACHE.clear()
monthly = q.fans(start="2025-01-01", end="2026-10-31") monthly = q.fans(start="2025-01-01", end="2026-10-31")
assert monthly["mode"] == "monthly" assert monthly["mode"] == "monthly"
assert monthly["span_days"] == 669 assert monthly["span_days"] == 669
assert monthly["dates"][0] == "2025-01-01" assert monthly["dates"][0] == "2025-01-01"
assert monthly["series"][0] == 500 assert monthly["series"][0] == 500
assert monthly["dates"][-1] == "2026-10-31" assert monthly["dates"][-1] == "2026-10-31"
assert monthly["series"][-1] == 1020 assert monthly["series"][-1] == 1020
# 同月多条时取峰值日期,并优先较晚日期 # 同月多条时取峰值日期,并优先较晚日期
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"INSERT OR REPLACE INTO up_stat" "INSERT OR REPLACE INTO up_stat"
"(stat_date, mid, follower, following, captured_at, ok) " "(stat_date, mid, follower, following, captured_at, ok) "
"VALUES ('2026-10-15', 37754047, 5000, NULL, 1, 1), " "VALUES ('2026-10-15', 37754047, 5000, NULL, 1, 1), "
"('2026-10-16', 37754047, 5000, NULL, 1, 1)" "('2026-10-16', 37754047, 5000, NULL, 1, 1)"
)) ))
DASHBOARD_CACHE.clear() DASHBOARD_CACHE.clear()
monthly = q.fans(start="2025-01-01", end="2026-10-31") monthly = q.fans(start="2025-01-01", end="2026-10-31")
assert monthly["dates"][-1] == "2026-10-16" assert monthly["dates"][-1] == "2026-10-16"
assert monthly["series"][-1] == 5000 assert monthly["series"][-1] == 5000
try: try:
q.fans(start="2026-11-01", end="2026-10-01") q.fans(start="2026-11-01", end="2026-10-01")
except ValueError: except ValueError:
pass pass
else: else:
raise AssertionError("start 晚于 end 应报错") raise AssertionError("start 晚于 end 应报错")
finally: finally:
db.close() db.close()
def test_fans_candidates_system_and_export(): def test_fans_candidates_system_and_export():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp) db, q = setup(tmp)
try: try:
fans = q.fans(days=3) fans = q.fans(days=3)
assert fans["mid"] == 37754047 assert fans["mid"] == 37754047
assert fans["name"] == "咻咻满" assert fans["name"] == "咻咻满"
assert fans["dates"] == list(DATES) assert fans["dates"] == list(DATES)
assert fans["series"] == [1000, 1010, 1020] assert fans["series"] == [1000, 1010, 1020]
assert fans["delta_1d"] == 10 assert fans["delta_1d"] == 10
assert fans["delta_7d"] is None assert fans["delta_7d"] is None
assert fans["delta_cum"] == 20 assert fans["delta_cum"] == 20
assert fans["options"][0]["mid"] == 37754047 assert fans["options"][0]["mid"] == 37754047
wide = q.fans(days=30) wide = q.fans(days=30)
assert wide["dates"] == list(DATES) assert wide["dates"] == list(DATES)
# “最近记录”只返回最近 14 天,早于窗口的记录不进入表格 # “最近记录”只返回最近 14 天,早于窗口的记录不进入表格
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"INSERT OR REPLACE INTO up_stat" "INSERT OR REPLACE INTO up_stat"
"(stat_date, mid, follower, following, captured_at, ok) " "(stat_date, mid, follower, following, captured_at, ok) "
"VALUES (?, ?, ?, NULL, 1, 1)", "VALUES (?, ?, ?, NULL, 1, 1)",
("2026-10-01", 37754047, 900), ("2026-10-01", 37754047, 900),
)) ))
DASHBOARD_CACHE.clear() DASHBOARD_CACHE.clear()
recent = q.fans(days=90) recent = q.fans(days=90)
assert recent["recent_days"] == 14 assert recent["recent_days"] == 14
assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES) assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES)
deltas = {point["stat_date"]: point["delta"] for point in recent["recent_points"]} deltas = {point["stat_date"]: point["delta"] for point in recent["recent_points"]}
assert deltas["2026-10-29"] == 100 # 对比 2026-10-01 的 900 assert deltas["2026-10-29"] == 100 # 对比 2026-10-01 的 900
assert deltas["2026-10-30"] == 10 assert deltas["2026-10-30"] == 10
assert deltas["2026-10-31"] == 10 assert deltas["2026-10-31"] == 10
one_day = q.fans(days=90, recent_days=1) one_day = q.fans(days=90, recent_days=1)
assert [point["stat_date"] for point in one_day["recent_points"]] == [DATES[-1]] assert [point["stat_date"] for point in one_day["recent_points"]] == [DATES[-1]]
# 缺失前一天时不能把跨多天的变化标成“日增” # 缺失前一天时不能把跨多天的变化标成“日增”
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"DELETE FROM up_stat WHERE mid=37754047 AND stat_date=?", "DELETE FROM up_stat WHERE mid=37754047 AND stat_date=?",
("2026-10-30",), ("2026-10-30",),
)) ))
DASHBOARD_CACHE.clear() DASHBOARD_CACHE.clear()
gapped = q.fans(days=3) gapped = q.fans(days=3)
assert gapped["delta_1d"] is None assert gapped["delta_1d"] is None
assert gapped["delta_7d"] is None assert gapped["delta_7d"] is None
candidates = q.candidates() candidates = q.candidates()
assert candidates["total"] == 2 assert candidates["total"] == 2
assert candidates["pending"] == 1 assert candidates["pending"] == 1
assert candidates["items"][0]["aid"] == 201 assert candidates["items"][0]["aid"] == 201
assert 1 <= candidates["items"][0]["days_left"] <= 7 assert 1 <= candidates["items"][0]["days_left"] <= 7
assert candidates["items"][1]["decision"] == "rejected" assert candidates["items"][1]["decision"] == "rejected"
system = q.system() system = q.system()
assert system["data_date"] == DATES[-1] assert system["data_date"] == DATES[-1]
assert system["snapshot_days"] == 3 assert system["snapshot_days"] == 3
assert system["blacklist"][0]["video_count"] == 1 assert system["blacklist"][0]["video_count"] == 1
assert system["tasks"]["snapshot"]["ok"] is True assert system["tasks"]["snapshot"]["ok"] is True
assert system["errors"] == [] assert system["errors"] == []
assert system["preview"] is False assert system["preview"] is False
assert system["next_runs"]["snapshot"] > 0 assert system["next_runs"]["snapshot"] > 0
assert system["next_runs"]["folder_sync"] > FIXED_NOW assert system["next_runs"]["discover"] > FIXED_NOW
assert system["next_runs"]["discover"] > FIXED_NOW finally:
finally: db.close()
db.close()
def test_duration_efficiency_queries():
def test_duration_efficiency_queries(): with tempfile.TemporaryDirectory() as tmp:
with tempfile.TemporaryDirectory() as tmp: db, q = setup(tmp)
db, q = setup(tmp) try:
try: db.write(lambda conn: conn.executemany(
db.write(lambda conn: conn.executemany( "UPDATE video SET duration = ?, pubdate = ? WHERE aid = ?",
"UPDATE video SET duration = ?, pubdate = ? WHERE aid = ?", [
[ (9000, 1_756_684_800, 101), # 2025-09-01 前后,2.5 小时
(9000, 1_756_684_800, 101), # 2025-09-01 前后,2.5 小时 (7200, 1_756_771_200, 102), # 2 小时
(7200, 1_756_771_200, 102), # 2 小时 (300, 1_756_857_600, 103), # 5 分钟,应被排除
(300, 1_756_857_600, 103), # 5 分钟,应被排除 ],
], ))
)) DASHBOARD_CACHE.clear()
DASHBOARD_CACHE.clear()
efficiency = q.list_videos(sort="efficiency")
efficiency = q.list_videos(sort="efficiency") assert [item["aid"] for item in efficiency["items"]] == [102]
assert [item["aid"] for item in efficiency["items"]] == [102] first = efficiency["items"][0]
first = efficiency["items"][0] assert first["duration"] == 7200
assert first["duration"] == 7200 assert first["content_hours"] == 2.0
assert first["content_hours"] == 2.0 assert first["efficiency"] == 2600.0 # 原始播放/小时,详情页口径
assert first["efficiency"] == 2600.0 # 原始播放/小时,详情页口径 assert abs(first["efficiency_score"] - 2207.5) < 0.1
assert abs(first["efficiency_score"] - 2207.5) < 0.1 assert efficiency["duration_filter"] == {
assert efficiency["duration_filter"] == { "min_duration_seconds": 600, "maturity_days": 2,
"min_duration_seconds": 600, "maturity_days": 2, "min_view": 2000, "shrink_minutes": 60,
"min_view": 2000, "shrink_minutes": 60, }
}
# 播放不足 2000 的视频不进入效率榜(101 只有 1200)
# 播放不足 2000 的视频不进入效率榜(101 只有 1200) assert all(item["aid"] != 101 for item in efficiency["items"])
assert all(item["aid"] != 101 for item in efficiency["items"])
interaction = q.list_videos(sort="interaction_efficiency")
interaction = q.list_videos(sort="interaction_efficiency") assert [item["aid"] for item in interaction["items"]] == [102]
assert [item["aid"] for item in interaction["items"]] == [102] assert abs(interaction["items"][0]["interaction_efficiency_score"] - 159.36) < 0.1
assert abs(interaction["items"][0]["interaction_efficiency_score"] - 159.36) < 0.1
# 综合热度榜也应用最小播放门槛
# 综合热度榜也应用最小播放门槛 heat = q.list_videos(sort="heat")
heat = q.list_videos(sort="heat") assert [item["aid"] for item in heat["items"]] == [102]
assert [item["aid"] for item in heat["items"]] == [102]
# 时长缺失不计入时长效率榜
# 时长缺失不计入时长效率榜 ups = q.list_ups(sort="efficiency")
ups = q.list_ups(sort="efficiency") assert ups["sort"] == "efficiency"
assert ups["sort"] == "efficiency" up_a = next(item for item in ups["items"] if item["up_mid"] == 1)
up_a = next(item for item in ups["items"] if item["up_mid"] == 1) assert up_a["total_duration"] == int(9000 + 7200)
assert up_a["total_duration"] == int(9000 + 7200) assert up_a["content_hours"] == 4.5
assert up_a["content_hours"] == 4.5 assert up_a["views_per_hour"] == round(6400 / 4.5, 1)
assert up_a["views_per_hour"] == round(6400 / 4.5, 1) assert up_a["efficiency"] is not None
assert up_a["efficiency"] is not None assert up_a["interaction_efficiency"] is not None
assert up_a["interaction_efficiency"] is not None
by_duration = q.list_ups(sort="duration")
by_duration = q.list_ups(sort="duration") assert [item["up_mid"] for item in by_duration["items"]][0] == 1
assert [item["up_mid"] for item in by_duration["items"]][0] == 1
db.write(lambda conn: conn.execute(
db.write(lambda conn: conn.execute( "UPDATE video SET duration = NULL WHERE aid = 101"
"UPDATE video SET duration = NULL WHERE aid = 101" ))
)) DASHBOARD_CACHE.clear()
DASHBOARD_CACHE.clear() missing = q.list_videos(sort="efficiency")
missing = q.list_videos(sort="efficiency") assert [item["aid"] for item in missing["items"]] == [102]
assert [item["aid"] for item in missing["items"]] == [102]
# 发布未满 2 天的视频不进入效率榜
# 发布未满 2 天的视频不进入效率榜 db.write(lambda conn: conn.execute(
db.write(lambda conn: conn.execute( "UPDATE video SET duration = 7200, pubdate = ? WHERE aid = 102",
"UPDATE video SET duration = 7200, pubdate = ? WHERE aid = 102", (int(day_bounds("2026-10-30")[0]),),
(int(day_bounds("2026-10-30")[0]),), ))
)) DASHBOARD_CACHE.clear()
DASHBOARD_CACHE.clear() fresh = q.list_videos(sort="efficiency")
fresh = q.list_videos(sort="efficiency") assert fresh["items"] == []
assert fresh["items"] == [] assert fresh["available"] is False
assert fresh["available"] is False assert "发布满 2 天" in fresh["reason"]
assert "发布满 2 天" in fresh["reason"] assert "播放 ≥ 2000" in fresh["reason"]
assert "播放 ≥ 2000" in fresh["reason"] finally:
finally: db.close()
db.close()
def test_year_filter_and_published_sort():
def test_year_filter_and_published_sort(): with tempfile.TemporaryDirectory() as tmp:
with tempfile.TemporaryDirectory() as tmp: db, q = setup(tmp)
db, q = setup(tmp) try:
try: by_year = q.list_videos(sort="view", year=2026)
by_year = q.list_videos(sort="view", year=2026) assert [item["aid"] for item in by_year["items"]] == [102, 101, 103]
assert [item["aid"] for item in by_year["items"]] == [102, 101, 103] assert by_year["year"] == 2026
assert by_year["year"] == 2026
none = q.list_videos(sort="view", year=2025)
none = q.list_videos(sort="view", year=2025) assert none["items"] == []
assert none["items"] == [] assert none["total"] == 0
assert none["total"] == 0
published = q.list_videos(sort="published")
published = q.list_videos(sort="published") assert [item["aid"] for item in published["items"]] == [103, 101, 102]
assert [item["aid"] for item in published["items"]] == [103, 101, 102]
added = q.list_videos(sort="added")
added = q.list_videos(sort="added") assert added["total"] == 3
assert added["total"] == 3
ups = q.list_ups(sort="efficiency", year=2026)
ups = q.list_ups(sort="efficiency", year=2026) assert ups["year"] == 2026 and ups["total"] == 2
assert ups["year"] == 2026 and ups["total"] == 2 ups_none = q.list_ups(sort="views", year=2025)
ups_none = q.list_ups(sort="views", year=2025) assert ups_none["total"] == 0
assert ups_none["total"] == 0 finally:
finally: db.close()
db.close()
def test_about_payload():
def test_about_payload(): with tempfile.TemporaryDirectory() as tmp:
with tempfile.TemporaryDirectory() as tmp: db, q = setup(tmp)
db, q = setup(tmp) try:
try: about = q.about()
about = q.about() assert about["data_date"] == DATES[-1]
assert about["data_date"] == DATES[-1] assert about["snapshot_days"] == 3
assert about["snapshot_days"] == 3 assert about["snapshot_first_date"] == DATES[0]
assert about["snapshot_first_date"] == DATES[0] assert about["fans_start_date"] == DATES[0]
assert about["fans_start_date"] == DATES[0] assert about["schema_version"] == 5
assert about["schema_version"] == 4 assert about["analytics"] == {
assert about["analytics"] == { "min_duration_seconds": 600,
"min_duration_seconds": 600, "maturity_days": 2,
"maturity_days": 2, "min_view_for_ranking": 2000,
"min_view_for_ranking": 2000, "video_shrink_minutes": 60,
"video_shrink_minutes": 60, "shrink_minutes": None,
"shrink_minutes": None, }
} assert about["heat_weights"]["view"] == 0.30
assert about["heat_weights"]["view"] == 0.30 assert about["followers"]["videos_total"] == 3
assert about["followers"]["videos_total"] == 3 assert about["followers"]["videos_covered"] == 0
assert about["followers"]["videos_covered"] == 0 assert about["followers"]["videos_missing"] == 3
assert about["followers"]["videos_missing"] == 3 assert about["folders"][0]["media_id"] == 3700023351
assert about["folders"][0]["media_id"] == 3700023351 assert about["preview"] is False
assert about["preview"] is False finally:
finally: db.close()
db.close()
def main_test():
def main_test(): test_overview()
test_overview() test_video_listing_filters_and_sorting()
test_video_listing_filters_and_sorting() test_growth_and_cold_start()
test_growth_and_cold_start() test_video_detail_and_trend()
test_video_detail_and_trend() test_up_queries()
test_up_queries() test_query_cache_invalidation()
test_query_cache_invalidation() test_duration_efficiency_queries()
test_duration_efficiency_queries() test_year_filter_and_published_sort()
test_year_filter_and_published_sort() test_about_payload()
test_about_payload() test_fans_custom_range_modes()
test_fans_custom_range_modes() test_fans_candidates_system_and_export()
test_fans_candidates_system_and_export() print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统测试全部通过")
print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统测试全部通过")
if __name__ == "__main__":
if __name__ == "__main__": main_test()
main_test()
+308 -308
View File
@@ -1,308 +1,308 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""只读 Web 服务测试(离线): uv run python tests/test_web_server.py""" """只读 Web 服务测试(离线): uv run python tests/test_web_server.py"""
import http.client import http.client
import json import json
import os import os
import sys import sys
import tempfile import tempfile
import threading import threading
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.config import ConfigStore # noqa: E402 from reaction_board.config import ConfigStore # noqa: E402
from reaction_board.database import Database # noqa: E402 from reaction_board.database import Database # noqa: E402
from reaction_board.web.server import create_server # noqa: E402 from reaction_board.web.server import create_server # noqa: E402
from tests.web_fixtures import build_db, write_config # noqa: E402 from tests.web_fixtures import build_db, write_config # noqa: E402
class RunningServer: class RunningServer:
def __init__(self, tmp: str, *, token: str = "", preview: bool = False): def __init__(self, tmp: str, *, token: str = "", preview: bool = False):
self.config = write_config(tmp, token=token) self.config = write_config(tmp, token=token)
build_db(tmp) build_db(tmp)
self.db = Database(Path(tmp) / "app.db") self.db = Database(Path(tmp) / "app.db")
self.db.initialize() self.db.initialize()
if preview: if preview:
self.db.write(lambda conn: conn.execute( self.db.write(lambda conn: conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES ('preview:seeded', '1')" "INSERT OR REPLACE INTO kv(k, v) VALUES ('preview:seeded', '1')"
)) ))
self.store = ConfigStore(self.config) self.store = ConfigStore(self.config)
self.server = create_server( self.server = create_server(
self.store, self.db, port=0, preview=preview, self.store, self.db, port=0, preview=preview,
clock=lambda: 1_793_459_000, clock=lambda: 1_793_459_000,
) )
self.port = self.server.server_address[1] self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start() self.thread.start()
def request(self, path: str, *, method: str = "GET", headers=None, body=None): def request(self, path: str, *, method: str = "GET", headers=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
conn.request(method, path, body=body, headers=headers or {}) conn.request(method, path, body=body, headers=headers or {})
response = conn.getresponse() response = conn.getresponse()
payload = response.read() payload = response.read()
result = response.status, dict(response.getheaders()), payload result = response.status, dict(response.getheaders()), payload
conn.close() conn.close()
return result return result
def json(self, path: str, **kwargs): def json(self, path: str, **kwargs):
status, headers, payload = self.request(path, **kwargs) status, headers, payload = self.request(path, **kwargs)
return status, headers, json.loads(payload.decode("utf-8")) return status, headers, json.loads(payload.decode("utf-8"))
def close(self): def close(self):
self.server.shutdown() self.server.shutdown()
self.server.server_close() self.server.server_close()
self.db.close() self.db.close()
def test_readonly_routes_and_static(): def test_readonly_routes_and_static():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
running = RunningServer(tmp) running = RunningServer(tmp)
try: try:
status, _, health = running.json("/api/health") status, _, health = running.json("/api/health")
assert status == 200 and health["ok"] is True assert status == 200 and health["ok"] is True
assert health["data"]["preview"] is False assert health["data"]["preview"] is False
status, _, payload = running.json("/api/session") status, _, payload = running.json("/api/session")
assert status == 200 assert status == 200
assert payload["data"]["auth_enabled"] is False assert payload["data"]["auth_enabled"] is False
assert payload["data"]["authenticated"] is True assert payload["data"]["authenticated"] is True
status, _, payload = running.json("/api/candidates") status, _, payload = running.json("/api/candidates")
assert status == 200 and payload["ok"] is True assert status == 200 and payload["ok"] is True
status, _, payload = running.json("/api/system") status, _, payload = running.json("/api/system")
assert status == 200 and payload["ok"] is True assert status == 200 and payload["ok"] is True
status, _, payload = running.json("/api/about") status, _, payload = running.json("/api/about")
assert status == 200 assert status == 200
assert payload["data"]["schema_version"] == 4 assert payload["data"]["schema_version"] == 5
assert payload["data"]["analytics"]["min_duration_seconds"] == 600 assert payload["data"]["analytics"]["min_duration_seconds"] == 600
status, _, overview = running.json("/api/overview") status, _, overview = running.json("/api/overview")
assert status == 200 and overview["ok"] is True assert status == 200 and overview["ok"] is True
assert overview["data"]["data_date"] == "2026-10-31" assert overview["data"]["data_date"] == "2026-10-31"
status, headers, payload = running.request("/") status, headers, payload = running.request("/")
assert status == 200 assert status == 200
assert "text/html" in headers["Content-Type"] assert "text/html" in headers["Content-Type"]
assert b"<html" in payload.lower() assert b"<html" in payload.lower()
for asset in ("/static/style.css", "/static/app.js", "/static/vendor/echarts.min.js"): for asset in ("/static/style.css", "/static/app.js", "/static/vendor/echarts.min.js"):
status, asset_headers, asset_payload = running.request(asset) status, asset_headers, asset_payload = running.request(asset)
assert status == 200, asset assert status == 200, asset
assert asset_payload, asset assert asset_payload, asset
content_type = asset_headers["Content-Type"] content_type = asset_headers["Content-Type"]
assert "text/css" in content_type or "javascript" in content_type, asset assert "text/css" in content_type or "javascript" in content_type, asset
status, _, payload = running.request("/static/../config.json") status, _, payload = running.request("/static/../config.json")
assert status == 404 assert status == 404
# 伪静态前端路由:直接访问 /rank、/video/... 返回 index.html # 伪静态前端路由:直接访问 /rank、/video/... 返回 index.html
for route_path in ("/rank", "/videos", "/ups", "/about", "/video/114775988111033", "/up/230020975", "/fans"): for route_path in ("/rank", "/videos", "/ups", "/about", "/video/114775988111033", "/up/230020975", "/fans"):
status, route_headers, route_payload = running.request(route_path) status, route_headers, route_payload = running.request(route_path)
assert status == 200, route_path assert status == 200, route_path
assert "text/html" in route_headers["Content-Type"], route_path assert "text/html" in route_headers["Content-Type"], route_path
assert b"<html" in route_payload.lower(), route_path assert b"<html" in route_payload.lower(), route_path
status, _, _ = running.request("/nope.js") status, _, _ = running.request("/nope.js")
assert status == 404 assert status == 404
status, _, payload = running.request("/api/videos") status, _, payload = running.request("/api/videos")
assert status == 200 assert status == 200
items = json.loads(payload)["data"]["items"] items = json.loads(payload)["data"]["items"]
assert items and items[0]["aid"] == 102 assert items and items[0]["aid"] == 102
status, _, payload = running.json("/api/videos?year=2026") status, _, payload = running.json("/api/videos?year=2026")
assert status == 200 assert status == 200
assert payload["data"]["year"] == 2026 assert payload["data"]["year"] == 2026
status, _, payload = running.json("/api/videos?year=1800") status, _, payload = running.json("/api/videos?year=1800")
assert status == 400 assert status == 400
status, _, payload = running.json("/api/ups?year=2026") status, _, payload = running.json("/api/ups?year=2026")
assert status == 200 assert status == 200
assert payload["data"]["year"] == 2026 assert payload["data"]["year"] == 2026
status, _, payload = running.request("/api/ups") status, _, payload = running.request("/api/ups")
assert status == 200 assert status == 200
assert json.loads(payload)["data"]["sort"] == "efficiency" assert json.loads(payload)["data"]["sort"] == "efficiency"
status, _, payload = running.request("/api/fans?days=7&recent_days=14") status, _, payload = running.request("/api/fans?days=7&recent_days=14")
assert status == 200 assert status == 200
fans = json.loads(payload)["data"] fans = json.loads(payload)["data"]
assert fans["recent_days"] == 14 assert fans["recent_days"] == 14
assert all( assert all(
point["stat_date"] >= "2026-10-18" point["stat_date"] >= "2026-10-18"
for point in fans["recent_points"] for point in fans["recent_points"]
) )
assert all("delta" in point for point in fans["recent_points"]) assert all("delta" in point for point in fans["recent_points"])
status, _, payload = running.request( status, _, payload = running.request(
"/api/fans?start=2026-10-29&end=2026-10-31" "/api/fans?start=2026-10-29&end=2026-10-31"
) )
assert status == 200 assert status == 200
data = json.loads(payload)["data"] data = json.loads(payload)["data"]
assert data["mode"] == "daily" assert data["mode"] == "daily"
assert data["dates"] == ["2026-10-29", "2026-10-30", "2026-10-31"] assert data["dates"] == ["2026-10-29", "2026-10-30", "2026-10-31"]
running.db.write(lambda conn: conn.execute( running.db.write(lambda conn: conn.execute(
"INSERT OR REPLACE INTO up_stat" "INSERT OR REPLACE INTO up_stat"
"(stat_date, mid, follower, following, captured_at, ok) " "(stat_date, mid, follower, following, captured_at, ok) "
"VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)" "VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)"
)) ))
status, _, payload = running.request( status, _, payload = running.request(
"/api/fans?start=2025-01-01&end=2026-10-31" "/api/fans?start=2025-01-01&end=2026-10-31"
) )
assert status == 200 assert status == 200
assert json.loads(payload)["data"]["mode"] == "monthly" assert json.loads(payload)["data"]["mode"] == "monthly"
status, headers, payload = running.request("/api/videos", method="HEAD") status, headers, payload = running.request("/api/videos", method="HEAD")
assert status == 200 and payload == b"" assert status == 200 and payload == b""
assert "Content-Length" in headers assert "Content-Length" in headers
# 只读:所有业务写方法都被拒绝,且数据库不变 # 只读:所有业务写方法都被拒绝,且数据库不变
before = running.db.read(lambda conn: conn.execute( before = running.db.read(lambda conn: conn.execute(
"SELECT COUNT(*) FROM candidate" "SELECT COUNT(*) FROM candidate"
).fetchone()[0]) ).fetchone()[0])
for method in ("POST", "PUT", "PATCH", "DELETE"): for method in ("POST", "PUT", "PATCH", "DELETE"):
status, _, _ = running.request("/api/videos/101", method=method) status, _, _ = running.request("/api/videos/101", method=method)
assert status == 405, method assert status == 405, method
after = running.db.read(lambda conn: conn.execute( after = running.db.read(lambda conn: conn.execute(
"SELECT COUNT(*) FROM candidate" "SELECT COUNT(*) FROM candidate"
).fetchone()[0]) ).fetchone()[0])
assert after == before assert after == before
status, _, error = running.json("/api/videos?sort=bad") status, _, error = running.json("/api/videos?sort=bad")
assert status == 400 and error["ok"] is False assert status == 400 and error["ok"] is False
status, _, error = running.json("/api/videos?page=0") status, _, error = running.json("/api/videos?page=0")
assert status == 400 assert status == 400
status, _, error = running.json("/api/videos/abc") status, _, error = running.json("/api/videos/abc")
assert status == 400 assert status == 400
status, _, error = running.json("/api/nope") status, _, error = running.json("/api/nope")
assert status == 404 assert status == 404
status, _, payload = running.request("/api/export.csv?sort=view") status, _, payload = running.request("/api/export.csv?sort=view")
assert status == 404 assert status == 404
status, headers, _ = running.request("/api/overview") status, headers, _ = running.request("/api/overview")
assert headers["X-Content-Type-Options"] == "nosniff" assert headers["X-Content-Type-Options"] == "nosniff"
assert "default-src 'self'" in headers["Content-Security-Policy"] assert "default-src 'self'" in headers["Content-Security-Policy"]
assert headers["X-Frame-Options"] == "DENY" assert headers["X-Frame-Options"] == "DENY"
finally: finally:
running.close() running.close()
def test_token_auth_and_login(): def test_token_auth_and_login():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
running = RunningServer(tmp, token="test-token") running = RunningServer(tmp, token="test-token")
try: try:
# 公共页面与公共 API 访客可见 # 公共页面与公共 API 访客可见
status, _, body = running.json("/api/overview") status, _, body = running.json("/api/overview")
assert status == 200 and body["ok"] is True assert status == 200 and body["ok"] is True
status, _, body = running.json("/api/videos") status, _, body = running.json("/api/videos")
assert status == 200 and body["ok"] is True assert status == 200 and body["ok"] is True
status, _, body = running.json("/api/fans?days=7") status, _, body = running.json("/api/fans?days=7")
assert status == 200 and body["ok"] is True assert status == 200 and body["ok"] is True
status, _, payload = running.request("/") status, _, payload = running.request("/")
assert status == 200 and b"<html" in payload.lower() assert status == 200 and b"<html" in payload.lower()
status, _, payload = running.request("/rank") status, _, payload = running.request("/rank")
assert status == 200 and b"<html" in payload.lower() assert status == 200 and b"<html" in payload.lower()
# 候选与系统需要登录 # 候选与系统需要登录
status, _, body = running.json("/api/candidates") status, _, body = running.json("/api/candidates")
assert status == 401 and body["error"]["code"] == "unauthorized" assert status == 401 and body["error"]["code"] == "unauthorized"
status, _, body = running.json("/api/system") status, _, body = running.json("/api/system")
assert status == 401 assert status == 401
status, headers, _ = running.request("/candidates") status, headers, _ = running.request("/candidates")
assert status == 303 and headers["Location"] == "/login?next=/candidates" assert status == 303 and headers["Location"] == "/login?next=/candidates"
status, headers, _ = running.request("/system") status, headers, _ = running.request("/system")
assert status == 303 and headers["Location"] == "/login?next=/system" assert status == 303 and headers["Location"] == "/login?next=/system"
status, _, body = running.json("/api/session") status, _, body = running.json("/api/session")
assert status == 200 assert status == 200
assert body["data"]["auth_enabled"] is True assert body["data"]["auth_enabled"] is True
assert body["data"]["authenticated"] is False assert body["data"]["authenticated"] is False
status, _, payload = running.request("/login?next=/candidates") status, _, payload = running.request("/login?next=/candidates")
assert status == 200 and b"web.auth_token" in payload assert status == 200 and b"web.auth_token" in payload
assert b"action=\"/login?next=/candidates\"" in payload assert b"action=\"/login?next=/candidates\"" in payload
status, _, _ = running.request( status, _, _ = running.request(
"/login", "/login",
method="POST", method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
body="token=wrong", body="token=wrong",
) )
assert status == 401 assert status == 401
status, headers, _ = running.request( status, headers, _ = running.request(
"/login?next=/candidates", "/login?next=/candidates",
method="POST", method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
body="token=test-token", body="token=test-token",
) )
assert status == 303 and headers["Location"] == "/candidates" assert status == 303 and headers["Location"] == "/candidates"
cookie = headers["Set-Cookie"].split(";", 1)[0] cookie = headers["Set-Cookie"].split(";", 1)[0]
assert cookie.startswith("rb_session=") assert cookie.startswith("rb_session=")
status, _, body = running.json("/api/candidates", headers={"Cookie": cookie}) status, _, body = running.json("/api/candidates", headers={"Cookie": cookie})
assert status == 200 and body["ok"] is True assert status == 200 and body["ok"] is True
status, _, body = running.json("/api/system", headers={"Cookie": cookie}) status, _, body = running.json("/api/system", headers={"Cookie": cookie})
assert status == 200 and body["ok"] is True assert status == 200 and body["ok"] is True
status, _, body = running.json( status, _, body = running.json(
"/api/candidates", headers={"Authorization": "Bearer test-token"} "/api/candidates", headers={"Authorization": "Bearer test-token"}
) )
assert status == 200 assert status == 200
tampered = cookie[:-1] + ("0" if cookie[-1] != "0" else "1") tampered = cookie[:-1] + ("0" if cookie[-1] != "0" else "1")
status, _, _ = running.json("/api/candidates", headers={"Cookie": tampered}) status, _, _ = running.json("/api/candidates", headers={"Cookie": tampered})
assert status == 401 assert status == 401
status, _, body = running.json("/api/overview", headers={"Cookie": tampered}) status, _, body = running.json("/api/overview", headers={"Cookie": tampered})
assert status == 200 assert status == 200
status, _, _ = running.json("/api/health") status, _, _ = running.json("/api/health")
assert status == 200 assert status == 200
finally: finally:
running.close() running.close()
def test_preview_and_loopback_guards(): def test_preview_and_loopback_guards():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
config = write_config(tmp) config = write_config(tmp)
build_db(tmp) build_db(tmp)
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
store = ConfigStore(config) store = ConfigStore(config)
try: try:
try: try:
create_server(store, db, host="0.0.0.0", port=0) create_server(store, db, host="0.0.0.0", port=0)
except ValueError as exc: except ValueError as exc:
assert "web.auth_token" in str(exc) assert "web.auth_token" in str(exc)
else: else:
raise AssertionError("非回环地址且无 token 应拒绝启动") raise AssertionError("非回环地址且无 token 应拒绝启动")
try: try:
create_server(store, db, port=0, preview=True) create_server(store, db, port=0, preview=True)
except ValueError as exc: except ValueError as exc:
assert "preview:seeded" in str(exc) assert "preview:seeded" in str(exc)
else: else:
raise AssertionError("非预览库不应允许 --preview") raise AssertionError("非预览库不应允许 --preview")
db.write(lambda conn: conn.execute( db.write(lambda conn: conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES ('preview:seeded', '1')" "INSERT OR REPLACE INTO kv(k, v) VALUES ('preview:seeded', '1')"
)) ))
server = create_server(store, db, port=0, preview=True) server = create_server(store, db, port=0, preview=True)
try: try:
assert server.context.preview is True assert server.context.preview is True
finally: finally:
server.server_close() server.server_close()
finally: finally:
db.close() db.close()
def main_test(): def main_test():
test_readonly_routes_and_static() test_readonly_routes_and_static()
test_token_auth_and_login() test_token_auth_and_login()
test_preview_and_loopback_guards() test_preview_and_loopback_guards()
print("OK: Web 只读路由/鉴权/预览/安全边界测试全部通过") print("OK: Web 只读路由/鉴权/预览/安全边界测试全部通过")
if __name__ == "__main__": if __name__ == "__main__":
main_test() main_test()
-2
View File
@@ -48,8 +48,6 @@ def write_config(
"up_blacklist_seed": [], "up_blacklist_seed": [],
"schedule": { "schedule": {
"discover_minutes": 60, "discover_minutes": 60,
"folder_sync_minutes": 60,
"full_folder_refresh_hours": 6,
"snapshot_at": "00:01", "snapshot_at": "00:01",
"candidate_ttl_days": 7, "candidate_ttl_days": 7,
}, },
+4 -4
View File
@@ -271,12 +271,12 @@ def _seed_task_records(
"backup_path": f"data/backup/app-{latest_day.strftime('%Y%m%d')}.db", "backup_path": f"data/backup/app-{latest_day.strftime('%Y%m%d')}.db",
}, },
}, },
"folder_sync": { "ingest": {
"name": "folder_sync", "ok": True, "name": "ingest", "ok": True,
"started_at": int(base + 20 * 3600), "started_at": int(base + 20 * 3600),
"finished_at": int(base + 20 * 3600 + 8), "finished_at": int(base + 20 * 3600 + 6),
"error": None, "error": None,
"summary": {"folders": 2, "added": 65, "removed": 0, "ok": True}, "summary": {"refs": 2, "added": 2, "ok": True},
}, },
"discovery": { "discovery": {
"name": "discovery", "ok": True, "name": "discovery", "ok": True,