feat: 补齐虚拟机验收并去掉 Docker 弱网占位

This commit is contained in:
Nixevol
2026-10-04 04:12:54 +08:00
parent 069039f4cb
commit fca23c310c
23 changed files with 291 additions and 622 deletions
+9 -9
View File
@@ -1,8 +1,8 @@
# NixMsg 验收对照表(PRD 第 10 节)
生成时间:2026-09-30T08:17:19Z
生成时间:2026-10-03T20:05:57Z
汇总:通过 19,部分通过 4,失败 0,未测 0
汇总:通过 21,部分通过 2,失败 0,未测 0
整项「通过」表示该行 PRD 一句话里的子项均有测试证据。部分覆盖不得标通过。
@@ -10,12 +10,12 @@
|---|---|---|---|
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 通过 | 子项 5/5 通过 |
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 通过 | 子项 6/6 通过 |
| F03 | 断开后状态及时变离线,全表可列出 | 部分通过 (2/4) | F03c 未测:环境/时长限制,本波不跑;F03d 未测:环境限制;验收用关连接模拟断线 |
| F03 | 断开后状态及时变离线,全表可列出 | 部分通过 (3/4) | F03d 未测:环境限制;验收用关连接模拟断线 |
| F04 | 只通知订阅了的端 | 通过 | 子项 1/1 通过 |
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | 通过 | 子项 5/5 通过 |
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | 通过 | 子项 2/2 通过 |
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 通过 | 子项 3/3 通过 |
| F08 | 弱网最终送达且应用层不重复,重启后续传 | 部分通过 (3/4) | F08d 未测:本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested |
| F08 | 弱网最终送达且应用层不重复,重启后续传 | 通过 | 子项 4/4 通过 |
| F09 | 保留时间从发送时刻起算,超时过期 | 通过 | 子项 2/2 通过 |
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 通过 | 子项 3/3 通过 |
| F11 | 发送方离线后到点仍发送 | 通过 | 子项 1/1 通过 |
@@ -28,7 +28,7 @@
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 通过 | 子项 3/3 通过 |
| F19 | 四种 SDK 通过同一清单 | 通过 | 子项 1/1 通过 |
| F20 | 裸 MQTT 能登录、收、确认、发 | 通过 | 子项 1/1 通过 |
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 部分通过 (2/3) | F21b 未测:验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过 |
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 通过 | 子项 3/3 通过 |
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 部分通过 (5/6) | F22f 未测:镜像未推送;本波未跑 compose 全量 |
| F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | 通过 | 子项 2/2 通过 |
@@ -49,7 +49,7 @@
| F02f | 服务器故障不误报密码错误 | 通过 | internal/broker/f02_test.go TestF02DBErrorClosesWithout086;internal/broker/broker_test.go TestInternalAuthErrorDoesNotReturnBadPassword |
| F03a | 断开后状态及时变离线 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory |
| F03b | 目录可列出 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory |
| F03c | 1000 端全表 1s 内返回 | 未测 | 环境/时长限制,本波不跑 |
| F03c | 1000 端全表 1s 内返回 | 通过 | Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check |
| F03d | 真拔网线后心跳超时离线 | 未测 | 环境限制;验收用关连接模拟断线 |
| F04a | 只通知订阅了的端 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF04PresenceWatch |
| F05a | 双端在线单聊送达与确认 | 通过 | test/accept/accept_test.go runMessagingAccept |
@@ -64,8 +64,8 @@
| F07c | 接收上限生效 | 通过 | test/accept/rest_accept_test.go max_receive_bytes=1024 |
| F08a | 重启后续传 | 通过 | test/accept/accept_test.go;test/chaos/q3_crash_test.go |
| F08b | 应用层不重复(同号同指纹) | 通过 | internal/app/message/submit_test.go TestSubmitTable/idempotent_hit |
| F08c | toxiproxy 弱网保留送达 | 通过 | test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered |
| F08d | Linux netem 20% 丢包 | 未测 | 本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested |
| F08c | 弱网保留送达 | 通过 | Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go |
| F08d | Linux netem 20% 丢包 | 通过 | 同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递 |
| F09a | 离线保留期内上线送达 | 通过 | test/accept/accept_test.go |
| F09b | 超时过期 | 通过 | internal/app/message/delivery_test.go TestDeliveryStateMachine/F09_keep_ttl_expire_via_cleanup |
| F10a | 短断线送到 | 通过 | test/accept/rest_accept_test.go |
@@ -97,7 +97,7 @@
| F19a | 四种 SDK 接入清单(仓库内已有测试,本波不重跑全量) | 通过 | sdk/go/itest_checklist_test.go;sdk/js/test/checklist.test.ts;sdk/python/tests/test_checklist.py;sdk/java ChecklistTest |
| F20a | 裸 MQTT WebSocket 登录、收、确认、发 | 通过 | test/accept/accept_test.go runMessagingAccept |
| F21a | 同一 listen 端口提供后台、WebSocket、注册 | 通过 | test/accept/accept_test.go |
| F21b | 裸 TCP MQTT 登录收发 | 未测 | 验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过 |
| F21b | 裸 TCP MQTT 登录收发 | 通过 | test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck |
| F21c | 后台可分到单独端口 | 通过 | internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen |
| F22a | 空目录 admin init + serve 健康检查 | 通过 | test/accept/accept_test.go runInitHealthz |
| F22b | 备份 | 通过 | cmd/nixmsg/commands_test.go TestBackupVacuumInto |
+84
View File
@@ -0,0 +1,84 @@
package accept
import (
"fmt"
"os"
"strconv"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/protocol"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
// TestF03DirectoryScale 按 NIXMSG_DIR_N 开通并保持在线,分页拉完整目录并计时。
// 默认跳过,不进入 task check。
func TestF03DirectoryScale(t *testing.T) {
if os.Getenv("NIXMSG_DIR_SCALE") != "1" {
t.Skip("设置 NIXMSG_DIR_SCALE=1 才跑目录规模测试")
}
n := 50
if v := os.Getenv("NIXMSG_DIR_N"); v != "" {
parsed, err := strconv.Atoi(v)
if err != nil || parsed < 1 {
t.Fatalf("NIXMSG_DIR_N=%q", v)
}
n = parsed
}
srv, err := harness.Start(harness.Options{})
if err != nil {
t.Fatal(err)
}
defer func() { _ = srv.Stop() }()
ac := AdminLogin(t, srv)
const password = "password1234"
ids := make([]string, n)
for i := 0; i < n; i++ {
ids[i] = fmt.Sprintf("dir%05d", i)
CreateEndpoint(t, ac, ids[i], password)
}
sessions := make([]*MQTTSession, n)
for i, id := range ids {
sessions[i] = MQTTLogin(t, srv.HTTPBase, id, password)
}
t.Cleanup(func() {
for _, s := range sessions {
if s != nil {
s.Close()
}
}
})
start := time.Now()
got := 0
cursor := ""
for page := 0; page < n+2; page++ {
frame := map[string]any{
"v": 1, "type": protocol.TypeDirectoryList, "rid": fmt.Sprintf("d%d", page),
"limit": protocol.MaxPageLimit,
}
if cursor != "" {
frame["cursor"] = cursor
}
resp := sessions[0].Request(t, frame)
if !resp.OK {
t.Fatalf("directory page %d: %+v", page, resp.Raw)
}
data, _ := resp.Data.(map[string]any)
items, _ := data["items"].([]any)
got += len(items)
next, _ := data["next_cursor"].(string)
if next == "" {
break
}
cursor = next
}
elapsed := time.Since(start)
if got < n {
t.Fatalf("listed %d want at least %d in %s", got, n, elapsed)
}
t.Logf("F03c N=%d listed=%d elapsed=%s", n, got, elapsed)
}
+13
View File
@@ -45,6 +45,19 @@ func MQTTLogin(t *testing.T, httpBase, endpointID, password string) *MQTTSession
return MQTTLoginWith(t, httpBase, endpointID, password, MQTTLoginOpts{})
}
// MQTTLoginTCP 用裸 TCP 连上 listen 地址,完成订阅与 hello。
func MQTTLoginTCP(t *testing.T, addr, endpointID, password string) *MQTTSession {
t.Helper()
mc, err := harness.DialMQTTTCP(addr, 15*time.Second)
if err != nil {
t.Fatalf("dial tcp mqtt: %v", err)
}
s := &MQTTSession{t: t, mc: mc, EndpointID: endpointID, pktID: 10, done: make(chan struct{})}
s.connectSubscribeHello(password, MQTTLoginOpts{})
go s.readLoop()
return s
}
// MQTTLoginWith 同 MQTTLogin,可声明接收上限等。
func MQTTLoginWith(t *testing.T, httpBase, endpointID, password string, opts MQTTLoginOpts) *MQTTSession {
t.Helper()
+66
View File
@@ -0,0 +1,66 @@
package accept
import (
"os"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
// TestF08NetemRemote 连到已经在丢包路径上的服务,确认保留消息能送达,且相同消息号不再投递一次。
// 由虚拟机脚本准备地址。不进入 task check。
func TestF08NetemRemote(t *testing.T) {
if os.Getenv("NIXMSG_NETEM") != "1" {
t.Skip("设置 NIXMSG_NETEM=1 才连到外部丢包环境")
}
httpBase := os.Getenv("NIXMSG_HTTP")
tcpAddr := os.Getenv("NIXMSG_TCP")
adminPass := os.Getenv("NIXMSG_ADMIN_PASS")
if httpBase == "" || tcpAddr == "" || adminPass == "" {
t.Fatal("需要 NIXMSG_HTTP、NIXMSG_TCP、NIXMSG_ADMIN_PASS")
}
hs := &harness.Server{
HTTPBase: httpBase,
AdminHTTPBase: httpBase,
AdminPassword: adminPass,
Addr: tcpAddr,
}
ac := AdminLogin(t, hs)
const password = "password1234"
CreateEndpoint(t, ac, "nemalice1", password)
CreateEndpoint(t, ac, "nembob001", password)
alice := MQTTLoginTCP(t, tcpAddr, "nemalice1", password)
defer alice.Close()
bob := MQTTLoginTCP(t, tcpAddr, "nembob001", password)
defer bob.Close()
send := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "nem1", "id": "nem-msg-1",
"to": map[string]any{"kind": "endpoint", "id": "nembob001"},
"body": map[string]any{"enc": "utf8", "data": "through-loss"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": true, "ttl_seconds": int64(3600)},
})
if !send.OK {
t.Fatalf("send: %+v", send)
}
msg := bob.WaitType(t, "msg", 45*time.Second)
if msg["id"] != "nem-msg-1" {
t.Fatalf("msg: %v", msg)
}
again := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "nem2", "id": "nem-msg-1",
"to": map[string]any{"kind": "endpoint", "id": "nembob001"},
"body": map[string]any{"enc": "utf8", "data": "through-loss"},
"delay_ms": int64(0),
"offline": map[string]any{"keep": true, "ttl_seconds": int64(3600)},
})
if !again.OK {
t.Fatalf("resend same id: %+v", again)
}
if extra := bob.TryType("msg", 3*time.Second); extra != nil && extra["id"] == "nem-msg-1" {
t.Fatalf("duplicate delivery: %v", extra)
}
}
+46
View File
@@ -0,0 +1,46 @@
package accept
import (
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/test/harness"
)
func TestF21BareTCPLoginSendRecvAck(t *testing.T) {
srv, err := harness.Start(harness.Options{})
if err != nil {
t.Fatal(err)
}
defer func() { _ = srv.Stop() }()
ac := AdminLogin(t, srv)
const password = "password1234"
CreateEndpoint(t, ac, "tcpalice1", password)
CreateEndpoint(t, ac, "tcpbob001", password)
alice := MQTTLoginTCP(t, srv.Addr, "tcpalice1", password)
defer alice.Close()
bob := MQTTLoginTCP(t, srv.Addr, "tcpbob001", password)
defer bob.Close()
send := alice.Request(t, map[string]any{
"v": 1, "type": "send", "rid": "tcp1", "id": "tcp-msg-1",
"to": map[string]any{"kind": "endpoint", "id": "tcpbob001"},
"body": map[string]any{"enc": "utf8", "data": "over-tcp"},
"delay_ms": int64(0),
})
if !send.OK {
t.Fatalf("send: %+v", send)
}
msg := bob.WaitType(t, "msg", 15*time.Second)
if msg["id"] != "tcp-msg-1" {
t.Fatalf("msg: %v", msg)
}
ack := bob.Request(t, map[string]any{
"v": 1, "type": "ack", "rid": "tcp2", "from": "tcpalice1", "id": "tcp-msg-1",
})
if !ack.OK {
t.Fatalf("ack: %+v", ack)
}
}