Files
DerrorsandClaude Opus 5.5 a931af3861 test: 测试按模块重组,共用替身移到 tests/fakes.py
- 按被测模块组织(test_pipeline、test_storage、test_web_auth 等),删除按开发阶段命名的文件与失效的 smoke_console.py
- FakeAI、FakeTelegram、样例消息与 PipelineCase / ServiceCase / WebCase 基类集中到 tests/fakes.py
- 补充测试:季集识别、访问守卫与跨站写、密钥随地址清除、批量操作计划、StorageBusy 延后、风控暂停、
  cookie 先验后存、密钥原子更新、配置快照、心跳限频、通知清理、Telegram 等待配置与补拉水位

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 23:15:04 +08:00

463 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""管理接口:总览、任务、日志、前端托管、115 登录、AI 接口、实时推送。"""
from __future__ import annotations
import asyncio
import time
import unittest
from unittest.mock import patch
from reelay.core import secret_store
from reelay.core.contracts import ParsedResource, PipelineContext, RawMessage, TaskState
from tests.fakes import MANUAL_REGEX, SHARE, TestClient, WebCase, message
class TestManagementApi(WebCase):
CONFIG = MANUAL_REGEX
def prepare(self, root):
self.frontend = root / "frontend"
self.frontend.mkdir()
(self.frontend / "index.html").write_text("<html>Reelay app</html>", encoding="utf-8")
(self.frontend / "app.js").write_text("safe asset", encoding="utf-8")
self.secret = root / "private.txt"
self.secret.write_text("MUST_NOT_BE_EXPOSED", encoding="utf-8")
(self.frontend / "linked-secret.txt").symlink_to(self.secret)
def test_health_dashboard_and_config(self):
self.assertEqual(self.client.get("/api/health").json()["ok"], True)
self.assertEqual(self.client.get("/api/dashboard").status_code, 200)
response = self.client.get("/api/config")
self.assertEqual(response.status_code, 200)
self.assertIn("save_dirs", response.json()["config"])
self.assertNotIn("REELAY_115_COOKIES", response.text)
def test_logs_can_be_cleared(self):
root = self.root
(root / "reelay.log").write_text("2026-10-23 10:00:00 INFO reelay | 一行\n", encoding="utf-8")
(root / "reelay.log.1").write_text("旧日志\n", encoding="utf-8")
(root / "reelay.log.2").write_text("更旧\n", encoding="utf-8")
(root / "reelay.log.bak").write_text("不是轮转文件\n", encoding="utf-8")
self.assertEqual(len(self.client.get("/api/logs").json()["lines"]), 1)
response = self.client.post("/api/logs/clear")
self.assertEqual(response.status_code, 200)
self.assertGreater(response.json()["freed"], 0)
self.assertEqual(self.client.get("/api/logs").json(), {"lines": [], "path": str(root / "reelay.log"), "exists": True})
self.assertFalse((root / "reelay.log.1").exists() or (root / "reelay.log.2").exists())
self.assertTrue((root / "reelay.log.bak").exists())
def test_organize_preview(self):
response = self.client.post("/api/extensions/organizer/actions/preview_plan", json={
"parsed": {
"is_media": True,
"media_type": "tv",
"title": "测试剧",
"year": 2026,
"season": 1,
"episodes": [1],
},
"landed": [{"id": "one", "name": "S01E01.mkv"}],
})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["items"][0]["target_name"], "测试剧 - S01E01 - 第 1 集.mkv")
def test_spa_routes_and_assets_still_work(self):
self.assertEqual(self.client.get('/app.js').text, 'safe asset')
self.assertIn('Reelay app', self.client.get('/tasks/123').text)
def test_traversal_and_symlinks_cannot_read_files_outside_frontend(self):
for path in (
'/%2e%2e/private.txt', '/%2e%2e%2fprivate.txt',
'/nested/%2e%2e/%2e%2e/private.txt', '/linked-secret.txt',
'/%2F' + str(self.secret).lstrip('/'),
):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 404)
self.assertNotIn('MUST_NOT_BE_EXPOSED', response.text)
def test_spa_index_symlink_cannot_bypass_boundary_check(self):
(self.frontend / 'index.html').unlink()
(self.frontend / 'index.html').symlink_to(self.secret)
response = self.client.get('/tasks')
self.assertEqual(response.status_code, 404)
self.assertNotIn('MUST_NOT_BE_EXPOSED', response.text)
def test_reconcile_preserves_manifest_and_only_queues_readback(self):
from reelay.core.contracts import ParsedResource, TaskState
ctx = PipelineContext(RawMessage('@test', '1', 'test'))
ctx.parsed = ParsedResource(is_media=True, share_code='share')
ctx.state = TaskState.NEED_MANUAL
storage = ctx.ns('storage')
storage.update({'phase': 'receiving', 'manifest': {'staging_cid': 123, 'files': []}})
self.app.state.store.save_task(ctx)
response = self.client.post(f'/api/tasks/{ctx.task_id}/reconcile')
self.assertEqual(response.status_code, 200)
restored = self.app.state.store.get_task(ctx.task_id)
self.assertEqual(restored.state, TaskState.QUEUED)
self.assertEqual(restored.ns('storage')['phase'], 'receiving')
self.assertEqual(restored.ns('storage')['manifest'], storage['manifest'])
self.assertEqual(self.client.post(f'/api/tasks/{ctx.task_id}/reparse').status_code, 409)
self.assertEqual(self.client.post(f'/api/tasks/{ctx.task_id}/reorganize', json={'conflict': 'keep'}).status_code, 422)
def test_config_validation_returns_422_without_replacing_valid_file(self):
before = self.config_path.read_text()
for config in ({"defaults": {"approval_mode": []}}, {"defaults": {"confidence_threshold": 3}},
{"defaults": {"parser": "unknown"}}, {"approval": {"on_timeout": []}}):
with self.subTest(config=config):
self.assertEqual(self.client.post("/api/config/validate", json={"config": config}).status_code, 422)
revision = self.client.get("/api/config").json()["revision"]
response = self.client.patch("/api/config/sections", json={"revision": revision, "sections": config})
self.assertEqual(response.status_code, 422)
self.assertEqual(self.config_path.read_text(), before)
response = self.client.patch("/api/config/sections", content=b"[invalid", headers={"Content-Type": "application/json"})
self.assertEqual(response.status_code, 422) # 不是合法 JSON:422 而不是 500
self.assertEqual(self.config_path.read_text(), before)
def test_health_reports_runtime_and_does_not_treat_credentials_as_connected(self):
from reelay.core import auth115
secret_store.current().update({"telegram_api_id": "1", "telegram_api_hash": "dummy"})
auth115.get_store().set_cookies("UID=1;CID=2;SEID=3")
dashboard = self.client.get("/api/dashboard").json()
self.assertEqual(dashboard["mode"], "not_running")
self.assertEqual(dashboard["connections"]["p115"]["status"], "unverified")
self.app.state.store.heartbeat("worker", status="running", config_revision=self.app.state.pipeline.revision)
self.app.state.store.heartbeat("source", status="connected")
dashboard = self.client.get("/api/dashboard").json()
self.assertEqual(dashboard["mode"], "live")
self.assertEqual(dashboard["connections"]["telegram"]["status"], "connected")
self.assertEqual(dashboard["connections"]["p115"]["status"], "unverified")
self.assertIn("worker", self.client.get("/api/health").json()["runtime"])
def test_dashboard_reports_115_cookie_probe_within_its_check_interval(self):
store = self.app.state.store
def p115_status():
return self.client.get("/api/dashboard").json()["connections"]["p115"]["status"]
store.heartbeat("p115_cookie", status="unknown", error="未配置 cookie", check_interval=300)
self.assertIn(p115_status(), {"unverified", "not_configured"})
store.heartbeat("p115_cookie", status="active", error="", check_interval=300)
with patch("time.time", return_value=time.time() + 120): # 普通心跳 30 秒即过期,探活按自身间隔
self.assertEqual(p115_status(), "active")
with patch("time.time", return_value=time.time() + 601):
self.assertEqual(p115_status(), "stale")
store.heartbeat("p115_cookie", status="expired", error="登录失效", check_interval=30)
self.assertEqual(p115_status(), "expired")
def test_failed_delivery_can_be_requeued_without_mutating_resource_task(self):
from reelay.core.contracts import TaskState
store = self.app.state.store
ctx = PipelineContext(RawMessage("@test", "outbox", "text"))
ctx.state = TaskState.DONE
ctx.ns("deliveries")["notification"] = {"kind": "notify", "event": "saved", "target": "console"}
store.save_task(ctx)
row = store.claim_deliveries()[0]
for _ in range(8):
store.finish_delivery(row["id"], error="TimeoutError")
response = self.client.get("/api/deliveries?status=failed")
self.assertEqual(response.status_code, 200)
self.assertNotIn("snapshot", response.text)
self.assertEqual(response.json()["items"][0]["attempts"], 8)
self.assertEqual(self.client.post(f"/api/deliveries/{row['id']}/retry").status_code, 200)
self.assertEqual(self.client.post(f"/api/deliveries/{row['id']}/retry").status_code, 409)
self.assertEqual(store.get_task(ctx.task_id).state, TaskState.DONE)
self.assertEqual(store.delivery_counts(), {"pending": 1})
def test_invalid_runtime_config_remains_editable_and_keeps_last_valid_version(self):
before = self.app.state.pipeline.revision
self.config_path.write_text("[invalid")
response = self.client.get("/api/config")
self.assertEqual(response.status_code, 200)
self.assertIn("save_dirs", response.json()["config"]) # 仍是上一有效版本
self.assertIn(str(self.config_path), response.json()["config_error"]) # 说清是哪个文件,好去修
self.assertEqual(self.app.state.pipeline.revision, before)
response = self.client.patch("/api/config/sections", json={"revision": response.json()["revision"],
"sections": {"defaults": {"save_to": "默认"}}})
self.assertEqual(response.status_code, 409) # 不覆盖损坏的文件
self.assertEqual(self.config_path.read_text(), "[invalid")
@unittest.skipIf(TestClient is None, "未安装 web 可选依赖")
class TestConsoleApi(WebCase):
"""新管理台用到的聚合接口:收件箱、带媒体字段的任务列表、扩展目录去重。"""
CONFIG = {"save_dirs": {"默认": "1", "剧集": "2"}, "defaults": {"save_to": "默认"}}
def setUp(self):
super().setUp()
store = self.app.state.store
for index, state in enumerate((TaskState.PENDING_APPROVAL, TaskState.PENDING_APPROVAL,
TaskState.NEED_MANUAL, TaskState.QUEUED, TaskState.DONE)):
ctx = message(msg_id=str(index), text=f"剧 第{index}集\n{SHARE}")
ctx.parsed = ParsedResource(is_media=True, title=f"剧{index}", media_type="tv", season=1,
episodes=[index], resolution="4K", size="1 GB", share_code="abc",
raw_fields={"poster_path": f"/p{index}.jpg"})
ctx.state = state
ctx.ns("confidence").update(score=0.4, caps=["tmdb_unmatched"])
store.save_task(ctx, error="同名冲突" if state == TaskState.NEED_MANUAL else None)
def test_inbox_returns_card_fields_without_links(self):
body = self.client.get("/api/inbox").json()
self.assertEqual(body["save_dirs"], ["默认", "剧集"])
self.assertEqual(len(body["items"]), 2)
item = body["items"][0]
self.assertEqual((item["media_type"], item["resolution"], item["confidence_caps"]), ("tv", "4K", ["tmdb_unmatched"]))
self.assertTrue(item["poster_path"].startswith("/p"))
self.assertEqual((item["save_dir_alias"], item["auto_dir"]), (None, "剧集/其他剧集")) # 没有地区的剧集归入兜底分类
self.assertNotIn("http", item["preview"])
manual = self.client.get("/api/inbox?state=need_manual").json()["items"]
self.assertEqual([m["error"] for m in manual], ["同名冲突"])
self.assertEqual(self.client.get("/api/inbox?state=done").status_code, 422)
def test_task_list_supports_multiple_states_paging_and_media_fields(self):
items = self.client.get("/api/tasks?state=pending_approval,queued&limit=2").json()["items"]
self.assertEqual(len(items), 2)
self.assertTrue({i["state"] for i in items} <= {"pending_approval", "queued"})
self.assertEqual(items[0]["episodes"], [int(items[0]["title"][-1])])
rest = self.client.get("/api/tasks?state=pending_approval,queued&limit=2&offset=2").json()["items"]
self.assertEqual(len(rest), 1)
def test_batch_approve_can_redirect_target_directory(self):
ids = [i["task_id"] for i in self.client.get("/api/inbox").json()["items"]]
result = self.client.post("/api/tasks/batch", json={"task_ids": ids, "action": "approve",
"save_dir_alias": "剧集"}).json()
self.assertEqual(result["done"], 2)
store = self.app.state.store
self.assertEqual({store.get_task(i).save_dir_alias for i in ids}, {"剧集"})
def test_library_counts_multi_season_pack_by_season_and_episode(self):
store = self.app.state.store
for msg_id, pairs in (("m1", [[1, 1], [1, 2], [2, 1], [2, 2]]), ("m2", [[3, 1]])):
ctx = message(msg_id=msg_id)
ctx.parsed = ParsedResource(is_media=True, title="多季剧", media_type="tv", share_code="abc",
season=None if len(pairs) > 1 else 3, episodes=sorted({e for _, e in pairs}))
ctx.state = TaskState.DONE
ctx.ns("storage")["manifest"] = {"pairs": pairs}
store.save_task(ctx)
group = next(g for g in self.client.get("/api/library").json()["groups"] if g["title"] == "多季剧")
self.assertEqual(group["episode_pairs"], [[1, 1], [1, 2], [2, 1], [2, 2], [3, 1]])
single = next(g for g in self.client.get("/api/library").json()["groups"] if g["title"] == "剧4")
self.assertIsNone(single["episode_pairs"])
def test_library_skips_files_replaced_by_overwrite_or_upgrade(self):
store = self.app.state.store
def done(msg_id, title, landed, media_type="movie", size=None, episodes=(), conflicts=()):
ctx = message(msg_id=msg_id)
ctx.parsed = ParsedResource(is_media=True, title=title, media_type=media_type, share_code="abc",
season=1 if episodes else None, episodes=list(episodes), size=size)
ctx.state = TaskState.DONE
ctx.ns("storage")["landed"] = [{"id": fid, "name": f"{fid}.mkv", "size": gb * 1024 ** 3} for fid, gb in landed]
ctx.ns("organize").update(conflicts=list(conflicts), plan=[
{"src_id": fid, "target_dir": title, "target_name": f"{title} {fid}.mkv"} for fid, _ in landed])
store.save_task(ctx)
done("r1", "空枪", [("10", 33)], size="33 GB")
done("r2", "空枪", [("11", 32)], size="32 GB",
conflicts=[{"target": "空枪/空枪.mkv", "action": "replace", "existing_id": "10"}])
done("r3", "合集剧", [("20", 1), ("21", 2)], media_type="tv", size="3 GB", episodes=(1, 2))
done("r4", "合集剧", [("22", 4)], media_type="tv", size="4 GB", episodes=(2,),
conflicts=[{"target": "合集剧/E02.mkv", "action": "upgrade", "existing_id": "21"},
{"target": "x", "action": "skip", "existing_id": "20"}]) # 保留旧文件的不算替换
self.assertEqual(store.replaced_file_ids(), {"10", "21"})
groups = {g["title"]: g for g in self.client.get("/api/library").json()["groups"]}
self.assertEqual((groups["空枪"]["tasks"], groups["空枪"]["files"], round(groups["空枪"]["bytes"] / 1024 ** 3)),
(1, ["空枪/空枪 11.mkv"], 32))
self.assertEqual((groups["合集剧"]["tasks"], sorted(groups["合集剧"]["files"]), round(groups["合集剧"]["bytes"] / 1024 ** 3)),
(2, ["合集剧/合集剧 20.mkv", "合集剧/合集剧 22.mkv"], 5))
self.assertEqual(groups["合集剧"]["episodes"], [1, 2])
items = {g["title"]: g for g in self.app.state.service.library_query()["items"]}
self.assertEqual((items["空枪"]["file_count"], items["合集剧"]["file_count"]), (1, 2))
def test_dashboard_has_recent_counts_and_catalog_merges_multi_event_extensions(self):
self.assertEqual(self.client.get("/api/dashboard").json()["recent"]["received_24h"], 5)
events = self.client.get("/api/config").json()["extensions"]["events"]
tracker = [e for e in events if e["name"] == "episode_tracker"]
self.assertEqual(len(tracker), 1)
self.assertEqual(set(tracker[0]["slot"].split(",")), {"on_saved", "on_daily"})
class TestAuth115Api(WebCase):
"""115 混合鉴权 Web API:状态、cookie 录入校验、开放平台扫码流程。"""
def prepare(self, root):
from reelay.core import auth115
self.auth115 = auth115
self.cred_store = auth115.CredentialStore(root / "creds.json")
singleton = patch.object(auth115, "get_store", lambda: self.cred_store)
singleton.start()
self.addCleanup(singleton.stop)
def test_status_empty(self):
data = self.client.get("/api/auth115/status").json()
self.assertFalse(data["cookie"]["configured"])
self.assertFalse(data["oauth"]["configured"])
self.assertEqual(data["writes_via"], "none")
def test_cookie_rejects_garbage(self):
resp = self.client.put("/api/auth115/cookie", json={"cookies": "not-a-cookie"})
self.assertEqual(resp.status_code, 422)
def test_cookie_is_probed_before_it_replaces_the_saved_one(self):
from reelay.core.auth115 import AuthState
cookies = "UID=1_A1_2;CID=c;SEID=s"
for state, error, status in ((AuthState.EXPIRED, "", 409), (AuthState.ERROR, "timeout", 409)):
with self.subTest(state=state), patch.object(self.cred_store, "probe_cookies", return_value=(state, error)):
self.assertEqual(self.client.put("/api/auth115/cookie", json={"cookies": cookies}).status_code, status)
self.assertFalse(self.cred_store.has_cookie) # 失效或验证不了的 cookie 不保存
with patch.object(self.cred_store, "probe_cookies", return_value=(AuthState.ACTIVE, "")), \
patch.object(self.cred_store, "ping_cookie", return_value=AuthState.ACTIVE):
response = self.client.put("/api/auth115/cookie", json={"cookies": cookies})
self.assertEqual(response.json(), {"ok": True, "state": AuthState.ACTIVE})
self.assertTrue(self.cred_store.has_cookie)
def test_cookie_qr_start_status_and_image(self):
png_bytes = b"\x89PNG\r\n\x1a\nfake-cookie"
class FakeP115Cookie:
@staticmethod
def login_qrcode_token():
return {"state": 1, "data": {"uid": "cu9", "time": 1, "sign": "s",
"qrcode": "https://115.com/scan/dg-cu9"}}
@staticmethod
def login_qrcode_scan_status(payload):
return {"state": 1, "data": {"status": 1}}
@staticmethod
def login_qrcode_scan_result(uid, app):
return {"state": 1, "data": {"cookie": {
"UID": "1_A1_2", "CID": "c", "SEID": "s", "KID": "k",
}}}
@staticmethod
def login_qrcode(payload, app="web"):
return png_bytes
fake = type("M", (), {"P115Client": FakeP115Cookie})()
with patch.dict("sys.modules", {"p115client": fake}):
start = self.client.post("/api/auth115/cookie/qr")
self.assertEqual(start.status_code, 200, start.text)
uid = start.json()["uid"]
image = self.client.get(f"/api/auth115/cookie/qr/image?uid={uid}")
self.assertEqual(image.status_code, 200)
self.assertEqual(image.headers["content-type"], "image/svg+xml")
self.assertIn(b"<svg", image.content)
waiting = self.client.get(f"/api/auth115/cookie/qr?uid={uid}")
self.assertEqual(waiting.json()["status"], 1)
FakeP115Cookie.login_qrcode_scan_status = staticmethod(
lambda payload: {"state": 1, "data": {"status": 2}})
with patch.object(self.cred_store, "ping_cookie", return_value="active"):
done = self.client.get(f"/api/auth115/cookie/qr?uid={uid}")
self.assertTrue(done.json()["authenticated"])
self.assertTrue(self.cred_store.has_cookie)
def test_qr_start_then_scan_confirmed_saves_tokens(self):
store = self.cred_store
class FakeP115Open:
@staticmethod
def login_qrcode_token_open(app_id):
return {"code": 0, "data": {"uid": "u9", "time": 1, "sign": "s",
"qrcode": "https://115.com/scan/dg-u9"}}
@staticmethod
def login_qrcode_scan_status(payload):
return {"data": {"status": 2}}
@staticmethod
def login_qrcode_access_token_open(uid):
return {"data": {"access_token": "wa", "refresh_token": "wr"}}
fake = type("M", (), {"P115OpenClient": FakeP115Open})()
with patch.dict("sys.modules", {"p115client": fake}):
r1 = self.client.post("/api/auth115/oauth/qr")
self.assertEqual(r1.status_code, 200, r1.text)
uid = r1.json()["uid"]
r2 = self.client.get(f"/api/auth115/oauth/qr?uid={uid}")
self.assertEqual(r2.status_code, 200)
self.assertTrue(r2.json()["authenticated"])
self.assertTrue(store.has_oauth)
self.assertTrue(self.client.get("/api/auth115/status").json()
["oauth"]["configured"])
self.assertEqual(self.client.delete("/api/auth115/oauth").status_code, 200)
self.assertFalse(store.has_oauth)
def test_qr_image_encodes_the_open_platform_link(self):
# 授权二维码必须是开放平台返回的 qrcode 链接;网页登录的二维码图片 App 扫了提示参数错误
import io
import qrcode
from qrcode.image.svg import SvgPathImage
class FakeP115Open:
@staticmethod
def login_qrcode_token_open(app_id):
return {"code": 0, "data": {"uid": "u8", "time": 1, "sign": "s",
"qrcode": "https://115.com/scan/dg-u8"}}
@staticmethod
def login_qrcode(payload, app="web"):
raise AssertionError("不能用网页登录的二维码图片")
@staticmethod
def login_qrcode_scan_status(payload):
return {"state": 1, "code": 0, "data": {}} # 还没扫码时 115 返回空的 data
expected = io.BytesIO()
qrcode.make("https://115.com/scan/dg-u8", image_factory=SvgPathImage).save(expected)
fake = type("M", (), {"P115OpenClient": FakeP115Open})()
with patch.dict("sys.modules", {"p115client": fake}):
uid = self.client.post("/api/auth115/oauth/qr").json()["uid"]
r2 = self.client.get(f"/api/auth115/oauth/qr/image?uid={uid}")
self.assertEqual(r2.status_code, 200, r2.text)
self.assertEqual(r2.headers["content-type"], "image/svg+xml")
self.assertEqual(r2.content, expected.getvalue())
status = self.client.get(f"/api/auth115/oauth/qr?uid={uid}").json()
self.assertEqual((status["status"], status["message"]), (0, "等待扫码"))
# 未知 uid 返回 404
r3 = self.client.get("/api/auth115/oauth/qr/image?uid=nope")
self.assertEqual(r3.status_code, 404)
class TestAiRoutes(WebCase):
"""HTTP 层:新接口已注册、参数与错误码正确(不访问网络,未配置 AI)。"""
def test_routes_answer_without_ai(self):
for path in ("/api/insights", "/api/ai/usage", "/api/subscriptions", "/api/sources/quality",
"/api/library?media_type=anime", "/api/stats"):
self.assertEqual(self.client.get(path).status_code, 200, path)
self.assertEqual(self.client.post("/api/ai/compile-rules", json={"text": "国产剧"}).status_code, 409)
self.assertEqual(self.client.post("/api/sources/draft", json={"bot": "@x", "samples": ["a"]}).status_code, 409)
self.assertEqual(self.client.post("/api/inbox/advice", json={"task_ids": ["x"]}).json(), {"items": {}})
added = self.client.post("/api/subscriptions", json={"title": "剧", "season": 2})
self.assertEqual({k: added.json()["subscription"][k] for k in ("title", "season")}, {"title": "剧", "season": 2})
self.assertEqual(self.client.post("/api/subscriptions", json={"title": "剧", "season": 2}).status_code, 409)
self.assertEqual(self.client.post("/api/subscriptions/remove", json={"title": "剧", "season": 2}).status_code, 200)
self.assertEqual(self.client.post("/api/subscriptions/from-task/missing").status_code, 404)
self.assertEqual(self.client.post("/api/insights/auto-approve", json={"source": "@new"}).json()["config"]["sources"],
[{"bot": "@new", "approval_mode": "auto"}])
class TestRealtimeShutdown(unittest.IsolatedAsyncioTestCase):
async def test_close_all_streams_ends_open_sse_even_when_queue_is_full(self):
# 停止时不结束推送流,uvicorn 会一直等这条长连接,直到 systemd / Docker 超时强杀
from reelay.web.realtime import EventBus, close_all_streams, event_stream
bus = EventBus(store=None)
stream = event_stream(bus)
self.assertEqual(await anext(stream), b": connected\n\n")
await anext(stream) # hello
for _ in range(64): # 浏览器卡住,队列积满
bus._broadcast({"type": "ping"})
close_all_streams()
async def drain():
return [frame async for frame in stream]
frames = await asyncio.wait_for(drain(), timeout=2)
self.assertEqual(len(frames), 63) # 丢掉最旧的一帧,放下结束标记
self.assertEqual(bus._subscribers, set())
if __name__ == "__main__":
unittest.main()