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 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) // No platform → the full main session (both lanes). 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 → only the telegram lane, and the DTO carries 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) } } 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) } }