- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
`clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
(0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
413 lines
16 KiB
Go
413 lines
16 KiB
Go
package gateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
)
|
||
|
||
// 本文件锁定 Android 协同 §G 的两条:G2(REST `stream:true` 不得「静默成功」)
|
||
// 与 G4(chat 全链路幂等 —— clientMsgId)。
|
||
//
|
||
// G2 的核心断言:**总线饱和(或不投递)时,经 REST `stream:true` 发起的对话
|
||
// 仍然落库并产生帧**。它与 ws_route_chat_test.go 的
|
||
// TestWSChatFrameSurvivesSaturatedBus 是同一条原则在另一条入口上的落地:会话
|
||
// 消息不得依赖可丢弃的通道(design/15 §2.1)。
|
||
//
|
||
// G4 的核心断言:同一 clientMsgId 发两次 → 只有一条用户消息、只有一个回合,且
|
||
// 第二次的响应明确(既有 messageId);省略该字段时行为与今天逐字一致。
|
||
|
||
// postChat 以 owner token POST 一次 /api/v1/chat,返回响应与原始响应体。
|
||
func postChat(t *testing.T, url, token string, body map[string]any) (*http.Response, []byte) {
|
||
t.Helper()
|
||
return doReq(t, http.MethodPost, url+"/api/v1/chat", body, token)
|
||
}
|
||
|
||
// dataOf 解出 {data:...} 信封里的 data 对象。
|
||
func dataOf(t *testing.T, body []byte) map[string]any {
|
||
t.Helper()
|
||
raw, ok := decodeBody(t, body)["data"]
|
||
if !ok {
|
||
t.Fatalf("响应没有 data 信封:%s", body)
|
||
}
|
||
var out map[string]any
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
t.Fatalf("解码 data 失败:%v(body=%s)", err, body)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// errorCodeOf 解出 {error:{code}} 里的 code。
|
||
func errorCodeOf(t *testing.T, body []byte) string {
|
||
t.Helper()
|
||
var eobj struct {
|
||
Code string `json:"code"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
|
||
t.Fatalf("解码 error 信封失败:%v(body=%s)", err, body)
|
||
}
|
||
return eobj.Code
|
||
}
|
||
|
||
// frameTypes 汇总见过的帧类型,供失败信息使用(读帧超时时能一眼看出漏了什么)。
|
||
func chatFrameTypes(frames []wsEvent) []string {
|
||
out := make([]string, 0, len(frames))
|
||
for _, f := range frames {
|
||
out = append(out, f.Type)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// readFramesUntil 在读到一个 want 类型的帧之前持续读帧(最多 5 秒),返回它
|
||
// 见过的**全部**帧。它带读超时,因此断言失败时是明确的报错,而不是挂死。
|
||
func readChatFramesUntil(t *testing.T, conn *websocket.Conn, want string) []wsEvent {
|
||
t.Helper()
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
seen := make([]wsEvent, 0, 8)
|
||
for time.Now().Before(deadline) {
|
||
_ = conn.SetReadDeadline(deadline)
|
||
_, msg, err := conn.ReadMessage()
|
||
if err != nil {
|
||
t.Fatalf("等待 %q 时读 WS 帧失败:%v(已见 %v)", want, err, chatFrameTypes(seen))
|
||
}
|
||
var ev wsEvent
|
||
if err := json.Unmarshal(msg, &ev); err != nil {
|
||
t.Fatalf("解码帧 %s:%v", msg, err)
|
||
}
|
||
seen = append(seen, ev)
|
||
if ev.Type == want {
|
||
return seen
|
||
}
|
||
}
|
||
t.Fatalf("等待 %q 超时(已见 %v)", want, chatFrameTypes(seen))
|
||
return nil
|
||
}
|
||
|
||
// hasFrame 报告帧列表里是否出现过某类型。
|
||
func hasFrame(frames []wsEvent, typ string) bool {
|
||
for _, f := range frames {
|
||
if f.Type == typ {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// TestChatStreamSurvivesSaturatedBus 是 G2 的主断言:总线**完全不投递**(饱和
|
||
// 丢弃)时,REST `stream:true` 依然驱动回合、落库用户消息与助手回复,并产出下行
|
||
// 帧。修复前这一轮是 publishChatStream → bus.Publish → hub 订阅:饱和时事件被
|
||
// 丢,客户端却早就拿到了 200 {"stream":true} —— 不落库、没有任何帧。
|
||
func TestChatStreamSurvivesSaturatedBus(t *testing.T) {
|
||
t.Parallel()
|
||
run := &recordingRunner{}
|
||
hist := newReplayHistoryStore()
|
||
sb := &saturatedBus{}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: sb})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
conn := dialWS(t, ts.URL, tok)
|
||
defer conn.Close()
|
||
waitConnCount(t, srv, 1)
|
||
|
||
resp, body := postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "总线饱和也要到", "stream": true})
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200(body=%s)", resp.StatusCode, body)
|
||
}
|
||
if got := dataOf(t, body); got["stream"] != true {
|
||
t.Fatalf("data = %v, want stream:true", got)
|
||
}
|
||
|
||
// 200 回给客户端时用户消息已经落库(handler 同步穿过提交与落库),助手回复
|
||
// 随回合结算到达。
|
||
waitUntil(t, "回合被驱动", func() bool { return run.count() == 1 })
|
||
waitUntil(t, "用户+助手消息落库", func() bool { return len(hist.records()) == 2 })
|
||
recs := hist.records()
|
||
if recs[0].Role != "user" || recs[0].Content != "总线饱和也要到" {
|
||
t.Fatalf("用户消息 = %+v,want 刚发的那条", recs[0])
|
||
}
|
||
if recs[1].Role != "assistant" || recs[1].Content == "" {
|
||
t.Fatalf("助手回复 = %+v,want 非空", recs[1])
|
||
}
|
||
|
||
// 帧:用户消息的 message_persisted 必然在 stream_end 之前出现过。
|
||
frames := readChatFramesUntil(t, conn, "stream_end")
|
||
if !hasFrame(frames, "message_persisted") {
|
||
t.Fatalf("帧 = %v,want 至少一条 message_persisted(客户端要靠它追加消息)", chatFrameTypes(frames))
|
||
}
|
||
|
||
// 会话消息绝不经可丢弃的通道:ws.chat 主题上不应有任何发布。
|
||
for _, subject := range sb.subjects() {
|
||
if subject == wsChatSubject {
|
||
t.Fatalf("REST stream:true 仍把会话消息发布到可丢弃的 bus 主题 %q(design/15 §2.1 禁止)", subject)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestChatStreamWithoutRunnerIsNotSilentlyAccepted 钉住诚实的退化:没接 loop 的
|
||
// 网关(runner 为 nil)**不能**对 stream:true 回 200 —— 修复前它照样回 200,
|
||
// 而客户端永远等不到任何帧、也永远不会落库。这里与同步路径同口径回 500。
|
||
func TestChatStreamWithoutRunnerIsNotSilentlyAccepted(t *testing.T) {
|
||
t.Parallel()
|
||
env := newEnv(t, nil, false)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := postChat(t, env.ts.URL, tok, map[string]any{"sessionId": "web", "text": "hi", "stream": true})
|
||
if resp.StatusCode != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, want 500(body=%s)", resp.StatusCode, body)
|
||
}
|
||
if code := errorCodeOf(t, body); code != "chat_unavailable" {
|
||
t.Fatalf("code = %q, want chat_unavailable", code)
|
||
}
|
||
}
|
||
|
||
// TestChatStreamAnswersBusyAndFull 锁定 G2 的顺带项:`stream:true` 也能给出
|
||
// 「排队 / 满」这两个明确的答案,而不是一律 200。(修复前 handler 提前 return,
|
||
// 202 queued 与 409 chat_busy 在这条路径上永远不可能出现。)
|
||
func TestChatStreamAnswersBusyAndFull(t *testing.T) {
|
||
t.Parallel()
|
||
release := make(chan struct{})
|
||
run := &blockingRunner{release: release}
|
||
hist := newReplayHistoryStore()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
// 第一条:会话空闲 → 200 stream:true,回合在后台跑着。
|
||
resp, body := postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "A", "stream": true})
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("第一条 status = %d, want 200(body=%s)", resp.StatusCode, body)
|
||
}
|
||
waitUntil(t, "A 回合在飞", func() bool { return run.started() == 1 })
|
||
|
||
// 第二条:会话正忙 → 202 {stream:true, queued:true, queueDepth:1},不阻塞。
|
||
resp, body = postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "B", "stream": true})
|
||
if resp.StatusCode != http.StatusAccepted {
|
||
t.Fatalf("第二条 status = %d, want 202(body=%s)", resp.StatusCode, body)
|
||
}
|
||
got := dataOf(t, body)
|
||
if got["queued"] != true || got["stream"] != true || got["queueDepth"] != float64(1) {
|
||
t.Fatalf("第二条 data = %v, want {stream:true,queued:true,queueDepth:1}", got)
|
||
}
|
||
|
||
// 填满待处理队列(默认上限 5;已入队 1 条),下一条必须是 409 chat_busy。
|
||
for i := 0; i < 4; i++ {
|
||
resp, body = postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "fill", "stream": true})
|
||
if resp.StatusCode != http.StatusAccepted {
|
||
t.Fatalf("第 %d 条填充 status = %d, want 202(body=%s)", i+3, resp.StatusCode, body)
|
||
}
|
||
}
|
||
resp, body = postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "溢出", "stream": true})
|
||
if resp.StatusCode != http.StatusConflict {
|
||
t.Fatalf("溢出 status = %d, want 409(body=%s)", resp.StatusCode, body)
|
||
}
|
||
if code := errorCodeOf(t, body); code != "chat_busy" {
|
||
t.Fatalf("溢出 code = %q, want chat_busy", code)
|
||
}
|
||
|
||
close(release)
|
||
waitUntil(t, "会话排空", func() bool {
|
||
return !srv.hub.orchestrator.Busy("web") && srv.hub.orchestrator.QueueDepth("web") == 0
|
||
})
|
||
}
|
||
|
||
// TestChatClientMsgIDDedupesTurn 是 G4 的主断言(REST 同步路径):同一个
|
||
// clientMsgId 发两次 → 只有一条用户消息、只有一个回合;第二次的响应明确给出既有
|
||
// messageId,客户端据此对齐本地乐观气泡,而不是靠「文本相等」猜。
|
||
func TestChatClientMsgIDDedupesTurn(t *testing.T) {
|
||
t.Parallel()
|
||
run := &countingRunner{}
|
||
hist := &fakeHistory{}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
body := map[string]any{"sessionId": "web", "text": "弱网重发的那条", "clientMsgId": "local-bubble-1"}
|
||
|
||
resp, first := postChat(t, ts.URL, tok, body)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("第一次 status = %d, want 200(body=%s)", resp.StatusCode, first)
|
||
}
|
||
|
||
// 第二次:逐字相同的 clientMsgId → 明确判重,不落库、不起回合。
|
||
resp, second := postChat(t, ts.URL, tok, body)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("第二次 status = %d, want 200(body=%s)", resp.StatusCode, second)
|
||
}
|
||
dup := dataOf(t, second)
|
||
if dup["duplicate"] != true {
|
||
t.Fatalf("第二次 data = %v, want duplicate:true", dup)
|
||
}
|
||
msgID, _ := dup["messageId"].(string)
|
||
if msgID == "" {
|
||
t.Fatalf("第二次 data = %v, want 既有 messageId(客户端要用它对齐气泡)", dup)
|
||
}
|
||
|
||
// 一个回合、一条用户消息:模型只被驱动一次,库里只有 user+assistant 两条。
|
||
if got := run.count(); got != 1 {
|
||
t.Fatalf("回合数 = %d, want 1(重复的 clientMsgId 绝不起第二个回合)", got)
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 2 {
|
||
t.Fatalf("落库条数 = %d, want 2(一条用户 + 一条助手)", len(recs))
|
||
}
|
||
if recs[0].Role != "user" || recs[0].ClientMsgID != "local-bubble-1" {
|
||
t.Fatalf("用户消息 = %+v,want clientMsgId=local-bubble-1 被回填", recs[0])
|
||
}
|
||
if recs[0].ID != msgID {
|
||
t.Fatalf("响应 messageId = %q,want 等于落库那条用户消息的 id %q", msgID, recs[0].ID)
|
||
}
|
||
}
|
||
|
||
// TestChatClientMsgIDDuplicateWhilePending 锁定幂等在**排队**分支上的行为:会话
|
||
// 忙时,同一个 clientMsgId 的第二条必须立刻判重,而不是又排一次队、又落一条用户
|
||
// 消息(重复到达的判定与队列分支无关,两者都在状态锁下的同一道门里)。
|
||
func TestChatClientMsgIDDuplicateWhilePending(t *testing.T) {
|
||
t.Parallel()
|
||
release := make(chan struct{})
|
||
run := &blockingRunner{release: release}
|
||
hist := &fakeHistory{}
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
// 第一条(不带幂等键)占住会话:stream 路径立刻返回,回合在后台跑着。
|
||
resp, body := postChat(t, ts.URL, tok, map[string]any{"sessionId": "web", "text": "占住会话", "stream": true})
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("第一条 status = %d, want 200(body=%s)", resp.StatusCode, body)
|
||
}
|
||
waitUntil(t, "会话变忙", func() bool { return run.started() == 1 })
|
||
|
||
dup := map[string]any{"sessionId": "web", "text": "排队的那条", "clientMsgId": "cm-pending"}
|
||
resp, body = postChat(t, ts.URL, tok, dup)
|
||
if resp.StatusCode != http.StatusAccepted {
|
||
t.Fatalf("排队那条 status = %d, want 202(body=%s)", resp.StatusCode, body)
|
||
}
|
||
// 同键再来一次:判重,队列深度不再增长。
|
||
resp, body = postChat(t, ts.URL, tok, dup)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("重复那条 status = %d, want 200(body=%s)", resp.StatusCode, body)
|
||
}
|
||
if got := dataOf(t, body); got["duplicate"] != true {
|
||
t.Fatalf("重复那条 data = %v, want duplicate:true", got)
|
||
}
|
||
if depth := srv.hub.orchestrator.QueueDepth("web"); depth != 1 {
|
||
t.Fatalf("待处理深度 = %d, want 1(重复的那条不得再排队)", depth)
|
||
}
|
||
withKey := 0
|
||
for _, m := range hist.records() {
|
||
if m.ClientMsgID == "cm-pending" {
|
||
withKey++
|
||
}
|
||
}
|
||
if withKey != 1 {
|
||
t.Fatalf("带该 clientMsgId 的用户消息 = %d 条, want 1", withKey)
|
||
}
|
||
|
||
close(release)
|
||
waitUntil(t, "会话排空", func() bool {
|
||
return !srv.hub.orchestrator.Busy("web") && srv.hub.orchestrator.QueueDepth("web") == 0
|
||
})
|
||
}
|
||
|
||
// TestChatWithoutClientMsgIDIsUnchanged 钉住 G4 的兼容口径:省略 clientMsgId 时
|
||
// 行为与今天逐字一致 —— 同一段文本发两次就是两条消息、两个回合,响应里也没有
|
||
// 任何 duplicate/messageId 字段(老客户端零影响)。
|
||
func TestChatWithoutClientMsgIDIsUnchanged(t *testing.T) {
|
||
t.Parallel()
|
||
run := &countingRunner{}
|
||
hist := &fakeHistory{}
|
||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
body := map[string]any{"sessionId": "web", "text": "同一段文本"}
|
||
|
||
for i := 0; i < 2; i++ {
|
||
resp, raw := postChat(t, ts.URL, tok, body)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("第 %d 次 status = %d, want 200(body=%s)", i+1, resp.StatusCode, raw)
|
||
}
|
||
got := dataOf(t, raw)
|
||
if _, ok := got["duplicate"]; ok {
|
||
t.Fatalf("第 %d 次 data = %v,未带幂等键时不该有 duplicate", i+1, got)
|
||
}
|
||
if got["reply"] == nil {
|
||
t.Fatalf("第 %d 次 data = %v, want 正常回复契约", i+1, got)
|
||
}
|
||
}
|
||
if got := run.count(); got != 2 {
|
||
t.Fatalf("回合数 = %d, want 2(无幂等键时逐字保持旧行为)", got)
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 4 {
|
||
t.Fatalf("落库条数 = %d, want 4(两次各一条用户 + 一条助手)", len(recs))
|
||
}
|
||
for _, m := range recs {
|
||
if m.ClientMsgID != "" {
|
||
t.Fatalf("落库消息 = %+v,未带幂等键时 clientMsgId 必须为空", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestWSChatClientMsgIDDuplicateIsExplicit 锁定 WS 侧的重复语义:同一
|
||
// clientMsgId 的两条 chat 帧**并发到达**(两条帧各自进 goroutine)时,只有一个
|
||
// 赢家 —— 一个回合、一条用户消息;输掉的那条收到明确的 `chat_duplicate` 帧,
|
||
// 其 messageId 指向真正落库的那条用户消息。
|
||
func TestWSChatClientMsgIDDuplicateIsExplicit(t *testing.T) {
|
||
t.Parallel()
|
||
run := &recordingRunner{}
|
||
hist := newReplayHistoryStore()
|
||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Loop: run, History: hist, Bus: &saturatedBus{}})
|
||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||
|
||
conn := dialWS(t, ts.URL, tok)
|
||
defer conn.Close()
|
||
waitConnCount(t, srv, 1)
|
||
|
||
payload := json.RawMessage(`{"sessionId":"web","text":"同一条","clientMsgId":"ws-bubble-1"}`)
|
||
for i := 0; i < 2; i++ {
|
||
if err := conn.WriteJSON(wsInbound{Type: "chat", Data: payload}); err != nil {
|
||
t.Fatalf("第 %d 条 chat 帧写入失败:%v", i+1, err)
|
||
}
|
||
}
|
||
|
||
waitUntil(t, "只驱动一个回合", func() bool { return run.count() == 1 })
|
||
waitUntil(t, "用户+助手消息落库", func() bool { return len(hist.records()) == 2 })
|
||
|
||
var userID string
|
||
for _, m := range hist.records() {
|
||
if m.Role == "user" {
|
||
if m.ClientMsgID != "ws-bubble-1" {
|
||
t.Fatalf("用户消息 = %+v,want clientMsgId 被回填", m)
|
||
}
|
||
userID = m.ID
|
||
}
|
||
}
|
||
if userID == "" {
|
||
t.Fatal("没有找到落库的用户消息")
|
||
}
|
||
|
||
frames := readChatFramesUntil(t, conn, EventChatDuplicate)
|
||
var dupFrame *wsEvent
|
||
for i := range frames {
|
||
if frames[i].Type == EventChatDuplicate {
|
||
dupFrame = &frames[i]
|
||
}
|
||
}
|
||
if dupFrame == nil {
|
||
t.Fatalf("帧 = %v,want 一条 %s", chatFrameTypes(frames), EventChatDuplicate)
|
||
}
|
||
data, ok := dupFrame.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("chat_duplicate 载荷 = %#v,want 对象", dupFrame.Data)
|
||
}
|
||
if data["clientMsgId"] != "ws-bubble-1" {
|
||
t.Fatalf("chat_duplicate 载荷 = %v,want clientMsgId=ws-bubble-1", data)
|
||
}
|
||
if data["messageId"] != userID {
|
||
t.Fatalf("chat_duplicate messageId = %v,want 落库那条的 id %q", data["messageId"], userID)
|
||
}
|
||
// 重复的那条绝不产生第二个回合。
|
||
if got := run.count(); got != 1 {
|
||
t.Fatalf("回合数 = %d, want 1", got)
|
||
}
|
||
}
|