Files
Cyrene_v2/internal/services/gateway/session_read_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

234 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}