package gateway import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // 这些测试覆盖 D4 读游标契约(Android 协作 B3): // GET /api/v1/sessions 携带的 unreadCount、推进端点 // POST /api/v1/sessions/{id}/read,以及为该账号其他浏览器清除角标的 // `session_read` 帧。 // unreadBySession 经线上键把 GET /api/v1/sessions 解码为 session id → unreadCount 的 // map,因此测试断言的是 JSON 契约,而不是包内结构体。 func unreadBySession(t *testing.T, ts *httptest.Server, tok string) map[string]int { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list sessions status = %d, want 200 (body=%s)", resp.StatusCode, body) } var sessions []struct { ID string `json:"id"` UnreadCount int `json:"unreadCount"` } decodeDataSlice(t, body, &sessions) out := make(map[string]int, len(sessions)) for _, s := range sessions { out[s.ID] = s.UnreadCount } return out } // TestSessionsUnreadCountAndMarkRead 端到端覆盖顺路径:有未读消息的 // 会话报告计数,POST /read 推进游标并返回 // {sessionId, readAt},列表降到游标之后发送的消息,而无请求体的 // POST(全部标记为已见)清除角标。 func TestSessionsUnreadCountAndMarkRead(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) ctx := context.Background() sub, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("new sub: %v", err) } for _, m := range []history.Message{ {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "a", CreatedAt: 1000}, {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "b", CreatedAt: 2000}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "c", CreatedAt: 3000}, {SessionID: sub, Role: history.RoleUser, Content: "sub", CreatedAt: 1500}, } { if err := st.Append(ctx, m); err != nil { t.Fatalf("seed %q: %v", m.Content, err) } } tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/read" // 尚未读过任何内容:主会话全未读,子会话各自独立地同样如此。 if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 3 || got[sub] != 1 { t.Fatalf("initial unread = %+v, want main 3 / sub 1", got) } // 把主游标推进到 2000:等于或早于它的消息即已读。 resp, body := doReq(t, http.MethodPost, base, map[string]any{"before": 2000}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("mark read status = %d, want 200 (body=%s)", resp.StatusCode, body) } env := decodeBody(t, body) if len(env) != 1 { t.Fatalf("envelope = %v, want only a data field", env) } var data struct { SessionID string `json:"sessionId"` ReadAt int64 `json:"readAt"` } if err := json.Unmarshal(env["data"], &data); err != nil { t.Fatalf("decode read data: %v (body=%s)", err, body) } if data.SessionID != history.MainSessionID || data.ReadAt != 2000 { t.Fatalf("read data = %+v, want sessionId %s readAt 2000", data, history.MainSessionID) } // 只有较晚那条消息保持未读,且子会话未被触碰。 if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 1 || got[sub] != 1 { t.Fatalf("unread after cursor 2000 = %+v, want main 1 / sub 1", got) } // 无请求体 = 「把我已见的一切标记为已读」(服务端解析为 now)。 resp, body = doReq(t, http.MethodPost, base, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("body-less mark status = %d, want 200 (body=%s)", resp.StatusCode, body) } if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil { t.Fatalf("decode body-less read data: %v (body=%s)", err, body) } if data.ReadAt < 3000 { t.Fatalf("body-less readAt = %d, want >= the newest message 3000", data.ReadAt) } if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 0 || got[sub] != 1 { t.Fatalf("unread after mark-now = %+v, want main 0 / sub 1", got) } markedNow := data.ReadAt // 游标只向前移动:重发陈旧游标不得重新打开角标。 resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("stale mark status = %d, want 200 (body=%s)", resp.StatusCode, body) } if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil { t.Fatalf("decode stale read data: %v", err) } if data.ReadAt != markedNow { t.Fatalf("stale readAt = %d, want the cursor to stay at %d", data.ReadAt, markedNow) } if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 0 { t.Fatalf("unread after stale mark = %d, want 0", got[history.MainSessionID]) } } // TestMarkSessionReadValidation 覆盖失败契约:存在但不可用的 // `before` 返回 400,未知会话 404,viewer 403,HistoryStore // 缺失 501 —— 其中任何一个都不得被误认为读成功。 func TestMarkSessionReadValidation(t *testing.T) { t.Parallel() _, ts, jwt, _ := newHistoryEnv(t) tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/sessions/web/read" for _, tc := range []struct { name string body any }{ {name: "zero", body: map[string]any{"before": 0}}, {name: "negative", body: map[string]any{"before": -1}}, {name: "non-numeric", body: map[string]any{"before": "abc"}}, {name: "not an object", body: "not-an-object"}, } { t.Run(tc.name, func(t *testing.T) { resp, body := doReq(t, http.MethodPost, base, tc.body, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) }) } // 空对象意味着「现在」,而不是「空操作」。 resp, body := doReq(t, http.MethodPost, base, map[string]any{}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("empty object status = %d, want 200 (body=%s)", resp.StatusCode, body) } // 未知会话 → 404;会话 id 不会被自动创建。 resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 1000}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown session status = %d, want 404 (body=%s)", resp.StatusCode, body) } // viewer 不能写游标(owner/admin 历史写,与 PATCH /sessions 同)。 viewer := signAny(t, jwt, "acct", auth.RoleViewer) resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, viewer) if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body) } // 而完全不带 token 是 401,不是静默成功。 resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("anonymous status = %d, want 401 (body=%s)", resp.StatusCode, body) } } // TestMarkSessionReadNotWired 钉住诚实的退化:未注入 HistoryStore 时 // 端点返回 501 not_implemented,而不是假装游标已移动。 func TestMarkSessionReadNotWired(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web/read", map[string]any{"before": 1000}, tok) if resp.StatusCode != http.StatusNotImplemented { t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // TestMarkSessionReadBroadcasts 断言跨端同步帧:成功的推进恰好 // 推送一个 `session_read` 帧,作用域为该会话并携带新的 // 游标;而失败的推进(404)什么也不推。 func TestMarkSessionReadBroadcasts(t *testing.T) { t.Parallel() srv, ts, jwt, _ := newHistoryEnv(t) c := newFakeConn(srv.hub, 32) srv.hub.register(c) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/read", map[string]any{"before": 2000}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } ev := readFakeFrame(t, c) if ev.Type != "session_read" { t.Fatalf("frame type = %q, want session_read (frame=%+v)", ev.Type, ev) } if ev.SessionID != history.MainSessionID { t.Fatalf("frame sessionId = %q, want %q", ev.SessionID, history.MainSessionID) } data, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("frame data = %+v, want object", ev.Data) } if data["readAt"] != float64(2000) { t.Fatalf("frame data.readAt = %v, want 2000", data["readAt"]) } if err := connHasNoFrame(c); err != nil { t.Fatal(err) } // 失败的推进不得告诉其他端角标已被清除。 resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 2000}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown status = %d, want 404 (body=%s)", resp.StatusCode, body) } if err := connHasNoFrame(c); err != nil { t.Fatal(err) } }