主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
234 lines
9.1 KiB
Go
234 lines
9.1 KiB
Go
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)
|
||
}
|
||
}
|