Files
Cyrene_v2/internal/services/gateway/history_handler_test.go
T
Aska 48421923f6 feat(history): 会话消息持久化 + 主/次级会话(design/16)
- runtime/history: Store + HistoryStore 接口(Append/List 游标分页/Recent/DeleteSession/NewSubSession/ListSessions),主会话固定 web、次级 web:<uuid>
- db 迁移 0003: chat_sessions + chat_messages + idx_chat_msgs_session_created(事务幂等)
- gateway: GET/POST /sessions、GET /sessions/{id}/messages 热分页、DELETE /sessions/{id}(主不可删 403);Deps.History 可空注入
- 编排层落库: session_persist.go(submitInternal 写用户消息、completeTurn 写助手消息,拒绝的消息不落库)
- WS: wsEvent 加 sessionId 定向 + history 回放帧(握手上行后回放主会话最新 50 条,thinking 剥离)
- wire.go 装配 History 到 gateway.Deps.History
- 端到端实测: POST /sessions 建次级 201 / GET /sessions 列表 / POST /chat 后 /messages 读到 user+assistant / DELETE 次级成功 / DELETE 主会话 403
2026-09-07 03:52:43 +08:00

229 lines
8.5 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"
)
// testLogger returns a logger that writes nowhere, keeping test output clean.
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newHistoryEnv builds a Server wired with a real in-memory history store, returning the
// httptest server, the JWT service and the underlying history store.
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 decodes the {data:[...]} envelope into the given slice target.
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)
// Fresh store always lists the main session.
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)
}
// Create a sub session.
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)
}
// Now main first, then the 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()
// Seed the main session with messages at ascending ms timestamps.
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)
// Newest page of 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)
}
// Cursor before the page's oldest (3000) → older page [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)
}
// Invalid 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 TestHistoryDeleteSubSession(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Create a sub and seed a message.
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)
}
// Delete it.
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)
}
// It is gone from the list.
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)
}
// Main session is immutable → 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)
}
func TestHistoryNotWired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Messages/create/delete answer a clear 501 when no history store is wired.
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},
} {
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 can list session metadata (readAuth) but not read/write history content.
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 can read messages.
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)
}
}