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 appends m through the real store and returns the stored message // (with its generated id), so HTTP tests can address a specific message. 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 covers D2: session-scoped ?q= search hits, paging, // the empty-q fallback, input validation and isolation from another session. 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) } } // A matching message in another (sub) session must never surface in the main search. 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) // Newest-first page of 2 matching hits. 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 cursor pages to the older hits. 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) } // The sub session's search sees only its own hit. 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) } // An empty q falls back to plain paging (all messages). 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 is mutually exclusive with after, and invalid limit still 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 covers D3: editing and deleting one message, // the input bounds, 404 and 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 // Edit the content; role/timestamp/session are preserved and echoed back. 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) } // The edit is persisted. 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) } // Invalid edits: empty/whitespace, missing field, over-long, unknown 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) } // Delete the message, then deleting again is 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) } // A viewer cannot edit or delete (owner/admin write). 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 asserts the D2/D3 endpoints answer a clear 501 when // no HistoryStore is injected. 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 covers D5: pin/unpin changes GET /sessions order, // the response echoes only the changed field, and the A4 title PATCH still works. 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}) // Pin the newest sub: it floats above the main session, and the response echoes // only the pinned field. 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 reports the pin flag. 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) } // A title PATCH still works and keeps its {id,title} response shape. 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) } // Unpin restores the baseline order. 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}) // Neither field → 400; unknown 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) } }