Files
Cyrene_v2/internal/services/gateway/history_handler_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

485 lines
19 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"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newHistoryEnv 构建一个接线了真实内存 history 存储的 Server,返回
// httptest 服务器、JWT 服务与底层 history 存储。
func newHistoryEnv(t *testing.T) (*Server, *httptest.Server, *auth.JWTService, *history.Store) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := history.NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("new history store: %v", err)
}
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st})
return srv, ts, jwt, st
}
// decodeDataSlice 把 {data:[...]} 信封解码进给定的切片目标。
func decodeDataSlice(t *testing.T, body []byte, out any) {
t.Helper()
if err := json.Unmarshal(decodeBody(t, body)["data"], out); err != nil {
t.Fatalf("decode data: %v (body=%s)", err, body)
}
}
func TestHistoryCreateAndListSessions(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 全新存储总会列出主会话。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var initial []historySessionSummary
decodeDataSlice(t, body, &initial)
if len(initial) != 1 || initial[0].ID != history.MainSessionID || initial[0].Type != history.SessionMain {
t.Fatalf("initial sessions = %+v, want [main web]", initial)
}
// 创建一个子会话。
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var created struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
t.Fatalf("decode create data: %v (body=%s)", err, body)
}
if !strings.HasPrefix(created.SessionID, history.MainSessionID+":") {
t.Fatalf("sessionId = %q, want prefix web:", created.SessionID)
}
// 现在 main 在前,sub 在后。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var sessions []historySessionSummary
decodeDataSlice(t, body, &sessions)
if len(sessions) != 2 || sessions[0].ID != history.MainSessionID || sessions[0].Type != history.SessionMain {
t.Fatalf("sessions[0] = %+v, want main web first", sessions[0])
}
if sessions[1].ID != created.SessionID || sessions[1].Type != history.SessionSub {
t.Fatalf("sessions[1] = %+v, want sub %s", sessions[1], created.SessionID)
}
}
func TestHistoryListMessagesPagination(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
// 用毫秒时间戳递增的消息播种主会话。
for _, ts := range []int64{1000, 2000, 3000, 4000} {
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: ts}); err != nil {
t.Fatalf("seed %d: %v", ts, err)
}
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 最新优先的一页,2 条。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("first page status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var page []historyMessage
decodeDataSlice(t, body, &page)
if len(page) != 2 || page[0].CreatedAt != 4000 || page[1].CreatedAt != 3000 {
t.Fatalf("first page = %+v, want [4000 3000]", page)
}
// 游标设在该页最旧(3000)之前 → 更旧的一页 [2000 1000]。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?before=3000&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("older page status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var older []historyMessage
decodeDataSlice(t, body, &older)
if len(older) != 2 || older[0].CreatedAt != 2000 || older[1].CreatedAt != 1000 {
t.Fatalf("older page = %+v, want [2000 1000]", older)
}
// 非法 limit/before → 400。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid limit status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
}
func TestHistoryListMessagesPlatformFilter(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "web", Platform: history.PlatformWeb, CreatedAt: 1000}); err != nil {
t.Fatalf("seed web: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "tg", Platform: "telegram", CreatedAt: 2000}); err != nil {
t.Fatalf("seed tg: %v", err)
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 不带 platform → 完整的主会话(两条通道)。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var all []historyMessage
decodeDataSlice(t, body, &all)
if len(all) != 2 {
t.Fatalf("all = %d, want 2", len(all))
}
// ?platform=telegram → 只有 telegram 通道,且 DTO 携带 platform。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10&platform=telegram", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var tg []historyMessage
decodeDataSlice(t, body, &tg)
if len(tg) != 1 || tg[0].Content != "tg" || tg[0].Platform != "telegram" {
t.Fatalf("telegram page = %+v, want one platform=telegram message", tg)
}
}
// handleChannelMessagesGranularity 在每种粒度上演练管理适配器日志
// 端点:单个适配器配置(?adapter=)、整个平台(?platform=)、
// 以及全部 IM 适配器总览(二者都不给)。它断言 DTO 上携带
// adapterId,使面板能标注来源适配器配置。
func TestHistoryChannelMessagesGranularity(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
for _, m := range []history.Message{
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
{SessionID: "sess-g", Role: history.RoleUser, Content: "kook-group", Platform: "kook", AdapterID: "kook:3bef", SenderName: "张三", CreatedAt: 2000},
{SessionID: "sess-t", Role: history.RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", CreatedAt: 3000},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "web-msg", Platform: history.PlatformWeb, CreatedAt: 4000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// ?adapter=kook:3bef → 只有该适配器配置,包含 owner 镜像与
// 群消息,最新优先,且 DTO 携带 adapterId。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?adapter=kook:3bef", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("adapter status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var byAdapter []historyMessage
decodeDataSlice(t, body, &byAdapter)
if len(byAdapter) != 2 {
t.Fatalf("adapter page = %d, want 2 (%+v)", len(byAdapter), byAdapter)
}
if byAdapter[0].Content != "kook-group" || byAdapter[0].AdapterID != "kook:3bef" {
t.Fatalf("adapter[0] = %+v, want kook-group with adapterId", byAdapter[0])
}
if byAdapter[1].Content != "kook-owner" || byAdapter[1].SessionID != history.MainSessionID {
t.Fatalf("adapter[1] = %+v, want kook-owner on main session", byAdapter[1])
}
// ?platform=kook → 跨隔离会话的平台通道(owner 私有
// 镜像被排除,与 handleChannelPlatformLog 语义一致)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?platform=kook", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var byPlatform []historyMessage
decodeDataSlice(t, body, &byPlatform)
if len(byPlatform) != 1 || byPlatform[0].Content != "kook-group" {
t.Fatalf("platform page = %+v, want [kook-group]", byPlatform)
}
// 无过滤 → 全部 IM 适配器总览(adapter_id != ''),web 被排除。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var all []historyMessage
decodeDataSlice(t, body, &all)
if len(all) != 3 {
t.Fatalf("all page = %d, want 3 (%+v)", len(all), all)
}
if all[0].Content != "tg-group" || all[1].Content != "kook-group" || all[2].Content != "kook-owner" {
t.Fatalf("all order = %+v, want [tg-group kook-group kook-owner]", all)
}
// 非法 before → 400。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?before=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid before status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
}
func TestHistoryDeleteSubSession(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 创建一个子会话并播种一条消息。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var created struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
t.Fatalf("decode create data: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: created.SessionID, Role: history.RoleUser, Content: "x", CreatedAt: 1000}); err != nil {
t.Fatalf("seed sub: %v", err)
}
// 删除它。
resp, _ = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+created.SessionID, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status = %d, want 200", resp.StatusCode)
}
// 它已从列表中消失。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200", resp.StatusCode)
}
var sessions []historySessionSummary
decodeDataSlice(t, body, &sessions)
if len(sessions) != 1 || sessions[0].ID != history.MainSessionID {
t.Fatalf("sessions after delete = %+v, want [main]", sessions)
}
// 主会话不可变 → 403。
resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+history.MainSessionID, nil, tok)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("delete main status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
// TestHistoryRenameSession 覆盖 PATCH /api/v1/sessions/{id}:重命名子会话
// 与固定的主会话、输入边界、RBAC 以及未知 id。
func TestHistoryRenameSession(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 创建一个待重命名的子会话。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var created struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
t.Fatalf("decode create data: %v", err)
}
// 重命名它并断言响应信封与持久化后的列表。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": " 我的会话 "}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rename status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var renamed struct {
ID string `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &renamed); err != nil {
t.Fatalf("decode rename data: %v", err)
}
if renamed.ID != created.SessionID || renamed.Title != "我的会话" {
t.Fatalf("rename data = %+v, want id %s title 我的会话", renamed, created.SessionID)
}
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var sessions []historySessionSummary
decodeDataSlice(t, body, &sessions)
found := false
for _, m := range sessions {
if m.ID == created.SessionID {
found = true
if m.Title != "我的会话" {
t.Fatalf("listed sub title = %q, want 我的会话", m.Title)
}
}
}
if !found {
t.Fatalf("renamed sub %s missing from %+v", created.SessionID, sessions)
}
// 固定的主会话即使不能删除也可以被重命名。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+history.MainSessionID, map[string]any{"title": "主控台"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rename main status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var mainRenamed struct {
ID string `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &mainRenamed); err != nil {
t.Fatalf("decode main rename data: %v", err)
}
if mainRenamed.ID != history.MainSessionID || mainRenamed.Title != "主控台" {
t.Fatalf("main rename data = %+v, want id web title 主控台", mainRenamed)
}
// 空 / 仅空白 / 过长的标题都是 400。
for _, bad := range []string{"", " ", strings.Repeat("字", maxSessionTitleRunes+1)} {
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": bad}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("title %q status = %d, want 400 (body=%s)", bad, resp.StatusCode, body)
}
wantErrorShape(t, body)
}
// 未知会话 id 是 404。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"title": "x"}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown rename status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// 恰好为 80 个 rune 边界的标题被接受。
exact := strings.Repeat("字", maxSessionTitleRunes)
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": exact}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("80-rune rename status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
// viewer 不能重命名(owner/admin 写)。
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "nope"}, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer rename status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
}
// TestPersistUserMessageNamesSession 端到端演练 Web 持久化路径:
// persistUserMessages(HTTP/WS 边界所调用的编排器钩子)经真实
// 存储写入首条用户消息,从而命名该会话。之后的用户
// 消息不再改标题。
func TestPersistUserMessageNamesSession(t *testing.T) {
t.Parallel()
_, _, _, st := newHistoryEnv(t)
o := &Orchestrator{history: st, log: testLogger()}
ctx := context.Background()
// 子会话起始无名;首条用户消息为它命名。
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "帮我规划一次旅行")}, "web:test", "chat", "cyrene")
o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "第二句话")}, "web:test", "chat", "cyrene")
metas, err := st.ListSessions(ctx)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
var gotSub string
for _, m := range metas {
if m.ID == sub {
gotSub = m.Title
}
}
if gotSub != "帮我规划一次旅行" {
t.Fatalf("sub title = %q, want 帮我规划一次旅行", gotSub)
}
// 主会话同样由它的首条 Web 用户消息命名。
o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, " 今天\n天气如何 ")}, "web:test", "chat", "cyrene")
metas, err = st.ListSessions(ctx)
if err != nil {
t.Fatalf("list sessions again: %v", err)
}
var gotMain string
for _, m := range metas {
if m.ID == history.MainSessionID {
gotMain = m.Title
}
}
if gotMain != "今天 天气如何" {
t.Fatalf("main title = %q, want 今天 天气如何", gotMain)
}
}
func TestHistoryNotWired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 未接线 history 存储时,取消息/创建/删除返回明确的 501。
for _, tc := range []struct {
method, url string
want int
}{
{http.MethodGet, "/api/v1/sessions/web/messages?limit=2", http.StatusNotImplemented},
{http.MethodPost, "/api/v1/sessions", http.StatusNotImplemented},
{http.MethodPatch, "/api/v1/sessions/web", http.StatusNotImplemented},
} {
resp, body := doReq(t, tc.method, ts.URL+tc.url, nil, tok)
if resp.StatusCode != tc.want {
t.Fatalf("%s %s status = %d, want %d (body=%s)", tc.method, tc.url, resp.StatusCode, tc.want, body)
}
if !strings.Contains(string(body), "not_implemented") {
t.Fatalf("%s %s body = %s, want not_implemented code", tc.method, tc.url, body)
}
}
}
func TestHistoryRBAC(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
owner := signAny(t, jwt, "acct", auth.RoleOwner)
// viewer 可以列出会话元数据(readAuth),但不能读写历史内容。
resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, viewer)
if resp.StatusCode != http.StatusOK {
t.Fatalf("viewer list sessions status = %d, want 200", resp.StatusCode)
}
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/web/messages", nil, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer messages status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer create status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
// owner 可以读取消息。
resp, _ = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/web/messages?limit=2", nil, owner)
if resp.StatusCode != http.StatusOK {
t.Fatalf("owner messages status = %d, want 200", resp.StatusCode)
}
}