feat: Python/Java SDK 接入清单与打包文档

对真实 nixmsg 跑 DEVELOPMENT 第 9 节接入清单(跳过仅 JS 跨域),补 README/示例,并修 Paho/HiveMQ 真机联调死锁与鉴权分类。
This commit is contained in:
Nixevol
2026-09-30 08:57:06 +08:00
parent 2f750bcb0f
commit 1f715e4e51
15 changed files with 1952 additions and 148 deletions
+498
View File
@@ -0,0 +1,498 @@
"""DEVELOPMENT 第 9 节接入清单(对真实 nixmsg;跳过仅 JS 跨域)。"""
from __future__ import annotations
import threading
import time
import unittest
from typing import Optional
from nixmsg import (
Body,
Client,
ConnectionState,
NixMsgError,
Receipt,
RegisterOptions,
SendOptions,
Target,
)
from nixmsg.uuid7 import new_uuid7
from .harness import NixMsgServer
IMMEDIATE = SendOptions(delay_ms=0)
def wait_until(pred, timeout: float = 15.0, interval: float = 0.05) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if pred():
return True
time.sleep(interval)
return False
class MessageBox:
def __init__(self) -> None:
self.items: list = []
self.lock = threading.Lock()
self.event = threading.Event()
def on_message(self, msg) -> None:
with self.lock:
self.items.append(msg)
self.event.set()
def wait_n(self, n: int, timeout: float = 15.0):
deadline = time.time() + timeout
while time.time() < deadline:
with self.lock:
if len(self.items) >= n:
return list(self.items)
self.event.wait(0.1)
self.event.clear()
with self.lock:
return list(self.items)
def clear(self) -> None:
with self.lock:
self.items.clear()
self.event.clear()
class ReceiptBox:
def __init__(self) -> None:
self.items: list[Receipt] = []
self.lock = threading.Lock()
self.event = threading.Event()
def on_receipt(self, r: Receipt) -> None:
with self.lock:
self.items.append(r)
self.event.set()
def wait_state(self, state: str, timeout: float = 15.0) -> Optional[Receipt]:
deadline = time.time() + timeout
while time.time() < deadline:
with self.lock:
for r in self.items:
if r.state == state:
return r
self.event.wait(0.1)
self.event.clear()
return None
class ChecklistIT(unittest.TestCase):
srv: NixMsgServer
seq = 0
@classmethod
def setUpClass(cls) -> None:
cls.srv = NixMsgServer()
cls.srv.start()
@classmethod
def tearDownClass(cls) -> None:
cls.srv.stop()
def _uid(self, prefix: str) -> str:
ChecklistIT.seq += 1
return f"{prefix}{ChecklistIT.seq:04d}"
def _register(self, eid: str, password: str = "password12", name: str = "", code: Optional[str] = None):
return Client.register(
self.srv.ws_url,
code if code is not None else self.srv.reg_code,
RegisterOptions(id=eid, login_password=password, name=name or eid),
)
def _connect(self, eid: str, password: str = "password12", **kwargs) -> Client:
c = Client(**kwargs)
c.connect(self.srv.ws_url, eid, password=password, wait=True)
self.assertEqual(c.state, ConnectionState.ONLINE)
return c
def test_01_handshake(self) -> None:
eid = self._uid("hs")
self._register(eid)
tokens: list[str] = []
c = Client()
c.on_session(lambda t: tokens.append(t))
c.connect(self.srv.ws_url, eid, password="password12")
self.assertEqual(c.state, ConnectionState.ONLINE)
self.assertTrue(c.limits.server_time_ms > 0)
self.assertGreaterEqual(c.limits.max_body_bytes, 256 * 1024)
self.assertTrue(tokens and tokens[0].startswith("nst_"))
c.close()
def test_02_dm_callback_once(self) -> None:
a, b = self._uid("a2"), self._uid("b2")
self._register(a)
self._register(b)
ca, cb = self._connect(a), self._connect(b)
box = MessageBox()
cb.on_message(box.on_message)
mid = new_uuid7()
ca.send(Target("endpoint", b), Body(data="hello-once"), SendOptions(delay_ms=0, message_id=mid))
got = box.wait_n(1, 10)
self.assertEqual(len(got), 1)
self.assertEqual(got[0].id, mid)
self.assertEqual(got[0].body.data, "hello-once")
time.sleep(0.5)
self.assertEqual(len(box.wait_n(1, 0.2)), 1)
ca.close()
cb.close()
def test_03_send_while_disconnected_no_dup(self) -> None:
a, b = self._uid("a3"), self._uid("b3")
self._register(a)
self._register(b)
ca, cb = self._connect(a), self._connect(b)
box = MessageBox()
cb.on_message(box.on_message)
mid = new_uuid7()
# 断开发送方传输,触发重连;期间入队发送
ca._transport.disconnect()
self.assertTrue(wait_until(lambda: ca.state == ConnectionState.RECONNECTING, 5))
err: list[BaseException] = []
result: list = []
def do_send() -> None:
try:
result.append(
ca.send(
Target("endpoint", b),
Body(data="queued"),
SendOptions(delay_ms=0, message_id=mid),
)
)
except BaseException as e:
err.append(e)
th = threading.Thread(target=do_send, daemon=True)
th.start()
th.join(timeout=60)
self.assertFalse(err, err)
self.assertTrue(result)
self.assertEqual(result[0].id, mid)
self.assertTrue(wait_until(lambda: ca.state == ConnectionState.ONLINE, 30))
got = box.wait_n(1, 15)
self.assertEqual(len(got), 1)
self.assertEqual(got[0].id, mid)
time.sleep(0.8)
self.assertEqual(len(box.items), 1)
ca.close()
cb.close()
def test_04_same_message_id_and_dedup_unit_covered(self) -> None:
"""同消息号重交:断线入队后送达一次。ack 丢失重推依赖 FakeTransport 单测(真实 broker 无法选择性丢 ack)。"""
a, b = self._uid("a4"), self._uid("b4")
self._register(a)
self._register(b)
ca, cb = self._connect(a), self._connect(b)
box = MessageBox()
cb.on_message(box.on_message)
mid = new_uuid7()
r1 = ca.send(Target("endpoint", b), Body(data="idem"), SendOptions(delay_ms=0, message_id=mid))
self.assertEqual(r1.id, mid)
got = box.wait_n(1, 10)
self.assertEqual(len(got), 1)
# 同号同内容再发:服务器防重,回调仍只有一次
r2 = ca.send(Target("endpoint", b), Body(data="idem"), SendOptions(delay_ms=0, message_id=mid))
self.assertEqual(r2.id, mid)
time.sleep(0.8)
self.assertEqual(len(box.items), 1)
ca.close()
cb.close()
def test_05_recall_within_delay(self) -> None:
a, b = self._uid("a5"), self._uid("b5")
self._register(a)
self._register(b)
ca, cb = self._connect(a), self._connect(b)
box = MessageBox()
revoked: list = []
cb.on_message(box.on_message)
cb.on_revoked(lambda e: revoked.append(e))
mid = new_uuid7()
r = ca.send(
Target("endpoint", b),
Body(data="will-recall"),
SendOptions(delay_ms=10_000, message_id=mid),
)
self.assertEqual(r.state, "scheduled")
ca.recall(mid)
time.sleep(1.2)
self.assertEqual(box.items, [])
self.assertEqual(revoked, [])
ca.close()
cb.close()
def test_06_scheduled_about_2s(self) -> None:
a, b = self._uid("a6"), self._uid("b6")
self._register(a)
self._register(b)
ca, cb = self._connect(a), self._connect(b)
box = MessageBox()
cb.on_message(box.on_message)
mid = new_uuid7()
t0 = time.monotonic()
ca.send(Target("endpoint", b), Body(data="later"), SendOptions(delay_ms=2000, message_id=mid))
got = box.wait_n(1, 12)
elapsed = time.monotonic() - t0
self.assertEqual(len(got), 1)
self.assertEqual(got[0].id, mid)
self.assertGreaterEqual(elapsed, 1.5)
self.assertLess(elapsed, 8.0)
ca.close()
cb.close()
def test_07_offline_keep(self) -> None:
a, b_ok, b_miss = self._uid("a7"), self._uid("bok"), self._uid("bms")
self._register(a)
self._register(b_ok)
self._register(b_miss)
ca = self._connect(a)
# 对方晚约 1 秒上线能收到
mid1 = new_uuid7()
ca.send(
Target("endpoint", b_ok),
Body(data="keep-ok"),
SendOptions(delay_ms=0, keep=True, ttl_seconds=86400, message_id=mid1),
)
time.sleep(1.0)
cb1 = self._connect(b_ok)
box1 = MessageBox()
cb1.on_message(box1.on_message)
got1 = box1.wait_n(1, 10)
self.assertEqual(len(got1), 1)
self.assertEqual(got1[0].id, mid1)
cb1.close()
# 保留 1 秒且 3 秒后才上线则收不到,发送方收到过期回执
receipts = ReceiptBox()
ca.on_receipt(receipts.on_receipt)
mid2 = new_uuid7()
ca.send(
Target("endpoint", b_miss),
Body(data="keep-expire"),
SendOptions(delay_ms=0, keep=True, ttl_seconds=1, message_id=mid2),
)
time.sleep(3.5)
cb2 = self._connect(b_miss)
box2 = MessageBox()
cb2.on_message(box2.on_message)
time.sleep(1.5)
self.assertEqual(box2.items, [])
exp = receipts.wait_state("expired", 15)
if exp is None:
# 回执可能略慢:用 status 核对投递已过期
st = ca.status(mid2)
items = (st.get("data") or st).get("items") if isinstance(st.get("data") or st, dict) else None
# status 顶层即 data
data = st if "items" in st else (st.get("data") or {})
items = data.get("items") or data.get("deliveries") or []
states = [str(i.get("state", "")) for i in items] if isinstance(items, list) else []
self.assertTrue(
"expired" in states or any(r.state == "expired" for r in receipts.items),
f"want expired receipt/status, receipts={[r.state for r in receipts.items]} status={st}",
)
else:
self.assertEqual(exp.id, mid2)
ca.close()
cb2.close()
def test_08_group_sender_no_echo(self) -> None:
a, b, c = self._uid("a8"), self._uid("b8"), self._uid("c8")
self._register(a)
self._register(b)
self._register(c)
ca, cb, cc = self._connect(a), self._connect(b), self._connect(c)
gid = f"g_{a}"
ca.group_create("G", [{"id": b}, {"id": c}], group_id=gid)
time.sleep(0.4)
box_a, box_b, box_c = MessageBox(), MessageBox(), MessageBox()
ca.on_message(box_a.on_message)
cb.on_message(box_b.on_message)
cc.on_message(box_c.on_message)
mid = new_uuid7()
ca.send(Target("group", gid), Body(data="hi-g"), SendOptions(delay_ms=0, message_id=mid))
gb = box_b.wait_n(1, 10)
gc = box_c.wait_n(1, 10)
self.assertEqual(len(gb), 1)
self.assertEqual(len(gc), 1)
self.assertEqual(gb[0].id, mid)
self.assertEqual(gc[0].id, mid)
time.sleep(0.8)
self.assertEqual(box_a.items, [])
ca.close()
cb.close()
cc.close()
def test_09_talk_password(self) -> None:
a, b = self._uid("a9"), self._uid("b9")
self._register(a)
self._register(b)
ca, cb = self._connect(a), self._connect(b)
cb.set_talk_password("talk99")
# 拒绝
with self.assertRaises(NixMsgError) as cm:
ca.send(Target("endpoint", b), Body(data="no"), IMMEDIATE)
self.assertIn(cm.exception.code, ("talk_password_required", "talk_password_invalid"))
# 解锁
ca.unlock(b, "talk99")
mid = new_uuid7()
ca.send(Target("endpoint", b), Body(data="ok"), SendOptions(delay_ms=0, message_id=mid))
box = MessageBox()
cb.on_message(box.on_message)
self.assertEqual(len(box.wait_n(1, 10)), 1)
# 改密后失效
cb.set_talk_password("talk00")
with self.assertRaises(NixMsgError) as cm2:
ca.send(Target("endpoint", b), Body(data="fail"), IMMEDIATE)
self.assertIn(cm2.exception.code, ("talk_password_required", "talk_password_invalid"))
# 对方先发则可以回复
ca.set_talk_password("alicepw")
box2 = MessageBox()
ca.on_message(box2.on_message)
cb.send(
Target("endpoint", a),
Body(data="first"),
SendOptions(delay_ms=0, talk_password="alicepw", message_id=new_uuid7()),
)
self.assertEqual(len(box2.wait_n(1, 10)), 1)
# a 可回 b(b 曾主动发过)
mid3 = new_uuid7()
box3 = MessageBox()
cb.on_message(box3.on_message)
ca.send(Target("endpoint", b), Body(data="reply"), SendOptions(delay_ms=0, message_id=mid3))
self.assertEqual(len(box3.wait_n(1, 10)), 1)
ca.close()
cb.close()
def test_10_kick_no_reconnect(self) -> None:
eid = self._uid("k10")
self._register(eid)
c1 = self._connect(eid)
states: list[ConnectionState] = []
c1.on_connection(lambda e: states.append(e.state))
c2 = self._connect(eid)
self.assertTrue(wait_until(lambda: c1.state == ConnectionState.KICKED, 15))
time.sleep(2.5)
self.assertEqual(c1.state, ConnectionState.KICKED)
self.assertNotEqual(c1.state, ConnectionState.ONLINE)
self.assertEqual(c2.state, ConnectionState.ONLINE)
c1.close()
c2.close()
def test_11_body_too_large_local(self) -> None:
eid = self._uid("big")
self._register(eid)
c = self._connect(eid)
big = "x" * (256 * 1024 + 1)
with self.assertRaises(NixMsgError) as cm:
c.send(Target("endpoint", eid), Body(data=big), IMMEDIATE)
self.assertEqual(cm.exception.code, "body_too_large")
c.close()
def test_12_registration_toggle(self) -> None:
code = self.srv.reg_code
# 关闭时失败
self.srv.set_registration(enabled=False)
with self.assertRaises(NixMsgError) as cm:
Client.register(self.srv.ws_url, code, RegisterOptions(id=self._uid("r12a"), login_password="password12"))
self.assertEqual(cm.exception.code, "registration_closed")
# 错码
self.srv.set_registration(enabled=True, code=code)
with self.assertRaises(NixMsgError) as cm2:
Client.register(
self.srv.ws_url,
"wrong-code-xx",
RegisterOptions(id=self._uid("r12b"), login_password="password12"),
)
self.assertEqual(cm2.exception.code, "registration_code_invalid")
# 成功后能登录
eid = self._uid("r12c")
Client.register(self.srv.ws_url, code, RegisterOptions(id=eid, login_password="password12"))
c = self._connect(eid)
c.close()
# 换码后旧码失败、已注册照常登录
new_code = "s2py-new-code1"
self.srv.set_registration(enabled=True, code=new_code)
with self.assertRaises(NixMsgError):
Client.register(
self.srv.ws_url,
code,
RegisterOptions(id=self._uid("r12d"), login_password="password12"),
)
c2 = self._connect(eid)
c2.close()
# 恢复默认码供后续用例
self.srv.set_registration(enabled=True, code=code)
self.srv.reg_code = code
def test_13_change_login_password(self) -> None:
eid = self._uid("pw13")
self._register(eid, password="password12")
c = self._connect(eid, password="password12")
c.change_login_password("password12", "password99")
c.close()
# 新密码成功
c2 = self._connect(eid, password="password99")
c2.close()
# 旧密码失败且不再重连
c3 = Client()
states: list[ConnectionState] = []
c3.on_connection(lambda e: states.append(e.state))
with self.assertRaises(NixMsgError) as cm:
c3.connect(self.srv.ws_url, eid, password="password12", wait=True)
self.assertIn(cm.exception.code, ("bad_credentials", "auth_failed"))
time.sleep(2.0)
self.assertEqual(c3.state, ConnectionState.AUTH_FAILED)
c3.close()
def test_15_session_token(self) -> None:
eid = self._uid("tok")
self._register(eid)
tokens: list[str] = []
c = Client()
c.on_session(lambda t: tokens.append(t))
c.connect(self.srv.ws_url, eid, password="password12")
self.assertTrue(tokens)
token = tokens[0]
c.close()
# 令牌重连
c2 = Client()
c2.connect(self.srv.ws_url, eid, session_token=token)
self.assertEqual(c2.state, ConnectionState.ONLINE)
c2.close()
# 另一处密码登录使旧令牌失效
c3 = self._connect(eid, password="password12")
new_token = c3.session_token
self.assertTrue(new_token and new_token != token)
c3.close()
c4 = Client()
with self.assertRaises(NixMsgError) as cm:
c4.connect(self.srv.ws_url, eid, session_token=token, wait=True)
self.assertEqual(cm.exception.code, "session_invalid")
time.sleep(1.5)
self.assertEqual(c4.state, ConnectionState.AUTH_FAILED)
c4.close()
# logout 后令牌失效
c5 = self._connect(eid, password="password12")
tok5 = c5.session_token
assert tok5
c5.logout()
time.sleep(0.3)
c6 = Client()
with self.assertRaises(NixMsgError) as cm2:
c6.connect(self.srv.ws_url, eid, session_token=tok5, wait=True)
self.assertEqual(cm2.exception.code, "session_invalid")
c6.close()
if __name__ == "__main__":
unittest.main()