From cb9512fe11ffc16c7bfa2270a2600db0a63a6766 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 16:17:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=AA=8C=E6=94=B6=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=E8=A1=A8=E6=8C=89=E5=AD=90=E9=A1=B9=E8=AE=B0=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=99=9A=E6=8A=A5=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/DEVIATIONS.md | 12 +- docs/OPS.md | 8 +- docs/RELEASE.md | 29 +- test/accept/ACCEPTANCE.md | 128 ++++-- test/accept/accept_test.go | 1 + test/report/catalog.go | 178 +++++++++ test/report/cmd/genreport/main.go | 16 +- test/report/generate.go | 171 +++++++- test/report/generate_test.go | 112 +++++- test/report/testdata/q2_results.json | 574 +++++++++++++++++++++++++-- 11 files changed, 1138 insertions(+), 93 deletions(-) create mode 100644 test/report/catalog.go diff --git a/README.md b/README.md index 5531e62..5783a85 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ docker compose -f deploy/docker-compose.yml up -d - [产品需求](docs/PRD.md) - [开发说明](docs/DEVELOPMENT.md) - [运维手册](docs/OPS.md) -- [验收对照表](test/accept/ACCEPTANCE.md)(F01–F23 短时间项已通过;长时/环境限制见备注与 [OPS.md](docs/OPS.md) 第 9 节) +- [验收对照表](test/accept/ACCEPTANCE.md)(F01–F23 按子项记结果:通过 19,部分通过 4;未测子项见对照表与 [OPS.md](docs/OPS.md) 第 9 节) - [开发任务](docs/TASKS.md) - [与文档的偏差](docs/DEVIATIONS.md) diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index da97e46..7deb4c5 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1447,7 +1447,7 @@ - 实际做法:`test/accept/rest_accept_test.go` 用随机端口与临时目录;`grace_seconds`/`ack_timeout_seconds` 调到数秒;`record_retention_days=0` 另起进程;F19 对照表改为通过并写明四套 SDK checklist 证据路径,本波不重跑全量。 - 原因:短时可测项应收口;长时/环境限制项不假装通过。 - 备选方案:专用压测机与 Linux 宿主再补长时项。 - - 影响:`ACCEPTANCE.md` 汇总通过 23 / 失败 0 / 未测 0;长时子项仍写在备注。 + - 影响:当时 `ACCEPTANCE.md` 汇总写成通过 23 / 失败 0 / 未测 0;已被 T-02(#63)按子项更正。 2. **harness MQTT 握手后清除 SetDeadline** - 原条款:`test/harness` 属总控;Dial 时 `SetDeadline(now+timeout)`。 @@ -1708,3 +1708,13 @@ issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是 - 原因:原工具只发 MQTT 3.1.1 CONNECT、无用户名密码、无 hello/收发,连不上 NixMsg,也测不出 PRD 第 8 节任何指标。 - 备选方案:直接用四套 SDK 压测(否决:工具需覆盖裸设备路径,且不改 sdk)。 - 影响:仅 `test/load/*` 与本文本节;不改 broker/message/sdk。本地验收为 50 连接、每秒 20 条、1 分钟出报告。 + +### 复审修复 T-02 + +1. **验收对照表不再把部分覆盖标成整项通过** + - 日期:2026-09-30 + - 原条款:PRD §10;issue #63。 + - 实际做法:`test/report` 增加 `partial` 与子项;整项仅在全部子项通过时写「通过」,否则「部分通过 (x/y)」。未覆盖子项三选一:引用已有单元/集成测试作证据、保持未测并写原因。本波不新编端到端用例去「补绿」,不把未跑的长时/环境项标通过。同步 `ACCEPTANCE.md`、`q2_results.json`、`docs/RELEASE.md`、`docs/OPS.md`、`README.md`。 + - 原因:原先 12 项备注写着未穷尽仍标通过,交付说明写成「通过 23」。 + - 备选方案:为每个未测子项补验收用例(本波不做,避免为变绿放松断言)。 + - 影响:汇总改为通过 19、部分通过 4(F03/F08/F21/F22)、失败 0。F19 仍引用仓库内 SDK 清单、本波不重跑。 diff --git a/docs/OPS.md b/docs/OPS.md index 9ffd592..418ffac 100644 --- a/docs/OPS.md +++ b/docs/OPS.md @@ -127,10 +127,10 @@ curl -sS -H "Authorization: Bearer $NIXMSG_METRICS_TOKEN" http://127.0.0.1:7443/ ## 9. 验收与仍跳过的长时项 -F01–F23 短时间验收对照表见 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(汇总通过 23,失败 0,未测 0)。下列因环境或时长限制**未测**,不得宣称已通过: +F01–F23 验收对照表见 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(汇总通过 19,部分通过 4,失败 0,未测 0)。整项通过要求该行子项都有证据。下列子项**未测**,不得宣称已通过: - F03:1000 端全表 1 秒内返回、真拔网线后心跳超时离线 -- F08 / Q3:Linux netem 20% 丢包(本机 Windows) +- F08:Linux netem 20% 丢包(本机 Windows) +- F21:裸 TCP MQTT 登录收发(验收只走 WebSocket) +- F22:Docker 全量冒烟 - 压测:1000 连接保持 10 分钟、每秒 200 条 - -F22 通过的子集仅覆盖 init + 健康检查等;备份恢复、升级迁移、证书重载、Docker 全量、`/metrics` 抓取等仍见对照表备注。 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index bdc23bb..074da0a 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -17,13 +17,7 @@ ## 2. F01–F23 验收结果 -来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(生成时间 2026-09-30T02:16:46Z)。对照表汇总:通过 23,失败 0,未测 0。 - -长时/环境限制项在对照表备注中保留「未测子项」说明,不单独占「未测」行: - -- F03:未跑 1000 端全表 1s、真拔网线心跳超时(关连接模拟断线) -- F08:Linux netem 20% 丢包未测(本机 Windows) -- 压测:未跑 1000 连接保持 10 分钟 +来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(T-02 按子项重写,见 issue #63)。对照表汇总:**通过 19,部分通过 4,失败 0,未测 0**。整项「通过」要求该行 PRD 一句话里的子项都有测试证据;不得把部分覆盖标成通过。 ### 通过 @@ -31,12 +25,10 @@ |---|---| | F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | | F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | -| F03 | 断开后状态及时变离线,全表可列出 | | F04 | 只通知订阅了的端 | | F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | | F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | | F07 | 256 KiB 通过,超出拒绝,接收上限生效 | -| F08 | 弱网最终送达且应用层不重复,重启后续传 | | F09 | 保留时间从发送时刻起算,超时过期 | | F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | | F11 | 发送方离线后到点仍发送 | @@ -47,15 +39,20 @@ | F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | | F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | | F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | -| F19 | 四种 SDK 通过同一清单 | -| F20 | 裸 MQTT 能登录、收、确认、发 | -| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | -| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | +| F19 | 四种 SDK 通过同一清单(仓库内清单测试,本波未重跑全量) | +| F20 | 裸 MQTT WebSocket 能登录、收、确认、发 | | F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | -### 未测 +### 部分通过 -无整行未测项。子项因长时间或环境限制未测的见上「长时/环境限制」与对照表备注。 +| 编号 | 已覆盖 | 仍未测 | +|---|---|---| +| F03 | 断开后变离线、目录可列出 | 1000 端全表 1s、真拔网线心跳超时 | +| F08 | 重启续传、应用层同号去重、toxiproxy 弱网 | Linux netem 20% 丢包 | +| F21 | 单端口 HTTP/WS/注册、后台分离端口 | 裸 TCP MQTT 登录收发 | +| F22 | init/健康检查、备份、迁移前备份、证书重载、/metrics | Docker 全量冒烟 | + +压测 1000 连接保持 10 分钟、每秒 200 条仍未跑(见 OPS 第 9 节)。 ### 失败 @@ -122,7 +119,7 @@ | 项 | 结果 | |---|---| | `go test ./test/accept/ -count=1`(含 F03/F04/F07/F10/F11/F14/F15/F18,F19 引用既有 SDK 清单) | 通过(约 24–27s) | -| 写入 `ACCEPTANCE.md` / `q2_results.json` | 通过 23,失败 0,未测 0 | +| 写入 `ACCEPTANCE.md` / `q2_results.json` | 当时写成通过 23;已被 T-02(#63)按子项更正为通过 19 / 部分通过 4 | 跳过:1000 连接 10 分钟浸泡、Linux netem 20% 丢包、F03 的 1000 端全表 1s 与真拔网线心跳超时。 diff --git a/test/accept/ACCEPTANCE.md b/test/accept/ACCEPTANCE.md index 2a1ea25..b78590c 100644 --- a/test/accept/ACCEPTANCE.md +++ b/test/accept/ACCEPTANCE.md @@ -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 | diff --git a/test/accept/accept_test.go b/test/accept/accept_test.go index 228dee5..c8294a2 100644 --- a/test/accept/accept_test.go +++ b/test/accept/accept_test.go @@ -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, diff --git a/test/report/catalog.go b/test/report/catalog.go new file mode 100644 index 0000000..5587015 --- /dev/null +++ b/test/report/catalog.go @@ -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 +} diff --git a/test/report/cmd/genreport/main.go b/test/report/cmd/genreport/main.go index c775181..edb693d 100644 --- a/test/report/cmd/genreport/main.go +++ b/test/report/cmd/genreport/main.go @@ -9,13 +9,19 @@ import ( func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: genreport [out.md]") + fmt.Fprintln(os.Stderr, "usage: genreport [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 { diff --git a/test/report/generate.go b/test/report/generate.go index 8693e1a..592abcc 100644 --- a/test/report/generate.go +++ b/test/report/generate.go @@ -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)) diff --git a/test/report/generate_test.go b/test/report/generate_test.go index 0a37614..f8c98b4 100644 --- a/test/report/generate_test.go +++ b/test/report/generate_test.go @@ -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 { diff --git a/test/report/testdata/q2_results.json b/test/report/testdata/q2_results.json index 0659493..eb4f02e 100644 --- a/test/report/testdata/q2_results.json +++ b/test/report/testdata/q2_results.json @@ -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 } ] }