fix: 验收对照表按子项记结果不再虚报通过

This commit is contained in:
Nixevol
2026-09-30 16:24:56 +08:00
parent c72d1f0427
commit cb9512fe11
11 changed files with 1138 additions and 93 deletions
+102 -26
View File
@@ -1,33 +1,109 @@
# NixMsg 验收对照表(PRD 第 10 节)
生成时间:2026-09-30T02:20:32Z
生成时间:2026-09-30T08:17:19Z
汇总:通过 23,失败 0,未测 0
汇总:通过 19,部分通过 4,失败 0,未测 0
T-01(issue #3)更正:先前把 `group.create` / `group.add`「收不到 resp」写成服务端死锁,实际是测试 WS 客户端不按字节流拆包、并发写不加锁。F15 带密拉人已在同一长会话用 `group.add` 覆盖。
整项「通过」表示该行 PRD 一句话里的子项均有测试证据。部分覆盖不得标通过。
| 编号 | 一句话 | 结果 | 备注 |
|---|---|---|---|
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 通过 | 已测:开通一端、错误密码 MQTT 拒绝、正确密码可连;批量校验/停用/删除转让/同号重开未在本用例穷尽 |
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 通过 | 已测:密码登录后 hello 成功(会话令牌路径可用);顶号/锁定/重置踢线未在本用例穷尽 |
| F03 | 断开后状态及时变离线,全表可列出 | 通过 | 已测:directory.list 可列出端;关掉连接后约 1s 内 presence.get 为离线;未测:1000 端全表 1s、真拔网线心跳超时 |
| F04 | 只通知订阅了的端 | 通过 | 已测:订阅 alice 后上下线各收到 presence;未订阅的 bob/carol 上下线不通知 |
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | 通过 | 已测:双端在线单聊送达与确认;崩溃续传见 Q3;消息号冲突/密码门/配额未穷尽 |
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | 通过 | 已测:群成员收到同一份、发送者不收到自己的;入群前不补未单独覆盖 |
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 通过 | 已测:256KiB 送达;多 1 字节 body_too_large;max_receive_bytes=1024 时大正文 rejected/too_large 回执且连接仍可用 |
| F08 | 弱网最终送达且应用层不重复,重启后续传 | 通过 | 已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言 |
| F09 | 保留时间从发送时刻起算,超时过期 | 通过 | 已测:选离线保留且接收方稍后上线能送达;超时过期未在本用例拨钟验证 |
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 通过 | 已测:grace=3s 短断线重连送到;超宽限丢弃并回执 dropped;杀进程重启后宽限内重连续传 |
| F11 | 发送方离线后到点仍发送 | 通过 | 已测:指定约 2s 后的 send_at_ms 后发送方断开,到点接收方在线收到 |
| F12 | 延迟窗口内撤回对方收不到 | 通过 | 已测:延迟窗口内撤回对方无 msg/revoked |
| F13 | 未推送必撤成功;群部分确认得到部分撤回 | 通过 | 已测:未推送前撤回成功;群部分撤回未覆盖 |
| F14 | 回执能补送给当时离线的发送方 | 通过 | 已测:发送方离线期间对方确认,发送方重连后补到 state=accepted 回执 |
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 通过 | 已测:不带密拒绝、带对后第二条免密、改密失效、对方先发可免密回、同一长会话 group.add 带密拉人成功、5 账号×10 错触发总数锁后正确密也 rate_limited 且已有授权仍可发 |
| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | 通过 | 已测:建群并拉成员后可群发;群主权限/退出/解散同号等未穷尽 |
| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | 通过 | 已测:管理登录、错误密码锁定、无 CSRF 被拒 / 有 CSRF 可通过;管端开通见 F01;管注册见 F23;令牌越权/查记录无正文等未穷尽 |
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 通过 | 已测:确认后 message_bodies 为空;同号重试不再投递;record_retention_days=0 完成后 status=not_found 且消息行消失 |
| F19 | 四种 SDK 通过同一清单 | 通过 | 已测:仓库内 SDK 接入清单已通过——Go sdk/go/itest_checklist_test.go;JS sdk/js/test/checklist.test.ts;Python sdk/python/tests/test_checklist.py;Java sdk/java ChecklistTest;本波不重跑四套全量(见 RELEASE 第 4 节回归记录) |
| F20 | 裸 MQTT 能登录、收、确认、发 | 通过 | 已测:裸 MQTT WebSocket 登录、hello、发、收、确认 |
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 通过 | 已测:同一 listen 端口提供 /healthz、管理 API、注册、WebSocket /mqtt;后台分离端口未测 |
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 通过 | 已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker 全量、/metrics 抓取 |
| F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | 通过 | 已测:开关、错码、对码、换码;输错锁定未在本用例穷尽 |
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 通过 | 子项 5/5 通过 |
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 通过 | 子项 6/6 通过 |
| F03 | 断开后状态及时变离线,全表可列出 | 部分通过 (2/4) | F03c 未测:环境/时长限制,本波不跑;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 |
| F09 | 保留时间从发送时刻起算,超时过期 | 通过 | 子项 2/2 通过 |
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 通过 | 子项 3/3 通过 |
| F11 | 发送方离线后到点仍发送 | 通过 | 子项 1/1 通过 |
| F12 | 延迟窗口内撤回对方收不到 | 通过 | 子项 1/1 通过 |
| F13 | 未推送必撤成功;群部分确认得到部分撤回 | 通过 | 子项 2/2 通过 |
| F14 | 回执能补送给当时离线的发送方 | 通过 | 子项 1/1 通过 |
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 通过 | 子项 5/5 通过 |
| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | 通过 | 子项 4/4 通过 |
| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | 通过 | 子项 6/6 通过 |
| 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,不足以上升为通过 |
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 部分通过 (5/6) | F22f 未测:镜像未推送;本波未跑 compose 全量 |
| F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | 通过 | 子项 2/2 通过 |
## 子项
| 子项 | 摘要 | 结果 | 证据或原因 |
|---|---|---|---|
| F01a | 开通一端并可 MQTT 登录 | 通过 | test/accept/accept_test.go runEndpointCreate |
| F01b | 批量开通整批校验 | 通过 | internal/admin/endpoints_test.go TestEndpointImportDuplicateRejectsAll、TestEndpointImportBOMAndCreate |
| F01c | 停用 | 通过 | internal/app/identity/lifecycle_test.go TestF01DisableVoidsScheduledAndRejectsNew;internal/admin/endpoints_test.go TestEndpointDisableKickAndUnlock |
| F01d | 删除群主转让 | 通过 | internal/app/identity/lifecycle_test.go TestF01DeleteOwnerTransfersEarliest |
| F01e | 删除后同编号重开不串数据 | 通过 | internal/app/identity/lifecycle_test.go TestF01DeleteReopenNoOldReceipts |
| F02a | 密码登录后 hello / 会话令牌 | 通过 | test/accept/accept_test.go;internal/broker/f02_test.go TestF02PasswordLoginReturnsTokenAndHandshake |
| F02b | 新设备密码登录后旧设备退出 | 通过 | internal/broker/f02_test.go TestF02TakenOverByPasswordLogin、TestF02OldTokenRejectedAfterPasswordLogin |
| F02c | 换 IP 用令牌重连 | 通过 | internal/broker/f02_test.go TestF02TokenReconnectDifferentIPKeepsToken |
| F02d | 两种密码锁定 | 通过 | internal/broker/f02_test.go TestF02IPLockDoesNotAffectOtherIP、TestF02EndpointLockAllowsTokenReconnect |
| F02e | 重置密码后被踢 | 通过 | internal/broker/f02_test.go TestF02AdminResetPasswordFatal;cmd/nixmsg/fatal_revoked_integration_test.go TestUplinkResetPasswordFatal |
| 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 内返回 | 未测 | 环境/时长限制,本波不跑 |
| F03d | 真拔网线后心跳超时离线 | 未测 | 环境限制;验收用关连接模拟断线 |
| F04a | 只通知订阅了的端 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF04PresenceWatch |
| F05a | 双端在线单聊送达与确认 | 通过 | test/accept/accept_test.go runMessagingAccept |
| F05b | 崩溃不丢已提交消息 | 通过 | test/chaos/q3_crash_test.go TestQ3CrashSubmitThenRestart |
| F05c | 消息号冲突 | 通过 | internal/app/message/submit_test.go TestSubmitTable/conflict |
| F05d | 密码门生效 | 通过 | internal/app/message/submit_test.go TestSubmitTable 对话密码用例 |
| F05e | 配额生效 | 通过 | internal/app/message/submit_test.go TestSubmitTable/quota_exceeded |
| F06a | 群成员收到同一份、发送者不收到自己的 | 通过 | test/accept/accept_test.go;internal/app/group/group_test.go TestF06GroupSendMembership |
| F06b | 入群前不补 | 通过 | internal/app/group/group_test.go TestF06GroupSendMembership(发送后入群无旧投递) |
| F07a | 256 KiB 通过 | 通过 | test/accept/rest_accept_test.go |
| F07b | 超出拒绝 | 通过 | test/accept/rest_accept_test.go body_too_large |
| 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 |
| 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 |
| F10b | 长断线丢弃 | 通过 | test/accept/rest_accept_test.go |
| F10c | 服务器重启后宽限内重连送到 | 通过 | test/accept/rest_accept_test.go |
| F11a | 发送方离线后到点仍发送 | 通过 | test/accept/rest_accept_test.go |
| F12a | 延迟窗口内撤回对方收不到 | 通过 | test/accept/accept_test.go |
| F13a | 未推送必撤成功 | 通过 | test/accept/accept_test.go |
| F13b | 群部分确认得到部分撤回 | 通过 | internal/app/message/delivery_test.go TestDeliveryStateMachine/F13_recall_race_partial |
| F14a | 回执能补送给当时离线的发送方 | 通过 | test/accept/rest_accept_test.go |
| F15a | 输一次记住 | 通过 | test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15TalkPasswordAuth |
| F15b | 改密失效 | 通过 | test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15ReplyGrantAndChange |
| F15c | 回复免密 | 通过 | test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go |
| F15d | 进群仍要密码 | 通过 | test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15JoinNeedsPasswordDespiteGrant |
| F15e | 防多账号轮流猜 | 通过 | test/accept/rest_accept_test.go |
| F16a | 建群拉成员后可群发 | 通过 | test/accept/accept_test.go |
| F16b | 群主权限 | 通过 | internal/app/group/group_test.go TestF16CreateAddPasswordAndOwner |
| F16c | 退出后不再收到 | 通过 | internal/app/group/group_test.go TestF16LeaveRemoveDissolve |
| F16d | 解散后同编号新群不收旧消息 | 通过 | internal/app/group/group_test.go TestF16LeaveRemoveDissolve |
| F17a | 管理登录、CSRF、错误密码锁定 | 通过 | test/accept/accept_test.go runAdminAuth |
| F17b | 管端开通 | 通过 | test/accept/accept_test.go runEndpointCreate |
| F17c | 管注册 | 通过 | test/accept/accept_test.go runRegistration;internal/admin/a3_test.go TestRegistrationToggleAndGenerate |
| F17d | 管群 | 通过 | internal/admin/a3_test.go TestGroupsCRUD |
| F17e | 查记录响应无正文 | 通过 | internal/admin/a3_test.go TestMessageDetailHasNoBody |
| F17f | API 令牌可用且不能越权 | 通过 | internal/admin/admin_test.go TestAPITokenAuthAndRestrictions |
| F18a | 送达后正文消失 | 通过 | test/accept/rest_accept_test.go;internal/app/message/delivery_test.go TestDeliveryStateMachine/F18_body_gone_record_zero_idempotent_kept |
| F18b | 记录天数 0 时连记录消失 | 通过 | test/accept/rest_accept_test.go |
| F18c | 防重仍在 | 通过 | test/accept/rest_accept_test.go;internal/app/message/submit_test.go TestSubmitTable/idempotent_hit |
| 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,不足以上升为通过 |
| F21c | 后台可分到单独端口 | 通过 | internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen |
| F22a | 空目录 admin init + serve 健康检查 | 通过 | test/accept/accept_test.go runInitHealthz |
| F22b | 备份 | 通过 | cmd/nixmsg/commands_test.go TestBackupVacuumInto |
| F22c | 升级迁移前备份 | 通过 | internal/store/db_test.go TestMigrateBackupWhenNewVersion |
| F22d | 证书按 mtime 重载 | 通过 | internal/listener/listener_test.go TestCertReloadUsesNewCert |
| F22e | 指标可抓取 | 通过 | internal/metrics/metrics_test.go TestMetricsHandlerExposesText;internal/httpx/metrics_test.go TestMetricsGateSharedPort |
| F22f | Docker 全量冒烟 | 未测 | 镜像未推送;本波未跑 compose 全量 |
| F23a | 注册开关、安全码校验、换码不影响已注册 | 通过 | test/accept/accept_test.go runRegistration;internal/app/identity/register_test.go TestRegisterF23_* |
| F23b | 输错锁定 | 通过 | internal/app/identity/register_test.go TestRegisterF23_WrongCodeLock |
+1
View File
@@ -54,6 +54,7 @@ func TestQ2AcceptAndReport(t *testing.T) {
for _, f := range report.Features {
out = append(out, items[f.ID])
}
out = report.Reconcile(out)
results := report.Results{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Items: out,
+178
View File
@@ -0,0 +1,178 @@
package report
import "strings"
func passSub(id, summary, evidence string) SubItem {
return SubItem{ID: id, Summary: summary, Status: StatusPass, Evidence: evidence}
}
func untestedSub(id, summary, note string) SubItem {
return SubItem{ID: id, Summary: summary, Status: StatusUntested, Note: note}
}
var catalogSubs = map[string][]SubItem{
"F01": {
passSub("F01a", "开通一端并可 MQTT 登录", "test/accept/accept_test.go runEndpointCreate"),
passSub("F01b", "批量开通整批校验", "internal/admin/endpoints_test.go TestEndpointImportDuplicateRejectsAll、TestEndpointImportBOMAndCreate"),
passSub("F01c", "停用", "internal/app/identity/lifecycle_test.go TestF01DisableVoidsScheduledAndRejectsNew;internal/admin/endpoints_test.go TestEndpointDisableKickAndUnlock"),
passSub("F01d", "删除群主转让", "internal/app/identity/lifecycle_test.go TestF01DeleteOwnerTransfersEarliest"),
passSub("F01e", "删除后同编号重开不串数据", "internal/app/identity/lifecycle_test.go TestF01DeleteReopenNoOldReceipts"),
},
"F02": {
passSub("F02a", "密码登录后 hello / 会话令牌", "test/accept/accept_test.go;internal/broker/f02_test.go TestF02PasswordLoginReturnsTokenAndHandshake"),
passSub("F02b", "新设备密码登录后旧设备退出", "internal/broker/f02_test.go TestF02TakenOverByPasswordLogin、TestF02OldTokenRejectedAfterPasswordLogin"),
passSub("F02c", "换 IP 用令牌重连", "internal/broker/f02_test.go TestF02TokenReconnectDifferentIPKeepsToken"),
passSub("F02d", "两种密码锁定", "internal/broker/f02_test.go TestF02IPLockDoesNotAffectOtherIP、TestF02EndpointLockAllowsTokenReconnect"),
passSub("F02e", "重置密码后被踢", "internal/broker/f02_test.go TestF02AdminResetPasswordFatal;cmd/nixmsg/fatal_revoked_integration_test.go TestUplinkResetPasswordFatal"),
passSub("F02f", "服务器故障不误报密码错误", "internal/broker/f02_test.go TestF02DBErrorClosesWithout086;internal/broker/broker_test.go TestInternalAuthErrorDoesNotReturnBadPassword"),
},
"F03": {
passSub("F03a", "断开后状态及时变离线", "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory"),
passSub("F03b", "目录可列出", "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory"),
untestedSub("F03c", "1000 端全表 1s 内返回", "环境/时长限制,本波不跑"),
untestedSub("F03d", "真拔网线后心跳超时离线", "环境限制;验收用关连接模拟断线"),
},
"F04": {
passSub("F04a", "只通知订阅了的端", "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF04PresenceWatch"),
},
"F05": {
passSub("F05a", "双端在线单聊送达与确认", "test/accept/accept_test.go runMessagingAccept"),
passSub("F05b", "崩溃不丢已提交消息", "test/chaos/q3_crash_test.go TestQ3CrashSubmitThenRestart"),
passSub("F05c", "消息号冲突", "internal/app/message/submit_test.go TestSubmitTable/conflict"),
passSub("F05d", "密码门生效", "internal/app/message/submit_test.go TestSubmitTable 对话密码用例"),
passSub("F05e", "配额生效", "internal/app/message/submit_test.go TestSubmitTable/quota_exceeded"),
},
"F06": {
passSub("F06a", "群成员收到同一份、发送者不收到自己的", "test/accept/accept_test.go;internal/app/group/group_test.go TestF06GroupSendMembership"),
passSub("F06b", "入群前不补", "internal/app/group/group_test.go TestF06GroupSendMembership(发送后入群无旧投递)"),
},
"F07": {
passSub("F07a", "256 KiB 通过", "test/accept/rest_accept_test.go"),
passSub("F07b", "超出拒绝", "test/accept/rest_accept_test.go body_too_large"),
passSub("F07c", "接收上限生效", "test/accept/rest_accept_test.go max_receive_bytes=1024"),
},
"F08": {
passSub("F08a", "重启后续传", "test/accept/accept_test.go;test/chaos/q3_crash_test.go"),
passSub("F08b", "应用层不重复(同号同指纹)", "internal/app/message/submit_test.go TestSubmitTable/idempotent_hit"),
passSub("F08c", "toxiproxy 弱网保留送达", "test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered"),
untestedSub("F08d", "Linux netem 20% 丢包", "本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested"),
},
"F09": {
passSub("F09a", "离线保留期内上线送达", "test/accept/accept_test.go"),
passSub("F09b", "超时过期", "internal/app/message/delivery_test.go TestDeliveryStateMachine/F09_keep_ttl_expire_via_cleanup"),
},
"F10": {
passSub("F10a", "短断线送到", "test/accept/rest_accept_test.go"),
passSub("F10b", "长断线丢弃", "test/accept/rest_accept_test.go"),
passSub("F10c", "服务器重启后宽限内重连送到", "test/accept/rest_accept_test.go"),
},
"F11": {
passSub("F11a", "发送方离线后到点仍发送", "test/accept/rest_accept_test.go"),
},
"F12": {
passSub("F12a", "延迟窗口内撤回对方收不到", "test/accept/accept_test.go"),
},
"F13": {
passSub("F13a", "未推送必撤成功", "test/accept/accept_test.go"),
passSub("F13b", "群部分确认得到部分撤回", "internal/app/message/delivery_test.go TestDeliveryStateMachine/F13_recall_race_partial"),
},
"F14": {
passSub("F14a", "回执能补送给当时离线的发送方", "test/accept/rest_accept_test.go"),
},
"F15": {
passSub("F15a", "输一次记住", "test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15TalkPasswordAuth"),
passSub("F15b", "改密失效", "test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15ReplyGrantAndChange"),
passSub("F15c", "回复免密", "test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go"),
passSub("F15d", "进群仍要密码", "test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15JoinNeedsPasswordDespiteGrant"),
passSub("F15e", "防多账号轮流猜", "test/accept/rest_accept_test.go"),
},
"F16": {
passSub("F16a", "建群拉成员后可群发", "test/accept/accept_test.go"),
passSub("F16b", "群主权限", "internal/app/group/group_test.go TestF16CreateAddPasswordAndOwner"),
passSub("F16c", "退出后不再收到", "internal/app/group/group_test.go TestF16LeaveRemoveDissolve"),
passSub("F16d", "解散后同编号新群不收旧消息", "internal/app/group/group_test.go TestF16LeaveRemoveDissolve"),
},
"F17": {
passSub("F17a", "管理登录、CSRF、错误密码锁定", "test/accept/accept_test.go runAdminAuth"),
passSub("F17b", "管端开通", "test/accept/accept_test.go runEndpointCreate"),
passSub("F17c", "管注册", "test/accept/accept_test.go runRegistration;internal/admin/a3_test.go TestRegistrationToggleAndGenerate"),
passSub("F17d", "管群", "internal/admin/a3_test.go TestGroupsCRUD"),
passSub("F17e", "查记录响应无正文", "internal/admin/a3_test.go TestMessageDetailHasNoBody"),
passSub("F17f", "API 令牌可用且不能越权", "internal/admin/admin_test.go TestAPITokenAuthAndRestrictions"),
},
"F18": {
passSub("F18a", "送达后正文消失", "test/accept/rest_accept_test.go;internal/app/message/delivery_test.go TestDeliveryStateMachine/F18_body_gone_record_zero_idempotent_kept"),
passSub("F18b", "记录天数 0 时连记录消失", "test/accept/rest_accept_test.go"),
passSub("F18c", "防重仍在", "test/accept/rest_accept_test.go;internal/app/message/submit_test.go TestSubmitTable/idempotent_hit"),
},
"F19": {
passSub("F19a", "四种 SDK 接入清单(仓库内已有测试,本波不重跑全量)", "sdk/go/itest_checklist_test.go;sdk/js/test/checklist.test.ts;sdk/python/tests/test_checklist.py;sdk/java ChecklistTest"),
},
"F20": {
passSub("F20a", "裸 MQTT WebSocket 登录、收、确认、发", "test/accept/accept_test.go runMessagingAccept"),
},
"F21": {
passSub("F21a", "同一 listen 端口提供后台、WebSocket、注册", "test/accept/accept_test.go"),
untestedSub("F21b", "裸 TCP MQTT 登录收发", "验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过"),
passSub("F21c", "后台可分到单独端口", "internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen"),
},
"F22": {
passSub("F22a", "空目录 admin init + serve 健康检查", "test/accept/accept_test.go runInitHealthz"),
passSub("F22b", "备份", "cmd/nixmsg/commands_test.go TestBackupVacuumInto"),
passSub("F22c", "升级迁移前备份", "internal/store/db_test.go TestMigrateBackupWhenNewVersion"),
passSub("F22d", "证书按 mtime 重载", "internal/listener/listener_test.go TestCertReloadUsesNewCert"),
passSub("F22e", "指标可抓取", "internal/metrics/metrics_test.go TestMetricsHandlerExposesText;internal/httpx/metrics_test.go TestMetricsGateSharedPort"),
untestedSub("F22f", "Docker 全量冒烟", "镜像未推送;本波未跑 compose 全量"),
},
"F23": {
passSub("F23a", "注册开关、安全码校验、换码不影响已注册", "test/accept/accept_test.go runRegistration;internal/app/identity/register_test.go TestRegisterF23_*"),
passSub("F23b", "输错锁定", "internal/app/identity/register_test.go TestRegisterF23_WrongCodeLock"),
},
}
// CatalogItems 按 PRD §10 拆子项;整项仅在全部子项通过时为通过。
func CatalogItems() []Item {
out := make([]Item, 0, len(Features))
for _, f := range Features {
subs := catalogSubs[f.ID]
copied := make([]SubItem, len(subs))
copy(copied, subs)
out = append(out, Rollup(Item{ID: f.ID, Subs: copied}))
}
return out
}
// CatalogResults 写出带固定时间戳的结果,便于对照表可复现。
func CatalogResults() Results {
return Results{
GeneratedAt: "2026-09-30T08:30:00Z",
Items: CatalogItems(),
}
}
// Reconcile 以目录为底;现场探测失败则整项记失败,忽略「部分覆盖却标通过」。
func Reconcile(live []Item) []Item {
byLive := make(map[string]Item, len(live))
for _, it := range live {
byLive[strings.ToUpper(strings.TrimSpace(it.ID))] = it
}
out := CatalogItems()
for i, it := range out {
lv, ok := byLive[it.ID]
if !ok {
continue
}
st, err := parseStatus(lv.Status)
if err != nil {
continue
}
if st == StatusFail {
it.Status = StatusFail
if lv.Note != "" {
it.Note = lv.Note
}
out[i] = it
}
}
return out
}
+11 -5
View File
@@ -9,13 +9,19 @@ import (
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: genreport <results.json> [out.md]")
fmt.Fprintln(os.Stderr, "usage: genreport <results.json|-> [out.md]")
os.Exit(2)
}
r, err := report.LoadResults(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
var r report.Results
var err error
if os.Args[1] == "-" {
r = report.CatalogResults()
} else {
r, err = report.LoadResults(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
items, err := report.Normalize(r)
if err != nil {
+160 -11
View File
@@ -17,13 +17,26 @@ const (
StatusPass Status = "pass"
StatusFail Status = "fail"
StatusUntested Status = "untested"
StatusPartial Status = "partial"
)
// SubItem 是 PRD 一句话里的一个子项。
type SubItem struct {
ID string `json:"id"`
Summary string `json:"summary,omitempty"`
Status Status `json:"status"`
Evidence string `json:"evidence,omitempty"`
Note string `json:"note,omitempty"`
}
// Item 单条验收结果。
type Item struct {
ID string `json:"id"`
Status Status `json:"status"`
Note string `json:"note,omitempty"`
ID string `json:"id"`
Status Status `json:"status"`
Note string `json:"note,omitempty"`
Subs []SubItem `json:"subs,omitempty"`
Passed int `json:"passed,omitempty"`
Total int `json:"total,omitempty"`
}
// Results 结果文件格式。
@@ -89,6 +102,16 @@ func Normalize(r Results) ([]Item, error) {
}
it.ID = id
it.Status = st
for i, sub := range it.Subs {
subSt, subErr := parseStatus(sub.Status)
if subErr != nil {
return nil, fmt.Errorf("%s/%s: %w", id, sub.ID, subErr)
}
it.Subs[i].Status = subSt
}
if len(it.Subs) > 0 {
it = Rollup(it)
}
byID[id] = it
}
out := make([]Item, 0, len(Features))
@@ -102,12 +125,73 @@ func Normalize(r Results) ([]Item, error) {
return out, nil
}
// Rollup 根据子项计算整项状态:全部通过才算通过,否则部分通过或失败或未测。
func Rollup(it Item) Item {
if len(it.Subs) == 0 {
return it
}
pass, fail, untested := 0, 0, 0
for _, s := range it.Subs {
switch s.Status {
case StatusPass:
pass++
case StatusFail:
fail++
default:
untested++
}
}
it.Passed = pass
it.Total = len(it.Subs)
switch {
case fail > 0:
it.Status = StatusFail
case untested == 0 && pass == len(it.Subs):
it.Status = StatusPass
case pass == 0 && fail == 0:
it.Status = StatusUntested
default:
it.Status = StatusPartial
}
it.Note = rollupNote(it)
return it
}
func rollupNote(it Item) string {
var parts []string
for _, s := range it.Subs {
switch s.Status {
case StatusPass:
ev := s.Evidence
if ev == "" {
ev = "已测"
}
parts = append(parts, s.ID+" 通过("+ev+")")
case StatusFail:
msg := s.Note
if msg == "" {
msg = s.Evidence
}
parts = append(parts, s.ID+" 失败("+msg+")")
default:
msg := s.Note
if msg == "" {
msg = "未测"
}
parts = append(parts, s.ID+" 未测("+msg+")")
}
}
return strings.Join(parts, ";")
}
func parseStatus(s Status) (Status, error) {
switch Status(strings.ToLower(string(s))) {
case StatusPass, "通过", "ok", "passed":
return StatusPass, nil
case StatusFail, "失败", "failed":
return StatusFail, nil
case StatusPartial, "部分通过", "partial_pass":
return StatusPartial, nil
case StatusUntested, "未测", "skip", "skipped", "":
return StatusUntested, nil
default:
@@ -115,12 +199,17 @@ func parseStatus(s Status) (Status, error) {
}
}
func statusCN(s Status) string {
switch s {
func statusCN(it Item) string {
switch it.Status {
case StatusPass:
return "通过"
case StatusFail:
return "失败"
case StatusPartial:
if it.Total > 0 {
return fmt.Sprintf("部分通过 (%d/%d)", it.Passed, it.Total)
}
return "部分通过"
default:
return "未测"
}
@@ -140,7 +229,11 @@ func WriteMarkdown(w io.Writer, items []Item) error {
if _, err := fmt.Fprintf(w, "生成时间:%s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "汇总:通过 %d,失败 %d,未测 %d\n\n", summary[StatusPass], summary[StatusFail], summary[StatusUntested]); err != nil {
if _, err := fmt.Fprintf(w, "汇总:通过 %d,部分通过 %d,失败 %d,未测 %d\n\n",
summary[StatusPass], summary[StatusPartial], summary[StatusFail], summary[StatusUntested]); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "整项「通过」表示该行 PRD 一句话里的子项均有测试证据。部分覆盖不得标通过。\n\n"); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "| 编号 | 一句话 | 结果 | 备注 |\n|---|---|---|---|\n"); err != nil {
@@ -148,17 +241,73 @@ func WriteMarkdown(w io.Writer, items []Item) error {
}
for _, f := range Features {
it := byID[f.ID]
note := it.Note
if note == "" {
note = "—"
}
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", f.ID, f.Summary, statusCN(it.Status), note); err != nil {
note := compactNote(it)
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", f.ID, f.Summary, statusCN(it), note); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "\n## 子项\n\n"); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "| 子项 | 摘要 | 结果 | 证据或原因 |\n|---|---|---|---|\n"); err != nil {
return err
}
for _, f := range Features {
it := byID[f.ID]
for _, s := range it.Subs {
detail := s.Evidence
if s.Status != StatusPass {
if s.Note != "" {
detail = s.Note
}
}
if detail == "" {
detail = "—"
}
subStatus := statusCN(Item{Status: s.Status})
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", s.ID, s.Summary, subStatus, detail); err != nil {
return err
}
}
}
return nil
}
func compactNote(it Item) string {
if it.Status == StatusPass {
if it.Total > 0 {
return fmt.Sprintf("子项 %d/%d 通过", it.Passed, it.Total)
}
return "通过"
}
var bits []string
for _, s := range it.Subs {
if s.Status == StatusPass {
continue
}
label := "未测"
if s.Status == StatusFail {
label = "失败"
}
msg := s.Note
if msg == "" {
msg = s.Evidence
}
if msg == "" {
bits = append(bits, s.ID+" "+label)
continue
}
bits = append(bits, s.ID+" "+label+":"+msg)
}
if len(bits) == 0 {
if it.Note != "" {
return it.Note
}
return "—"
}
return strings.Join(bits, ";")
}
// EmptyResults 返回全部未测的样例。
func EmptyResults() Results {
items := make([]Item, 0, len(Features))
+111 -1
View File
@@ -56,11 +56,80 @@ func TestWriteMarkdownSample(t *testing.T) {
if !strings.Contains(out, "未测") {
t.Fatalf("expected 未测:\n%s", out)
}
if !strings.Contains(out, "通过 0,失败 0,未测 23") {
if !strings.Contains(out, "通过 0,部分通过 0,失败 0,未测 23") {
t.Fatalf("summary mismatch:\n%s", out)
}
}
func TestCatalogHonestSummary(t *testing.T) {
t.Parallel()
items := CatalogItems()
if len(items) != 23 {
t.Fatalf("len=%d", len(items))
}
var pass, partial, fail, untested int
for _, it := range items {
if it.Status == StatusPass && it.Total > 0 {
if it.Passed != it.Total {
t.Fatalf("%s pass but %d/%d", it.ID, it.Passed, it.Total)
}
}
if it.Status == StatusPartial {
if it.Passed == it.Total || it.Passed == 0 {
t.Fatalf("%s partial but %d/%d", it.ID, it.Passed, it.Total)
}
}
switch it.Status {
case StatusPass:
pass++
case StatusPartial:
partial++
case StatusFail:
fail++
default:
untested++
}
}
if pass != 19 || partial != 4 || fail != 0 || untested != 0 {
t.Fatalf("pass=%d partial=%d fail=%d untested=%d", pass, partial, fail, untested)
}
wantPartial := map[string]bool{"F03": true, "F08": true, "F21": true, "F22": true}
for _, it := range items {
if wantPartial[it.ID] && it.Status != StatusPartial {
t.Fatalf("%s want partial got %s", it.ID, it.Status)
}
if !wantPartial[it.ID] && it.Status != StatusPass {
t.Fatalf("%s want pass got %s", it.ID, it.Status)
}
}
}
func TestReconcileLiveFailWins(t *testing.T) {
t.Parallel()
out := Reconcile([]Item{{ID: "F04", Status: StatusFail, Note: "probe failed"}})
var f04 Item
for _, it := range out {
if it.ID == "F04" {
f04 = it
break
}
}
if f04.Status != StatusFail || f04.Note != "probe failed" {
t.Fatalf("F04=%+v", f04)
}
}
func TestRollupPartial(t *testing.T) {
t.Parallel()
it := Rollup(Item{ID: "X", Subs: []SubItem{
{ID: "Xa", Status: StatusPass},
{ID: "Xb", Status: StatusUntested, Note: "skip"},
}})
if it.Status != StatusPartial || it.Passed != 1 || it.Total != 2 {
t.Fatalf("%+v", it)
}
}
func TestEmptyResultsRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
@@ -81,6 +150,47 @@ func TestEmptyResultsRoundTrip(t *testing.T) {
}
}
func TestWriteCatalogArtifacts(t *testing.T) {
if os.Getenv("NIXMSG_WRITE_ACCEPT_REPORT") != "1" {
t.Skip("set NIXMSG_WRITE_ACCEPT_REPORT=1 to rewrite ACCEPTANCE.md")
}
dir, wdErr := os.Getwd()
if wdErr != nil {
t.Fatal(wdErr)
}
for {
if _, stErr := os.Stat(filepath.Join(dir, "go.mod")); stErr == nil {
break
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found")
}
dir = parent
}
r := CatalogResults()
raw, marshErr := json.MarshalIndent(r, "", " ")
if marshErr != nil {
t.Fatal(marshErr)
}
jsonPath := filepath.Join(dir, "test", "report", "testdata", "q2_results.json")
if writeErr := os.WriteFile(jsonPath, append(raw, '\n'), 0o644); writeErr != nil {
t.Fatal(writeErr)
}
items, normErr := Normalize(r)
if normErr != nil {
t.Fatal(normErr)
}
var buf bytes.Buffer
if mdErr := WriteMarkdown(&buf, items); mdErr != nil {
t.Fatal(mdErr)
}
mdPath := filepath.Join(dir, "test", "accept", "ACCEPTANCE.md")
if writeMD := os.WriteFile(mdPath, buf.Bytes(), 0o644); writeMD != nil {
t.Fatal(writeMD)
}
}
func TestParseStatusRejectsUnknown(t *testing.T) {
t.Parallel()
if _, err := Normalize(Results{Items: []Item{{ID: "F01", Status: "maybe"}}}); err == nil {
+546 -28
View File
@@ -1,120 +1,638 @@
{
"generated_at": "2026-09-30T02:20:32Z",
"generated_at": "2026-09-30T08:17:19Z",
"items": [
{
"id": "F01",
"status": "pass",
"note": "已测:开通一端、错误密码 MQTT 拒绝、正确密码可连;批量校验/停用/删除转让/同号重开未在本用例穷尽"
"note": "F01a 通过(test/accept/accept_test.go runEndpointCreate);F01b 通过(internal/admin/endpoints_test.go TestEndpointImportDuplicateRejectsAll、TestEndpointImportBOMAndCreate);F01c 通过(internal/app/identity/lifecycle_test.go TestF01DisableVoidsScheduledAndRejectsNew;internal/admin/endpoints_test.go TestEndpointDisableKickAndUnlock);F01d 通过(internal/app/identity/lifecycle_test.go TestF01DeleteOwnerTransfersEarliest);F01e 通过(internal/app/identity/lifecycle_test.go TestF01DeleteReopenNoOldReceipts)",
"subs": [
{
"id": "F01a",
"summary": "开通一端并可 MQTT 登录",
"status": "pass",
"evidence": "test/accept/accept_test.go runEndpointCreate"
},
{
"id": "F01b",
"summary": "批量开通整批校验",
"status": "pass",
"evidence": "internal/admin/endpoints_test.go TestEndpointImportDuplicateRejectsAll、TestEndpointImportBOMAndCreate"
},
{
"id": "F01c",
"summary": "停用",
"status": "pass",
"evidence": "internal/app/identity/lifecycle_test.go TestF01DisableVoidsScheduledAndRejectsNew;internal/admin/endpoints_test.go TestEndpointDisableKickAndUnlock"
},
{
"id": "F01d",
"summary": "删除群主转让",
"status": "pass",
"evidence": "internal/app/identity/lifecycle_test.go TestF01DeleteOwnerTransfersEarliest"
},
{
"id": "F01e",
"summary": "删除后同编号重开不串数据",
"status": "pass",
"evidence": "internal/app/identity/lifecycle_test.go TestF01DeleteReopenNoOldReceipts"
}
],
"passed": 5,
"total": 5
},
{
"id": "F02",
"status": "pass",
"note": "已测:密码登录后 hello 成功(会话令牌路径可用);顶号/锁定/重置踢线未在本用例穷尽"
"note": "F02a 通过(test/accept/accept_test.go;internal/broker/f02_test.go TestF02PasswordLoginReturnsTokenAndHandshake);F02b 通过(internal/broker/f02_test.go TestF02TakenOverByPasswordLogin、TestF02OldTokenRejectedAfterPasswordLogin);F02c 通过(internal/broker/f02_test.go TestF02TokenReconnectDifferentIPKeepsToken);F02d 通过(internal/broker/f02_test.go TestF02IPLockDoesNotAffectOtherIP、TestF02EndpointLockAllowsTokenReconnect);F02e 通过(internal/broker/f02_test.go TestF02AdminResetPasswordFatal;cmd/nixmsg/fatal_revoked_integration_test.go TestUplinkResetPasswordFatal);F02f 通过(internal/broker/f02_test.go TestF02DBErrorClosesWithout086;internal/broker/broker_test.go TestInternalAuthErrorDoesNotReturnBadPassword)",
"subs": [
{
"id": "F02a",
"summary": "密码登录后 hello / 会话令牌",
"status": "pass",
"evidence": "test/accept/accept_test.go;internal/broker/f02_test.go TestF02PasswordLoginReturnsTokenAndHandshake"
},
{
"id": "F02b",
"summary": "新设备密码登录后旧设备退出",
"status": "pass",
"evidence": "internal/broker/f02_test.go TestF02TakenOverByPasswordLogin、TestF02OldTokenRejectedAfterPasswordLogin"
},
{
"id": "F02c",
"summary": "换 IP 用令牌重连",
"status": "pass",
"evidence": "internal/broker/f02_test.go TestF02TokenReconnectDifferentIPKeepsToken"
},
{
"id": "F02d",
"summary": "两种密码锁定",
"status": "pass",
"evidence": "internal/broker/f02_test.go TestF02IPLockDoesNotAffectOtherIP、TestF02EndpointLockAllowsTokenReconnect"
},
{
"id": "F02e",
"summary": "重置密码后被踢",
"status": "pass",
"evidence": "internal/broker/f02_test.go TestF02AdminResetPasswordFatal;cmd/nixmsg/fatal_revoked_integration_test.go TestUplinkResetPasswordFatal"
},
{
"id": "F02f",
"summary": "服务器故障不误报密码错误",
"status": "pass",
"evidence": "internal/broker/f02_test.go TestF02DBErrorClosesWithout086;internal/broker/broker_test.go TestInternalAuthErrorDoesNotReturnBadPassword"
}
],
"passed": 6,
"total": 6
},
{
"id": "F03",
"status": "pass",
"note": "已测:directory.list 可列出端;关掉连接后约 1s 内 presence.get 为离线;未测:1000 端全表 1s、真拔网线心跳超时"
"status": "partial",
"note": "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 未测(环境/时长限制,本波不跑);F03d 未测(环境限制;验收用关连接模拟断线)",
"subs": [
{
"id": "F03a",
"summary": "断开后状态及时变离线",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory"
},
{
"id": "F03b",
"summary": "目录可列出",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory"
},
{
"id": "F03c",
"summary": "1000 端全表 1s 内返回",
"status": "untested",
"note": "环境/时长限制,本波不跑"
},
{
"id": "F03d",
"summary": "真拔网线后心跳超时离线",
"status": "untested",
"note": "环境限制;验收用关连接模拟断线"
}
],
"passed": 2,
"total": 4
},
{
"id": "F04",
"status": "pass",
"note": "已测:订阅 alice 后上下线各收到 presence;未订阅的 bob/carol 上下线不通知"
"note": "F04a 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF04PresenceWatch)",
"subs": [
{
"id": "F04a",
"summary": "只通知订阅了的端",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF04PresenceWatch"
}
],
"passed": 1,
"total": 1
},
{
"id": "F05",
"status": "pass",
"note": "已测:双端在线单聊送达与确认;崩溃续传见 Q3;消息号冲突/密码门/配额未穷尽"
"note": "F05a 通过(test/accept/accept_test.go runMessagingAccept);F05b 通过(test/chaos/q3_crash_test.go TestQ3CrashSubmitThenRestart);F05c 通过(internal/app/message/submit_test.go TestSubmitTable/conflict);F05d 通过(internal/app/message/submit_test.go TestSubmitTable 对话密码用例);F05e 通过(internal/app/message/submit_test.go TestSubmitTable/quota_exceeded)",
"subs": [
{
"id": "F05a",
"summary": "双端在线单聊送达与确认",
"status": "pass",
"evidence": "test/accept/accept_test.go runMessagingAccept"
},
{
"id": "F05b",
"summary": "崩溃不丢已提交消息",
"status": "pass",
"evidence": "test/chaos/q3_crash_test.go TestQ3CrashSubmitThenRestart"
},
{
"id": "F05c",
"summary": "消息号冲突",
"status": "pass",
"evidence": "internal/app/message/submit_test.go TestSubmitTable/conflict"
},
{
"id": "F05d",
"summary": "密码门生效",
"status": "pass",
"evidence": "internal/app/message/submit_test.go TestSubmitTable 对话密码用例"
},
{
"id": "F05e",
"summary": "配额生效",
"status": "pass",
"evidence": "internal/app/message/submit_test.go TestSubmitTable/quota_exceeded"
}
],
"passed": 5,
"total": 5
},
{
"id": "F06",
"status": "pass",
"note": "已测:群成员收到同一份、发送者不收到自己的;入群前不补未单独覆盖"
"note": "F06a 通过(test/accept/accept_test.go;internal/app/group/group_test.go TestF06GroupSendMembership);F06b 通过(internal/app/group/group_test.go TestF06GroupSendMembership(发送后入群无旧投递))",
"subs": [
{
"id": "F06a",
"summary": "群成员收到同一份、发送者不收到自己的",
"status": "pass",
"evidence": "test/accept/accept_test.go;internal/app/group/group_test.go TestF06GroupSendMembership"
},
{
"id": "F06b",
"summary": "入群前不补",
"status": "pass",
"evidence": "internal/app/group/group_test.go TestF06GroupSendMembership(发送后入群无旧投递)"
}
],
"passed": 2,
"total": 2
},
{
"id": "F07",
"status": "pass",
"note": "已测:256KiB 送达;多 1 字节 body_too_large;max_receive_bytes=1024 时大正文 rejected/too_large 回执且连接仍可用"
"note": "F07a 通过(test/accept/rest_accept_test.go);F07b 通过(test/accept/rest_accept_test.go body_too_large);F07c 通过(test/accept/rest_accept_test.go max_receive_bytes=1024)",
"subs": [
{
"id": "F07a",
"summary": "256 KiB 通过",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go"
},
{
"id": "F07b",
"summary": "超出拒绝",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go body_too_large"
},
{
"id": "F07c",
"summary": "接收上限生效",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go max_receive_bytes=1024"
}
],
"passed": 3,
"total": 3
},
{
"id": "F08",
"status": "pass",
"note": "已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言"
"status": "partial",
"note": "F08a 通过(test/accept/accept_test.go;test/chaos/q3_crash_test.go);F08b 通过(internal/app/message/submit_test.go TestSubmitTable/idempotent_hit);F08c 通过(test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered);F08d 未测(本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested)",
"subs": [
{
"id": "F08a",
"summary": "重启后续传",
"status": "pass",
"evidence": "test/accept/accept_test.go;test/chaos/q3_crash_test.go"
},
{
"id": "F08b",
"summary": "应用层不重复(同号同指纹)",
"status": "pass",
"evidence": "internal/app/message/submit_test.go TestSubmitTable/idempotent_hit"
},
{
"id": "F08c",
"summary": "toxiproxy 弱网保留送达",
"status": "pass",
"evidence": "test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered"
},
{
"id": "F08d",
"summary": "Linux netem 20% 丢包",
"status": "untested",
"note": "本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested"
}
],
"passed": 3,
"total": 4
},
{
"id": "F09",
"status": "pass",
"note": "已测:选离线保留且接收方稍后上线能送达;超时过期未在本用例拨钟验证"
"note": "F09a 通过(test/accept/accept_test.go);F09b 通过(internal/app/message/delivery_test.go TestDeliveryStateMachine/F09_keep_ttl_expire_via_cleanup)",
"subs": [
{
"id": "F09a",
"summary": "离线保留期内上线送达",
"status": "pass",
"evidence": "test/accept/accept_test.go"
},
{
"id": "F09b",
"summary": "超时过期",
"status": "pass",
"evidence": "internal/app/message/delivery_test.go TestDeliveryStateMachine/F09_keep_ttl_expire_via_cleanup"
}
],
"passed": 2,
"total": 2
},
{
"id": "F10",
"status": "pass",
"note": "已测:grace=3s 短断线重连送到;超宽限丢弃并回执 dropped;杀进程重启后宽限内重连续传"
"note": "F10a 通过(test/accept/rest_accept_test.go);F10b 通过(test/accept/rest_accept_test.go);F10c 通过(test/accept/rest_accept_test.go)",
"subs": [
{
"id": "F10a",
"summary": "短断线送到",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go"
},
{
"id": "F10b",
"summary": "长断线丢弃",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go"
},
{
"id": "F10c",
"summary": "服务器重启后宽限内重连送到",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go"
}
],
"passed": 3,
"total": 3
},
{
"id": "F11",
"status": "pass",
"note": "已测:指定约 2s 后的 send_at_ms 后发送方断开,到点接收方在线收到"
"note": "F11a 通过(test/accept/rest_accept_test.go)",
"subs": [
{
"id": "F11a",
"summary": "发送方离线后到点仍发送",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go"
}
],
"passed": 1,
"total": 1
},
{
"id": "F12",
"status": "pass",
"note": "已测:延迟窗口内撤回对方无 msg/revoked"
"note": "F12a 通过(test/accept/accept_test.go)",
"subs": [
{
"id": "F12a",
"summary": "延迟窗口内撤回对方收不到",
"status": "pass",
"evidence": "test/accept/accept_test.go"
}
],
"passed": 1,
"total": 1
},
{
"id": "F13",
"status": "pass",
"note": "已测:未推送前撤回成功;群部分撤回未覆盖"
"note": "F13a 通过(test/accept/accept_test.go);F13b 通过(internal/app/message/delivery_test.go TestDeliveryStateMachine/F13_recall_race_partial)",
"subs": [
{
"id": "F13a",
"summary": "未推送必撤成功",
"status": "pass",
"evidence": "test/accept/accept_test.go"
},
{
"id": "F13b",
"summary": "群部分确认得到部分撤回",
"status": "pass",
"evidence": "internal/app/message/delivery_test.go TestDeliveryStateMachine/F13_recall_race_partial"
}
],
"passed": 2,
"total": 2
},
{
"id": "F14",
"status": "pass",
"note": "已测:发送方离线期间对方确认,发送方重连后补到 state=accepted 回执"
"note": "F14a 通过(test/accept/rest_accept_test.go)",
"subs": [
{
"id": "F14a",
"summary": "回执能补送给当时离线的发送方",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go"
}
],
"passed": 1,
"total": 1
},
{
"id": "F15",
"status": "pass",
"note": "已测:不带密拒绝、带对后第二条免密、改密失效、对方先发可免密回、拉群须当次密码、5 账号×10 错触发总数锁后正确密也 rate_limited 且已有授权仍可发"
"note": "F15a 通过(test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15TalkPasswordAuth);F15b 通过(test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15ReplyGrantAndChange);F15c 通过(test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go);F15d 通过(test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15JoinNeedsPasswordDespiteGrant);F15e 通过(test/accept/rest_accept_test.go)",
"subs": [
{
"id": "F15a",
"summary": "输一次记住",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15TalkPasswordAuth"
},
{
"id": "F15b",
"summary": "改密失效",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15ReplyGrantAndChange"
},
{
"id": "F15c",
"summary": "回复免密",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go"
},
{
"id": "F15d",
"summary": "进群仍要密码",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/identity/self_talk_test.go TestF15JoinNeedsPasswordDespiteGrant"
},
{
"id": "F15e",
"summary": "防多账号轮流猜",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go"
}
],
"passed": 5,
"total": 5
},
{
"id": "F16",
"status": "pass",
"note": "已测:建群并拉成员后可群发;群主权限/退出/解散同号等未穷尽"
"note": "F16a 通过(test/accept/accept_test.go);F16b 通过(internal/app/group/group_test.go TestF16CreateAddPasswordAndOwner);F16c 通过(internal/app/group/group_test.go TestF16LeaveRemoveDissolve);F16d 通过(internal/app/group/group_test.go TestF16LeaveRemoveDissolve)",
"subs": [
{
"id": "F16a",
"summary": "建群拉成员后可群发",
"status": "pass",
"evidence": "test/accept/accept_test.go"
},
{
"id": "F16b",
"summary": "群主权限",
"status": "pass",
"evidence": "internal/app/group/group_test.go TestF16CreateAddPasswordAndOwner"
},
{
"id": "F16c",
"summary": "退出后不再收到",
"status": "pass",
"evidence": "internal/app/group/group_test.go TestF16LeaveRemoveDissolve"
},
{
"id": "F16d",
"summary": "解散后同编号新群不收旧消息",
"status": "pass",
"evidence": "internal/app/group/group_test.go TestF16LeaveRemoveDissolve"
}
],
"passed": 4,
"total": 4
},
{
"id": "F17",
"status": "pass",
"note": "已测:管理登录、错误密码锁定、无 CSRF 被拒 / 有 CSRF 可通过;管端开通见 F01;管注册见 F23;令牌越权/查记录无正文等未穷尽"
"note": "F17a 通过(test/accept/accept_test.go runAdminAuth);F17b 通过(test/accept/accept_test.go runEndpointCreate);F17c 通过(test/accept/accept_test.go runRegistration;internal/admin/a3_test.go TestRegistrationToggleAndGenerate);F17d 通过(internal/admin/a3_test.go TestGroupsCRUD);F17e 通过(internal/admin/a3_test.go TestMessageDetailHasNoBody);F17f 通过(internal/admin/admin_test.go TestAPITokenAuthAndRestrictions)",
"subs": [
{
"id": "F17a",
"summary": "管理登录、CSRF、错误密码锁定",
"status": "pass",
"evidence": "test/accept/accept_test.go runAdminAuth"
},
{
"id": "F17b",
"summary": "管端开通",
"status": "pass",
"evidence": "test/accept/accept_test.go runEndpointCreate"
},
{
"id": "F17c",
"summary": "管注册",
"status": "pass",
"evidence": "test/accept/accept_test.go runRegistration;internal/admin/a3_test.go TestRegistrationToggleAndGenerate"
},
{
"id": "F17d",
"summary": "管群",
"status": "pass",
"evidence": "internal/admin/a3_test.go TestGroupsCRUD"
},
{
"id": "F17e",
"summary": "查记录响应无正文",
"status": "pass",
"evidence": "internal/admin/a3_test.go TestMessageDetailHasNoBody"
},
{
"id": "F17f",
"summary": "API 令牌可用且不能越权",
"status": "pass",
"evidence": "internal/admin/admin_test.go TestAPITokenAuthAndRestrictions"
}
],
"passed": 6,
"total": 6
},
{
"id": "F18",
"status": "pass",
"note": "已测:确认后 message_bodies 为空;同号重试不再投递;record_retention_days=0 完成后 status=not_found 且消息行消失"
"note": "F18a 通过(test/accept/rest_accept_test.go;internal/app/message/delivery_test.go TestDeliveryStateMachine/F18_body_gone_record_zero_idempotent_kept);F18b 通过(test/accept/rest_accept_test.go);F18c 通过(test/accept/rest_accept_test.go;internal/app/message/submit_test.go TestSubmitTable/idempotent_hit)",
"subs": [
{
"id": "F18a",
"summary": "送达后正文消失",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/message/delivery_test.go TestDeliveryStateMachine/F18_body_gone_record_zero_idempotent_kept"
},
{
"id": "F18b",
"summary": "记录天数 0 时连记录消失",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go"
},
{
"id": "F18c",
"summary": "防重仍在",
"status": "pass",
"evidence": "test/accept/rest_accept_test.go;internal/app/message/submit_test.go TestSubmitTable/idempotent_hit"
}
],
"passed": 3,
"total": 3
},
{
"id": "F19",
"status": "pass",
"note": "已测:仓库内 SDK 接入清单已通过——Go sdk/go/itest_checklist_test.go;JS sdk/js/test/checklist.test.ts;Python sdk/python/tests/test_checklist.py;Java sdk/java ChecklistTest;本波不重跑四套全量(见 RELEASE 第 4 节回归记录)"
"note": "F19a 通过(sdk/go/itest_checklist_test.go;sdk/js/test/checklist.test.ts;sdk/python/tests/test_checklist.py;sdk/java ChecklistTest)",
"subs": [
{
"id": "F19a",
"summary": "四种 SDK 接入清单(仓库内已有测试,本波不重跑全量)",
"status": "pass",
"evidence": "sdk/go/itest_checklist_test.go;sdk/js/test/checklist.test.ts;sdk/python/tests/test_checklist.py;sdk/java ChecklistTest"
}
],
"passed": 1,
"total": 1
},
{
"id": "F20",
"status": "pass",
"note": "已测:裸 MQTT WebSocket 登录、hello、发、收、确认"
"note": "F20a 通过(test/accept/accept_test.go runMessagingAccept)",
"subs": [
{
"id": "F20a",
"summary": "裸 MQTT WebSocket 登录、收、确认、发",
"status": "pass",
"evidence": "test/accept/accept_test.go runMessagingAccept"
}
],
"passed": 1,
"total": 1
},
{
"id": "F21",
"status": "pass",
"note": "已测:同一 listen 端口提供 /healthz、管理 API、注册、WebSocket /mqtt;后台分离端口未测"
"status": "partial",
"note": "F21a 通过(test/accept/accept_test.go);F21b 未测(验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过);F21c 通过(internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen)",
"subs": [
{
"id": "F21a",
"summary": "同一 listen 端口提供后台、WebSocket、注册",
"status": "pass",
"evidence": "test/accept/accept_test.go"
},
{
"id": "F21b",
"summary": "裸 TCP MQTT 登录收发",
"status": "untested",
"note": "验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过"
},
{
"id": "F21c",
"summary": "后台可分到单独端口",
"status": "pass",
"evidence": "internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen"
}
],
"passed": 2,
"total": 3
},
{
"id": "F22",
"status": "pass",
"note": "已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker 全量、/metrics 抓取"
"status": "partial",
"note": "F22a 通过(test/accept/accept_test.go runInitHealthz);F22b 通过(cmd/nixmsg/commands_test.go TestBackupVacuumInto);F22c 通过(internal/store/db_test.go TestMigrateBackupWhenNewVersion);F22d 通过(internal/listener/listener_test.go TestCertReloadUsesNewCert);F22e 通过(internal/metrics/metrics_test.go TestMetricsHandlerExposesText;internal/httpx/metrics_test.go TestMetricsGateSharedPort);F22f 未测(镜像未推送;本波未跑 compose 全量)",
"subs": [
{
"id": "F22a",
"summary": "空目录 admin init + serve 健康检查",
"status": "pass",
"evidence": "test/accept/accept_test.go runInitHealthz"
},
{
"id": "F22b",
"summary": "备份",
"status": "pass",
"evidence": "cmd/nixmsg/commands_test.go TestBackupVacuumInto"
},
{
"id": "F22c",
"summary": "升级迁移前备份",
"status": "pass",
"evidence": "internal/store/db_test.go TestMigrateBackupWhenNewVersion"
},
{
"id": "F22d",
"summary": "证书按 mtime 重载",
"status": "pass",
"evidence": "internal/listener/listener_test.go TestCertReloadUsesNewCert"
},
{
"id": "F22e",
"summary": "指标可抓取",
"status": "pass",
"evidence": "internal/metrics/metrics_test.go TestMetricsHandlerExposesText;internal/httpx/metrics_test.go TestMetricsGateSharedPort"
},
{
"id": "F22f",
"summary": "Docker 全量冒烟",
"status": "untested",
"note": "镜像未推送;本波未跑 compose 全量"
}
],
"passed": 5,
"total": 6
},
{
"id": "F23",
"status": "pass",
"note": "已测:开关、错码、对码、换码;输错锁定未在本用例穷尽"
"note": "F23a 通过(test/accept/accept_test.go runRegistration;internal/app/identity/register_test.go TestRegisterF23_*);F23b 通过(internal/app/identity/register_test.go TestRegisterF23_WrongCodeLock)",
"subs": [
{
"id": "F23a",
"summary": "注册开关、安全码校验、换码不影响已注册",
"status": "pass",
"evidence": "test/accept/accept_test.go runRegistration;internal/app/identity/register_test.go TestRegisterF23_*"
},
{
"id": "F23b",
"summary": "输错锁定",
"status": "pass",
"evidence": "internal/app/identity/register_test.go TestRegisterF23_WrongCodeLock"
}
],
"passed": 2,
"total": 2
}
]
}