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