#!/usr/bin/env python3 """2026 三项需求的专项测试(**不启动服务、不联网、不真睡 10 秒**): 需求① **下载队列失败任务自动重试**(间隔 10 分钟): 配置默认值、`FailedRetry` 跳过条件(人工暂停 / 水位不足 / 关闭 / N==0 不写日志)、 `force` 强制、`/api/download/status` 的 `auto_retry`、`/api/download/retry_failed_now`。 需求② **单曲取址之间 5~10 秒缓冲**: 默认 5000/10000、`_pace()` 实际等待 ≥ min(**虚拟时钟**,不真睡)、 i.php 与 do.php 都走同一个 `_pace()`、全局串行(多线程不叠加)、 一次性批量取址路径(`/api/upstream/probe`)不许绕过节奏。 需求③ **扫描媒体目录,发现新文件按文件名自动登记进歌曲表**: 合法且够大的 → 新增登记/更新;非法名/过小 → 跳过;**绝不删除任何文件**; 超时路径(打桩成"会睡很久的列举函数")→ `ok=false` 且状态未变、耗时受控; 接口 `/api/keeps/scan`、`/api/keeps/scan/status`、`/api/keeps/stats` 的 `scan` 字段。 另外用源码级守卫钉死"新的守护线程必须在端口监听之后启动"(main.py 的顺序)。 """ import json import os import sys import tempfile import threading import time import unittest from unittest import mock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from ktv import admin, main from ktv import resolver as R from ktv import store as store_mod from ktv.config import Config from ktv.downloader import Downloader, FailedRetry, StorageGuard from ktv.store import Store from ktv.web import Request def req(method, path, query=None, body=b""): return Request(method, path, query or {}, {}, body) def jbody(data: dict) -> bytes: return json.dumps(data, ensure_ascii=False).encode() def rows(*specs): """(tid, name, lang, singers, hot) → import_library 需要的一行。""" return [(tid, name, lang, "", singers, f"{tid}.ts", hot, "") for tid, name, lang, singers, hot in specs] class _FakeStop: """假停止位:让调度器循环只跑一轮就退出(避免真的等 600 秒)。""" def __init__(self): self.rounds = 0 self.waits: list = [] def is_set(self) -> bool: return self.rounds >= 1 def wait(self, timeout) -> bool: self.waits.append(timeout) self.rounds += 1 return False class Case(unittest.TestCase): """公共夹具:临时数据/媒体目录 + 4 首小曲库(显式关连接,避免 ResourceWarning 污染输出)。""" def setUp(self): self.tmp = tempfile.mkdtemp() self.cfg = Config(os.path.join(self.tmp, "c.json")) self.media = os.path.join(self.tmp, "media") self.cfg.update({"data_dir": self.tmp, "media_dir": self.media, "tmp_dir": os.path.join(self.tmp, "t")}) os.makedirs(self.media, exist_ok=True) self.store = Store(os.path.join(self.tmp, "t.db")) self.store.import_library(rows((1, "A歌", "国语", "s1", 10), (2, "B歌", "粤语", "s2", 9), (3, "C歌", "英语", "s3", 8), (4, "D歌", "国语", "s4", 7)), "v1") def tearDown(self): try: self.store.conn.close() except Exception: pass def dl_state(self, tid: int) -> str: row = self.store.q1("SELECT state FROM downloads WHERE tid=?", (tid,)) return row["state"] if row else "" # ──────────────────────────── 需求① 失败任务自动重试 ──────────────────────────── class TestRetryFailedConfig(Case): """配置项默认值与"沿用 max_attempts"的取值口径。""" def test_defaults(self): self.assertTrue(self.cfg.get("retry_failed_enabled")) self.assertEqual(self.cfg.get("retry_failed_interval_sec"), 600) # 10 分钟 # 默认沿用 max_attempts(默认 3) self.assertEqual(self.cfg.get("retry_failed_max_attempts"), self.cfg.get("max_attempts")) self.assertEqual(self.cfg.retry_failed_max_attempts(), 3) def test_zero_follows_max_attempts(self): """填 0 = 跟随 max_attempts 的当前值(改 max_attempts 立刻生效)。""" self.cfg.update({"max_attempts": 7, "retry_failed_max_attempts": 0}) self.assertEqual(self.cfg.retry_failed_max_attempts(), 7) def test_interval_is_clamped(self): warns = self.cfg.update({"retry_failed_interval_sec": 5}) self.assertNotIn(self.cfg.get("retry_failed_interval_sec"), (0, 5)) self.assertGreaterEqual(self.cfg.get("retry_failed_interval_sec"), 60) self.assertTrue(any("自动重试间隔" in w for w in warns), warns) class TestLegacyConfigMigration(unittest.TestCase): """老机器的配置文件里写的是"当年的默认值"(save() 会把所有默认值落盘)→ 必须能升级到新语义。 不迁移的话:取址间隔一直是 3~5 秒,本版本的策略变更(5~10 秒、避免被上游判定为攻击) 在老机器上等于没做。 """ def test_old_defaults_are_migrated(self): path = os.path.join(tempfile.mkdtemp(), "c.json") with open(path, "w", encoding="utf-8") as fh: json.dump({"upstream_interval_min_ms": 3000, "upstream_interval_max_ms": 5000, "upstream_resolution": "720"}, fh) cfg = Config(path) self.assertEqual(cfg.get("upstream_interval_min_ms"), 5000) self.assertEqual(cfg.get("upstream_interval_max_ms"), 10000) self.assertEqual(sorted(cfg.migrated_keys), ["upstream_interval_max_ms", "upstream_interval_min_ms"]) # 迁移结果已落盘(下次启动不会再判一次) with open(path, encoding="utf-8") as fh: saved = json.load(fh) self.assertEqual(saved["upstream_interval_min_ms"], 5000) self.assertEqual(Config(path).migrated_keys, []) self.assertEqual(cfg.get("upstream_resolution"), "720") # 其它键原样保留 # 迁移只做一次:之后用户**显式**把间隔改回去也会被尊重 cfg.update({"upstream_interval_min_ms": 3000}) again = Config(path) self.assertEqual(again.get("upstream_interval_min_ms"), 3000) self.assertEqual(again.migrated_keys, []) def test_user_values_are_not_touched(self): path = os.path.join(tempfile.mkdtemp(), "c.json") with open(path, "w", encoding="utf-8") as fh: json.dump({"upstream_interval_min_ms": 8000, "upstream_interval_max_ms": 12000}, fh) cfg = Config(path) self.assertEqual((cfg.get("upstream_interval_min_ms"), cfg.get("upstream_interval_max_ms")), (8000, 12000)) self.assertEqual(cfg.migrated_keys, []) class RetryCase(Case): """夹具:3 首入队,其中 2 首标成 failed(模拟"已重试到上限")。""" def setUp(self): super().setUp() self.store.keep_add([1, 2, 3]) self.store.enqueue([1, 2, 3]) self.store.set_state(1, "failed", "取址失败 code=403") self.store.set_state(2, "failed", "下载不完整") self.downloader = Downloader(self.cfg, self.store) self.guard = StorageGuard(self.cfg, self.store, self.downloader) self.downloader.guard = self.guard self.retry = FailedRetry(self.cfg, self.store, self.downloader, self.guard) self.downloader.retry = self.retry def retry_events(self) -> list[str]: return [e["text"] for e in self.store.events(200) if "下载队列自动重试:重置" in e["text"]] class TestFailedRetry(RetryCase): def test_run_once_resets_failed_and_writes_event(self): res = self.retry.run_once() self.assertTrue(res["ok"]) self.assertFalse(res["skipped"]) self.assertEqual(res["reset"], 2) self.assertEqual(self.dl_state(1), "pending") self.assertEqual(self.dl_state(2), "pending") self.assertEqual(self.dl_state(3), "pending") # 本来就在队列里,不受影响 # 事件日志形如:下载队列自动重试:重置 2 首失败任务(间隔 600s) evs = self.retry_events() self.assertEqual(len(evs), 1, evs) self.assertIn("重置 2 首失败任务", evs[0]) self.assertIn("间隔 600s", evs[0]) st = self.retry.status() self.assertEqual((st["last_count"], st["total_count"]), (2, 2)) self.assertGreater(st["last_at"], 0) self.assertEqual(st["skipped_reason"], "") self.assertEqual(st["interval_sec"], 600) self.assertTrue(st["enabled"]) def test_no_log_when_nothing_to_reset(self): """N == 0 时**不写日志**(否则每 10 分钟刷一条无意义日志)。""" self.retry.run_once() # 第一次重置 2 首 before = len(self.retry_events()) res = self.retry.run_once() self.assertEqual(res["reset"], 0) self.assertEqual(len(self.retry_events()), before) # 第二次一行都没多 def test_skip_when_user_paused(self): """下载池被**人工暂停**时不重试(force=True 才绕过)。""" self.downloader.pause(by="user") res = self.retry.run_once() self.assertTrue(res["skipped"]) self.assertIn("人工暂停", res["skipped_reason"]) self.assertEqual(self.dl_state(1), "failed") self.assertEqual(self.retry.status()["skipped_reason"], res["skipped_reason"]) # 强制:手工接口用 res2 = self.retry.run_once(force=True) self.assertEqual(res2["reset"], 2) self.assertEqual(self.dl_state(1), "pending") def test_skip_when_storage_low(self): """存储守卫**水位不足**时不重试。""" self.guard.state = {"ok": False, "reason": "本地盘剩余 1.0GB < 20.0GB"} res = self.retry.run_once() self.assertTrue(res["skipped"]) self.assertIn("水位不足", res["skipped_reason"]) self.assertEqual(self.dl_state(1), "failed") def test_skip_when_disabled(self): self.cfg.update({"retry_failed_enabled": False}) res = self.retry.run_once() self.assertTrue(res["skipped"]) self.assertIn("已关闭", res["skipped_reason"]) self.assertFalse(self.retry.status()["enabled"]) self.assertEqual(self.dl_state(1), "failed") def test_run_lock_prevents_overlap(self): """防重入:同一个调度器不会两处同时重试(第二次直接说"还在进行中")。""" self.assertTrue(self.retry._run_lock.acquire(blocking=False)) try: res = self.retry.run_once() self.assertTrue(res["skipped"]) self.assertIn("还在进行中", res["skipped_reason"]) finally: self.retry._run_lock.release() def test_loop_returns_immediately_when_disabled(self): """interval=0 / enabled=false 时后台循环立刻退出(不留空转线程)。""" self.cfg.update({"retry_failed_interval_sec": 0}) th = threading.Thread(target=self.retry.loop, daemon=True) th.start() th.join(2) self.assertFalse(th.is_alive()) def test_loop_runs_and_swallows_errors(self): """循环真的按间隔调 run_once;run_once 抛异常时**只记日志**、线程不崩。""" stop = _FakeStop() self.retry._stop = stop # 只跑一轮就退出 self.retry.loop() self.assertEqual(stop.waits, [600]) # 先等一个间隔再重试 self.assertEqual(self.dl_state(1), "pending") self.store.set_state(1, "failed", "x") self.retry._stop = _FakeStop() self.store.retry_failed = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("库被锁")) self.retry.loop() # 第二轮:异常被吞掉 evs = [e["level"] + ":" + e["text"] for e in self.store.events(20)] self.assertTrue(any("下载队列自动重试失败" in x for x in evs), evs) class TestFailedRetryEndpoints(RetryCase): def setUp(self): super().setUp() self.router = admin.build_router(self.cfg, self.store, self.downloader) def call(self, method, path, body=b""): return self.router.dispatch(req(method, path, body=body)) def test_download_status_exposes_auto_retry(self): d = json.loads(self.call("GET", "/api/download/status").body) ar = d["auto_retry"] for k in ("enabled", "interval_sec", "last_at", "last_count", "total_count", "skipped_reason"): self.assertIn(k, ar, f"auto_retry 缺少 {k}") self.assertTrue(ar["enabled"]) self.assertEqual(ar["interval_sec"], 600) self.assertEqual(ar["last_count"], 0) def test_retry_failed_now(self): d = json.loads(self.call("POST", "/api/download/retry_failed_now").body) self.assertTrue(d["ok"]) self.assertEqual(d["reset"], 2) self.assertFalse(d["skipped"]) self.assertIn("auto_retry", d) self.assertEqual(self.dl_state(1), "pending") # 下载状态里也能看到"上次重置 2 首" st = json.loads(self.call("GET", "/api/download/status").body)["auto_retry"] self.assertEqual(st["last_count"], 2) def test_retry_failed_now_reports_skip_and_force(self): self.downloader.pause(by="user") d = json.loads(self.call("POST", "/api/download/retry_failed_now").body) self.assertTrue(d["skipped"]) self.assertIn("人工暂停", d["skipped_reason"]) self.assertEqual(self.dl_state(1), "failed") d2 = json.loads(self.call("POST", "/api/download/retry_failed_now", body=jbody({"force": True})).body) self.assertEqual(d2["reset"], 2) self.assertEqual(self.dl_state(1), "pending") def test_status_without_downloader_still_has_auto_retry(self): """未装配下载器(只读/测试)时也不能 500:按配置给一份空状态。""" router = admin.build_router(self.cfg, self.store, None) d = json.loads(router.dispatch(req("GET", "/api/download/status")).body) ar = d["auto_retry"] for k in ("enabled", "interval_sec", "last_at", "last_count", "total_count", "skipped_reason"): self.assertIn(k, ar) self.assertEqual(ar["interval_sec"], 600) # ──────────────────────────── 需求② 取址节奏 5~10 秒 ──────────────────────────── class _FakeClock: """虚拟时钟:`_pace()` 用它测"等了多少秒",**不真睡**(否则一次测试要 10 秒)。""" def __init__(self, t: float = 1000.0): self.t = float(t) self.sleeps: list[float] = [] def time(self) -> float: return self.t def sleep(self, seconds: float) -> None: self.sleeps.append(float(seconds)) self.t += float(seconds) class TestPacing(Case): def test_defaults_are_5s_to_10s(self): self.assertEqual(self.cfg.get("upstream_interval_min_ms"), 5000) self.assertEqual(self.cfg.get("upstream_interval_max_ms"), 10000) warns = self.cfg.update({"upstream_interval_min_ms": 1000}) self.assertTrue(any("攻击" in w for w in warns), warns) # 说明里写明"避免被判定为攻击" def test_pace_waits_at_least_min_and_at_most_max(self): """两次取址之间实际等待 ≥ min(且 ≤ max):用虚拟时钟精确断言。""" clock = _FakeClock() with mock.patch.object(R, "time", clock): R._last_request[0] = clock.t # 清掉真实时钟留下的时间戳 R._pace(5000, 10000) R._pace(5000, 10000) self.assertEqual(len(clock.sleeps), 2, clock.sleeps) for s in clock.sleeps: self.assertGreaterEqual(s, 5.0, f"等待小于 min: {s}") self.assertLessEqual(s, 10.0, f"等待大于 max: {s}") self.assertGreaterEqual(sum(clock.sleeps), 10.0) # 串行:两次不会重叠 def test_pace_uses_config_values(self): lo = int(self.cfg.get("upstream_interval_min_ms")) hi = int(self.cfg.get("upstream_interval_max_ms")) clock = _FakeClock() with mock.patch.object(R, "time", clock): R._last_request[0] = clock.t R._pace(lo, hi) self.assertGreaterEqual(clock.sleeps[0], lo / 1000.0) self.assertLessEqual(clock.sleeps[0], hi / 1000.0) def test_pace_is_global_serial_under_threads(self): """**全局串行**:多线程共用同一个节奏,不会叠加成并发洪水(总时长 ≈ 次数 × 间隔)。""" R._last_request[0] = time.time() n = 8 ths = [threading.Thread(target=R._pace, args=(50, 50)) for _ in range(n)] t0 = time.time() for t in ths: t.start() for t in ths: t.join(timeout=5) elapsed = time.time() - t0 self.assertGreaterEqual(elapsed, n * 0.05 * 0.75, f"没有串行限速:{elapsed:.3f}s") def test_get_paces_with_its_args(self): """`_get` 在真正发请求**之前**先 `_pace(min,max)`(i.php/do.php 都经它出去)。""" paced: list[tuple] = [] def fake_pace(lo, hi=None): paced.append((lo, hi)) return 0.0 def no_net(_req, timeout=None): # 立刻失败,不联网 raise OSError("no network in test") with mock.patch.object(R, "_pace", fake_pace), \ mock.patch.object(R.urllib.request, "urlopen", no_net): status, body = R._get("http://up.example.com/i.php?x=1", 5, "ua", 5000, 10000) self.assertEqual((status, body), (0, "")) self.assertEqual(paced, [(5000, 10000)]) def test_iphp_and_dophp_both_go_through_pace(self): """i.php(取 token)与 do.php(取地址)**都**把配置节奏交给 `_get` → `_pace()`。""" token_body = json.dumps({"code": 200, "token": "TK"}) song_body = json.dumps({"code": 200, "data": "http://cdn.example.com/480p/7200088.ts?token=x"}) seen: list[tuple] = [] def fake_get(url, timeout=20, ua="", min_interval_ms=300, max_interval_ms=None): seen.append(("i.php" in url, min_interval_ms, max_interval_ms)) return (200, token_body) if "/i.php" in url else (200, song_body) with mock.patch.object(R, "_get", fake_get): tok = R.get_token("http://up.example.com", "m", "s", min_interval_ms=5000, max_interval_ms=10000) url, detail = R.get_song_url("http://up.example.com", 7200088, "TK", "m", "s", min_interval_ms=5000, max_interval_ms=10000) self.assertEqual(tok, "TK") self.assertEqual(detail, "ok") self.assertTrue(url.endswith("7200088.ts?token=x")) # i.php + do.php 各一次,且都带 5000/10000(上一条测试已证明 _get 会用它们调 _pace) self.assertEqual(seen, [(True, 5000, 10000), (False, 5000, 10000)], seen) def test_resolver_passes_config_pacing(self): """下载线程实际取址(Resolver.resolve)用的是配置里的 5~10 秒,不是调用点的默认值。""" seen: dict = {} def fake_token(host, mac, sn, timeout=20, ua="", min_interval_ms=300, max_interval_ms=None): seen["token"] = (min_interval_ms, max_interval_ms) return "TK" def fake_url(host, tid, token, mac, sn, resolution="720", h265=False, timeout=20, ua="", min_interval_ms=300, max_interval_ms=None): seen["url"] = (min_interval_ms, max_interval_ms) return "http://cdn.example.com/480p/1.ts", "ok" self.cfg.update({"upstream_hosts": ["http://up.example.com"]}) r = R.Resolver(self.cfg, None) # 不传 store:纯逻辑 with mock.patch.object(R, "get_token", fake_token), \ mock.patch.object(R, "get_song_url", fake_url): url, host, detail = r.resolve(1) self.assertEqual(detail, "ok") self.assertEqual(seen["token"], (5000, 10000)) self.assertEqual(seen["url"], (5000, 10000)) def test_upstream_probe_does_not_bypass_pacing(self): """**一次性批量取址路径**(/api/upstream/probe 循环各源取 token)也不许绕过节奏。""" self.cfg.update({"upstream_hosts": ["http://a.example.com", "http://b.example.com"]}) router = admin.build_router(self.cfg, self.store, None) seen: list[tuple] = [] def fake_token(host, mac, sn, timeout=20, ua="", min_interval_ms=300, max_interval_ms=None): seen.append((min_interval_ms, max_interval_ms)) return "TK" with mock.patch.object(R, "fetch_config", lambda *a, **k: {}), \ mock.patch.object(R, "get_token", fake_token): d = json.loads(router.dispatch(req("POST", "/api/upstream/probe")).body) self.assertTrue(d["ok"]) self.assertEqual(len(seen), 2) # 两个源各探一次 self.assertEqual(seen, [(5000, 10000), (5000, 10000)], seen) # ──────────────────────────── 需求③ 媒体目录扫描(认领,不删除)──────────────────────────── class ScanCase(Case): MIN = 100 def write(self, name: str, size: int) -> str: path = os.path.join(self.media, name) with open(path, "wb") as fh: fh.write(b"\x47" * size) return path def keeps_row(self, tid: int): return self.store.q1("SELECT state,bytes,user,note FROM keeps WHERE tid=?", (tid,)) def scan(self, **kw): kw.setdefault("min_bytes", self.MIN) kw.setdefault("timeout_sec", 5) return self.store.keep_adopt_media(self.media, **kw) class TestMediaScanConfig(Case): def test_defaults(self): self.assertTrue(self.cfg.get("media_scan_enabled")) self.assertEqual(self.cfg.get("media_scan_interval_sec"), 600) self.assertEqual(self.cfg.get("media_scan_timeout_sec"), 30) # 默认沿用 min_file_bytes self.assertEqual(self.cfg.get("media_scan_min_bytes"), self.cfg.get("min_file_bytes")) self.assertEqual(self.cfg.media_scan_min_bytes(), self.cfg.get("min_file_bytes")) def test_zero_follows_min_file_bytes(self): self.cfg.update({"min_file_bytes": 4096, "media_scan_min_bytes": 0}) self.assertEqual(self.cfg.media_scan_min_bytes(), 4096) class TestMediaScanAdopt(ScanCase): def test_adopts_new_unknown_and_updates_existing(self): """① 合法且够大的文件被登记/更新;② 未知编号也登记;③ 非法名/过小跳过;④ **不删任何文件**。""" self.store.keep_add([1]) # 在表里但不是 done → 应被更新 self.write("1.ts", 4096) self.write("1234567.ts", 5000) # 曲库里没有 → 新增登记 self.write("7654321.ts", 5000) # 曲库里也没有 → 新增登记 self.write("bad.ts", 5000) # 文件名非法 → 跳过(且不删) self.write("8888888.ts", 10) # 过小 → 跳过(且不删) before = sorted(os.listdir(self.media)) res = self.scan() self.assertTrue(res["ok"], res) self.assertEqual(res["scanned"], 5) # 目录里 5 个 .ts self.assertEqual(res["adopted_count"], 2) self.assertEqual(sorted(res["adopted"]), [1234567, 7654321]) # 最多 10 个、升序 self.assertEqual(res["updated"], 1) self.assertEqual(res["skipped"], 2) self.assertEqual(res["freed"], 0) # 本方法不删文件 self.assertLess(res["elapsed_ms"], 5000) for tid, size in ((1, 4096), (1234567, 5000), (7654321, 5000)): row = self.keeps_row(tid) self.assertIsNotNone(row, f"{tid} 没有被登记进歌曲表") self.assertEqual((row["state"], row["bytes"]), ("done", size)) row = self.keeps_row(1234567) self.assertEqual(row["user"], "scan") self.assertEqual(row["note"], "媒体扫描自动登记") # 未知编号照常登记,只是 matched=false(曲库同步后自动补全) unmatched = self.store.keep_rows(state="unmatched", page_size=50)["items"] self.assertEqual(sorted(x["tid"] for x in unmatched), [1234567, 7654321]) self.assertIs(unmatched[0]["matched"], False) # ★ 没有任何文件被删除(目录内容一个字节都没变) self.assertEqual(sorted(os.listdir(self.media)), before) for name in before: self.assertTrue(os.path.isfile(os.path.join(self.media, name)), name) def test_already_done_is_not_counted_again(self): self.store.keep_add([1]) self.store.keep_mark_done(1, 4096, os.path.join(self.media, "1.ts")) self.write("1.ts", 4096) res = self.scan() self.assertTrue(res["ok"]) self.assertEqual((res["adopted_count"], res["updated"]), (0, 0)) self.assertEqual(res["scanned"], 1) def test_empty_dir_is_ok(self): res = self.scan() self.assertTrue(res["ok"]) self.assertEqual((res["scanned"], res["adopted_count"], res["updated"], res["skipped"]), (0, 0, 0, 0)) def test_missing_dir_returns_error_without_state_change(self): res = self.store.keep_adopt_media(os.path.join(self.tmp, "nope"), min_bytes=self.MIN, timeout_sec=2) self.assertFalse(res["ok"]) self.assertIn("不可用", res["error"]) self.assertEqual(self.store.keep_stats()["total"], 0) # 状态未变 def test_timeout_returns_error_and_changes_nothing(self): """★超时路径:打桩一个"会睡很久的列举函数" → ok=false、状态未变、耗时受控。""" self.write("1234567.ts", 5000) def slow_listing(_media_dir): time.sleep(0.6) return [("1234567.ts", 5000)] t0 = time.time() with mock.patch.object(store_mod, "_scan_media_entries", slow_listing): res = self.store.keep_adopt_media(self.media, min_bytes=self.MIN, timeout_sec=0.1) elapsed = time.time() - t0 self.assertFalse(res["ok"]) self.assertIn("超时", res["error"]) self.assertLess(elapsed, 0.5, f"调用方被阻塞了 {elapsed:.2f}s(必须按 timeout 放弃)") self.assertEqual(self.store.keep_stats()["total"], 0) # 一行都没登记 self.assertTrue(os.path.isfile(os.path.join(self.media, "1234567.ts"))) # 文件也没删 def test_listing_exception_returns_error_without_state_change(self): def boom(_media_dir): raise OSError("FUSE 掉线") with mock.patch.object(store_mod, "_scan_media_entries", boom): res = self.store.keep_adopt_media(self.media, min_bytes=self.MIN, timeout_sec=1) self.assertFalse(res["ok"]) self.assertIn("列举失败", res["error"]) self.assertEqual(self.store.keep_stats()["total"], 0) class TestMediaScanEndpoints(ScanCase): def setUp(self): super().setUp() # 接口用的是**配置里的**最小字节数(默认 6MiB);测试里调小,避免造 6MB 大文件 self.cfg.update({"media_scan_min_bytes": self.MIN}) self.router = admin.build_router(self.cfg, self.store, None) admin.MEDIA_SCAN_STATE.update({"running": False, "last_at": 0, "ok": None, "error": "", "scanned": 0, "adopted": 0, "adopted_sample": [], "updated": 0, "skipped": 0, "freed": 0, "elapsed_ms": 0}) def call(self, method, path, body=b""): return self.router.dispatch(req(method, path, body=body)) def test_scan_endpoint_and_status(self): self.write("1234567.ts", 5000) self.write("7654321.ts", 5000) self.write("bad.ts", 5000) self.write("8888888.ts", 10) before = sorted(os.listdir(self.media)) d = json.loads(self.call("POST", "/api/keeps/scan").body) self.assertTrue(d["ok"]) self.assertEqual(sorted(d["adopted"]), [1234567, 7654321]) self.assertEqual((d["updated"], d["skipped"]), (0, 2)) self.assertIn("scan", d) self.assertEqual(d["stats"]["total"], 2) # 上次扫描结果通过 /api/keeps/scan/status 暴露(时间/新增/更新/耗时/错误) st = json.loads(self.call("GET", "/api/keeps/scan/status").body)["scan"] self.assertEqual((st["adopted"], st["updated"], st["skipped"]), (2, 0, 2)) self.assertGreater(st["last_at"], 0) self.assertTrue(st["ok"]) self.assertEqual(st["error"], "") self.assertGreaterEqual(st["elapsed_ms"], 0) self.assertEqual(st["interval_sec"], 600) # /api/keeps/stats 也带 scan 字段 s = json.loads(self.call("GET", "/api/keeps/stats").body) self.assertIn("scan", s) self.assertEqual(s["total"], 2) # 绝不删除文件 self.assertEqual(sorted(os.listdir(self.media)), before) def test_scan_endpoint_reports_timeout(self): self.cfg.update({"media_scan_timeout_sec": 1}) # 配置里最低 1 秒 def slow_listing(_media_dir): time.sleep(2.0) return [] t0 = time.time() with mock.patch.object(store_mod, "_scan_media_entries", slow_listing): d = json.loads(self.call("POST", "/api/keeps/scan").body) self.assertFalse(d["ok"]) self.assertIn("超时", d["error"]) self.assertLess(time.time() - t0, 1.8, "接口必须按配置超时返回,不能一直挂着") st = json.loads(self.call("GET", "/api/keeps/scan/status").body)["scan"] self.assertFalse(st["ok"]) self.assertIn("超时", st["error"]) self.assertFalse(st["running"]) def test_scheduler_exits_when_disabled(self): for patch in ({"media_scan_enabled": False}, {"media_scan_interval_sec": 0}): self.cfg.update(patch) stop = threading.Event() th = threading.Thread(target=main.media_scan_scheduler, args=(self.cfg, self.store, stop), daemon=True) th.start() th.join(2) self.assertFalse(th.is_alive(), f"未按 {patch} 退出") def test_scheduler_runs_scan_once_and_swallows_errors(self): """守护线程真的按间隔调 run_media_scan;失败只记日志(不打断服务)。""" self.write("1234567.ts", 5000) main.media_scan_scheduler(self.cfg, self.store, _FakeStop()) # 只跑一轮 row = self.keeps_row(1234567) self.assertIsNotNone(row) self.assertEqual(row["state"], "done") with mock.patch.object(admin, "run_media_scan", side_effect=RuntimeError("FUSE 掉线")): main.media_scan_scheduler(self.cfg, self.store, _FakeStop()) evs = [e["text"] for e in self.store.events(20)] self.assertTrue(any("媒体目录扫描失败" in x for x in evs), evs) # ──────────────────────────── 管理界面契约(源码级)──────────────────────────── class TestUiContractForNewFeatures(unittest.TestCase): """界面必须能看到/操作到这三件事(否则后端做了用户也不知道)。""" def setUp(self): from ktv import ui self.html = ui.PAGE def test_keeps_tab_has_scan_button_and_last_result(self): self.assertIn("扫描媒体目录", self.html) self.assertIn("scanMedia(this)", self.html) self.assertIn('id="kScanLine"', self.html) # 上次扫描结果一行 self.assertIn("/api/keeps/scan/status", self.html) def test_tasks_tab_has_auto_retry_line_and_button(self): self.assertIn('id="retryLine"', self.html) # 「失败任务每 10 分钟自动重试…」 self.assertIn("失败任务每 ", self.html) self.assertIn("/api/download/retry_failed_now", self.html) self.assertIn("立即自动重试一次", self.html) def test_settings_expose_all_new_keys(self): for key in ("media_scan_interval_sec", "media_scan_min_bytes", "media_scan_timeout_sec", "retry_failed_interval_sec", "retry_failed_max_attempts"): self.assertIn(f"'{key}'", self.html, f"设置页缺少 {key}") for sel in ("s_media_scan_enabled", "s_retry_failed_enabled"): self.assertIn(f'id="{sel}"', self.html, f"设置页缺少 {sel}") # 设置项说明里写明目的:避免被上游判定为攻击 self.assertIn("避免被上游判定为攻击", self.html) self.assertIn("5000~10000", self.html) # ──────────────────────────── 启动顺序(源码级守卫)──────────────────────────── class TestStartupOrderAfterListen(unittest.TestCase): """媒体的守护线程必须在**端口监听之后**启动(历史事故:进程活着但不监听端口)。""" def test_new_daemons_start_after_listen(self): path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ktv", "main.py") with open(path, encoding="utf-8") as fh: src = fh.read() i_s1 = src.index("s1 = web.start_server(") i_s2 = src.index("s2 = web.start_server(") i_whoami = src.index("whoami.start()") i_retry = src.index("retry.start()") i_scan = src.index("target=media_scan_scheduler") for name, idx in (("whoami", i_whoami), ("retry", i_retry), ("media_scan", i_scan)): self.assertGreater(idx, i_s1, f"{name} 在服务端口监听之前启动了") self.assertGreater(idx, i_s2, f"{name} 在管理端口监听之前启动了") # 自动重试对象在监听之前只装配、不 start self.assertLess(src.index("retry = FailedRetry("), i_s2) if __name__ == "__main__": unittest.main(verbosity=2)