package gateway import ( "context" "encoding/json" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // seedFullMessage 经真实存储追加 m 并返回已存消息 // (含其生成的 id),使 HTTP 测试能定位到具体某条消息。 func seedFullMessage(t *testing.T, st *history.Store, m history.Message) history.Message { t.Helper() got, err := st.AppendFull(context.Background(), m) if err != nil { t.Fatalf("append %q: %v", m.Content, err) } return got } // TestHistorySearchMessagesHTTP 覆盖 D2:会话作用域的 ?q= 搜索命中、分页、 // 空 q 回退、输入校验以及与其他会话的隔离。 func TestHistorySearchMessagesHTTP(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: "今天天气不错", CreatedAt: 1000}, {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "嗯,适合出门", CreatedAt: 1500}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气要变凉了", CreatedAt: 2000}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气又热了", CreatedAt: 3000}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气终于晴了", CreatedAt: 4000}, } { if err := st.Append(ctx, m); err != nil { t.Fatalf("seed %q: %v", m.Content, err) } } // 另一个(子)会话中的匹配消息绝不得出现在主会话搜索中。 sub, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("new sub: %v", err) } if err := st.Append(ctx, history.Message{SessionID: sub, Role: history.RoleUser, Content: "天气 sub", CreatedAt: 5000}); err != nil { t.Fatalf("seed sub: %v", err) } tok := signAny(t, jwt, "acct", auth.RoleOwner) // 最新优先的一页,2 条匹配命中。 resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&limit=2", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("search 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("search page = %+v, want [4000 3000]", page) } if page[0].SessionID != history.MainSessionID { t.Fatalf("search leaked session %q", page[0].SessionID) } // before 游标翻到更旧的命中。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&before=3000&limit=2", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("search older 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("search older = %+v, want [2000 1000]", older) } // 子会话的搜索只看到自己的命中。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages?q=天气", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("sub search status = %d, want 200 (body=%s)", resp.StatusCode, body) } var subHits []historyMessage decodeDataSlice(t, body, &subHits) if len(subHits) != 1 || subHits[0].SessionID != sub { t.Fatalf("sub search = %+v, want only the sub hit", subHits) } // 空 q 回退到普通分页(全部消息)。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=&limit=10", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("empty q status = %d, want 200 (body=%s)", resp.StatusCode, body) } var all []historyMessage decodeDataSlice(t, body, &all) if len(all) != 5 { t.Fatalf("empty q = %d messages, want 5", len(all)) } // q 与 after 互斥,且非法 limit 仍是 400。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&after=1000", nil, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("q+after status = %d, want 400 (body=%s)", resp.StatusCode, body) } resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&limit=abc", nil, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("bad limit status = %d, want 400 (body=%s)", resp.StatusCode, body) } } // TestHistoryPatchAndDeleteMessageHTTP 覆盖 D3:编辑与删除单条消息、 // 输入边界、404 与 RBAC。 func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) msg := seedFullMessage(t, st, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "原文", CreatedAt: 1000}) tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/messages/" + msg.ID // 编辑 content;role/时间戳/会话保持不变并回显。 resp, body := doReq(t, http.MethodPatch, base, map[string]any{"content": "改后的正文"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("patch status = %d, want 200 (body=%s)", resp.StatusCode, body) } var updated historyMessage if err := json.Unmarshal(decodeBody(t, body)["data"], &updated); err != nil { t.Fatalf("decode patch data: %v (body=%s)", err, body) } if updated.ID != msg.ID || updated.Content != "改后的正文" || updated.Role != history.RoleUser || updated.CreatedAt != 1000 { t.Fatalf("updated = %+v, want content edited with role/ts preserved", updated) } // 该编辑已被持久化。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) } var listed []historyMessage decodeDataSlice(t, body, &listed) if len(listed) != 1 || listed[0].Content != "改后的正文" { t.Fatalf("listed = %+v, want the edited content", listed) } // 非法编辑:空/仅空白、字段缺失、过长、未知 id。 for _, tc := range []struct { name string body any want int }{ {"empty", map[string]any{"content": ""}, http.StatusBadRequest}, {"blank", map[string]any{"content": " "}, http.StatusBadRequest}, {"missing", map[string]any{}, http.StatusBadRequest}, {"too-long", map[string]any{"content": strings.Repeat("字", maxMessageContentRunes+1)}, http.StatusBadRequest}, } { resp, body = doReq(t, http.MethodPatch, base, tc.body, tok) if resp.StatusCode != tc.want { t.Fatalf("patch %s status = %d, want %d (body=%s)", tc.name, resp.StatusCode, tc.want, body) } wantErrorShape(t, body) } resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/messages/missing", map[string]any{"content": "x"}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("patch unknown status = %d, want 404 (body=%s)", resp.StatusCode, body) } // 删除该消息,再次删除则是 404。 resp, body = doReq(t, http.MethodDelete, base, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body) } var deleted struct { Deleted string `json:"deleted"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &deleted); err != nil { t.Fatalf("decode delete data: %v (body=%s)", err, body) } if deleted.Deleted != msg.ID { t.Fatalf("deleted = %q, want %q", deleted.Deleted, msg.ID) } resp, body = doReq(t, http.MethodDelete, base, nil, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("delete again status = %d, want 404 (body=%s)", resp.StatusCode, body) } // viewer 不能编辑或删除(owner/admin 写)。 other := seedFullMessage(t, st, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: 2000}) viewer := signAny(t, jwt, "acct", auth.RoleViewer) for _, method := range []string{http.MethodPatch, http.MethodDelete} { resp, body = doReq(t, method, ts.URL+"/api/v1/messages/"+other.ID, map[string]any{"content": "nope"}, viewer) if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer %s status = %d, want 403 (body=%s)", method, resp.StatusCode, body) } } } // TestHistoryMessagesNotWiredHTTP 断言未注入 HistoryStore 时 D2/D3 端点 // 返回明确的 501。 func TestHistoryMessagesNotWiredHTTP(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) tok := signAny(t, jwt, "acct", auth.RoleOwner) for _, tc := range []struct { method, url string }{ {http.MethodGet, "/api/v1/sessions/web/messages?q=x"}, {http.MethodPatch, "/api/v1/messages/x"}, {http.MethodDelete, "/api/v1/messages/x"}, } { resp, body := doReq(t, tc.method, ts.URL+tc.url, map[string]any{"content": "x"}, tok) if resp.StatusCode != http.StatusNotImplemented { t.Fatalf("%s %s status = %d, want 501 (body=%s)", tc.method, tc.url, resp.StatusCode, body) } if !strings.Contains(string(body), "not_implemented") { t.Fatalf("%s %s body = %s, want not_implemented", tc.method, tc.url, body) } } } // TestHistoryPatchSessionPinnedHTTP 覆盖 D5:置顶/取消置顶改变 GET /sessions 顺序, // 响应只回显被改动的字段,且 A4 的标题 PATCH 仍然可用。 func TestHistoryPatchSessionPinnedHTTP(t *testing.T) { t.Parallel() _, ts, jwt, _ := newHistoryEnv(t) tok := signAny(t, jwt, "acct", auth.RoleOwner) newSub := func() string { 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: %v (body=%s)", err, body) } return created.SessionID } first, second := newSub(), newSub() sessionOrder := func() []string { 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) ids := make([]string, 0, len(sessions)) for _, s := range sessions { ids = append(ids, s.ID) } return ids } assertOrder := func(want []string) { t.Helper() got := sessionOrder() if len(got) != len(want) { t.Fatalf("order = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("order = %v, want %v", got, want) } } } assertOrder([]string{history.MainSessionID, first, second}) // 置顶最新的子会话:它会浮到主会话之上,且响应只回显 // 被置顶的字段。 resp, body := doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": true}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("pin status = %d, want 200 (body=%s)", resp.StatusCode, body) } var pinned struct { ID string `json:"id"` Pinned *bool `json:"pinned"` Title string `json:"title"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &pinned); err != nil { t.Fatalf("decode pin: %v (body=%s)", err, body) } if pinned.ID != second || pinned.Pinned == nil || !*pinned.Pinned || pinned.Title != "" { t.Fatalf("pin data = %+v, want id+pinned:true only", pinned) } assertOrder([]string{second, history.MainSessionID, first}) // GET /sessions 报告置顶标志。 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 !sessions[0].Pinned || sessions[0].ID != second { t.Fatalf("sessions[0] = %+v, want pinned sub %s", sessions[0], second) } // 标题 PATCH 仍然可用,并保持其 {id,title} 响应形状。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"title": " 置顶会话 "}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("title patch 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 title patch: %v (body=%s)", err, body) } if renamed.ID != second || renamed.Title != "置顶会话" { t.Fatalf("title patch = %+v, want 置顶会话", renamed) } // 取消置顶恢复基线顺序。 resp, _ = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": false}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("unpin status = %d, want 200", resp.StatusCode) } assertOrder([]string{history.MainSessionID, first, second}) // 两个字段都不给 → 400;未知 id → 404;viewer → 403。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body) } resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"pinned": true}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown pin status = %d, want 404 (body=%s)", resp.StatusCode, body) } viewer := signAny(t, jwt, "acct", auth.RoleViewer) resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": true}, viewer) if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer pin status = %d, want 403 (body=%s)", resp.StatusCode, body) } }