package api import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store" ) // fakeChatStore is an in-memory ChatStorer for tests. type fakeChatStore struct { mu sync.Mutex sessions map[string]*store.Session messages map[string][]store.Message nextID int64 } func newFakeChatStore() *fakeChatStore { return &fakeChatStore{sessions: map[string]*store.Session{}, messages: map[string][]store.Message{}} } func (f *fakeChatStore) CreateSession(_ context.Context, id, userID, title string) (*store.Session, error) { f.mu.Lock() defer f.mu.Unlock() s := &store.Session{ID: id, UserID: userID, Title: title, CreatedAt: time.Now(), UpdatedAt: time.Now()} f.sessions[id] = s return s, nil } func (f *fakeChatStore) ListSessions(_ context.Context, userID string) ([]store.Session, error) { f.mu.Lock() defer f.mu.Unlock() out := []store.Session{} for _, s := range f.sessions { if s.UserID == userID { out = append(out, *s) } } return out, nil } func (f *fakeChatStore) DeleteSession(_ context.Context, id, userID string) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.sessions[id]; !ok { return fmt.Errorf("session not found") } delete(f.sessions, id) delete(f.messages, id) return nil } func (f *fakeChatStore) AddMessage(sessionID, role, content string) { f.mu.Lock() defer f.mu.Unlock() f.nextID++ f.messages[sessionID] = append(f.messages[sessionID], store.Message{ ID: f.nextID, SessionID: sessionID, Role: role, Content: content, CreatedAt: time.Now(), }) } func (f *fakeChatStore) ListMessages(_ context.Context, sessionID string, limit int) ([]store.Message, error) { f.mu.Lock() defer f.mu.Unlock() msgs := f.messages[sessionID] if msgs == nil { msgs = []store.Message{} } return msgs, nil } func (f *fakeChatStore) ClearMessages(_ context.Context, sessionID string) error { f.mu.Lock() defer f.mu.Unlock() f.messages[sessionID] = nil return nil } func (f *fakeChatStore) SearchMessages(_ context.Context, userID, query string, limit int) ([]store.Message, error) { f.mu.Lock() defer f.mu.Unlock() out := []store.Message{} for _, msgs := range f.messages { for _, m := range msgs { if strings.Contains(m.Content, query) { out = append(out, m) } } } return out, nil } func authReq(method, url, body string) *http.Request { var r *http.Request if body != "" { r, _ = http.NewRequest(method, url, strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") } else { r, _ = http.NewRequest(method, url, nil) } r.Header.Set("Authorization", "Bearer stub-token-admin") return r } func TestSessionLifecycle(t *testing.T) { auth := NewStubAuthManager() auth.Login("admin", "admin") // register stub-token-admin chat := newFakeChatStore() r := New(Dependencies{AuthMgr: auth, ChatStore: chat}) srv := httptest.NewServer(r) defer srv.Close() // Create session resp, err := http.DefaultClient.Do(authReq("POST", srv.URL+"/api/v1/sessions", `{"title":""}`)) if err != nil { t.Fatalf("POST sessions: %v", err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("create: expected 201, got %d", resp.StatusCode) } var created struct { SessionID string `json:"session_id"` } readJSON(t, resp, &created) if created.SessionID == "" { t.Fatal("empty session_id") } // Seed messages directly chat.AddMessage(created.SessionID, "user", "今晚想吃火锅") chat.AddMessage(created.SessionID, "assistant", "好呀,要不要叫上朋友") // List sessions resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions", "")) var listed struct { Sessions []store.Session `json:"sessions"` } readJSON(t, resp, &listed) if len(listed.Sessions) != 1 { t.Fatalf("list: expected 1 session, got %d", len(listed.Sessions)) } // Get messages resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/messages", "")) var msgs struct { Messages []store.Message `json:"messages"` } readJSON(t, resp, &msgs) if len(msgs.Messages) != 2 { t.Fatalf("messages: expected 2, got %d", len(msgs.Messages)) } // Search resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/messages/search?q=火锅", "")) var found struct { Total int `json:"total"` } readJSON(t, resp, &found) if found.Total != 1 { t.Errorf("search: expected 1 hit, got %d", found.Total) } // Export markdown resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/export", "")) body := readBody(t, resp) if !strings.Contains(body, "火锅") || !strings.Contains(body, "# Cyrene Chat") { t.Errorf("export markdown missing content: %q", body) } // Export json resp, _ = http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions/"+created.SessionID+"/export?format=json", "")) var exported struct { Messages []store.Message `json:"messages"` } readJSON(t, resp, &exported) if len(exported.Messages) != 2 { t.Errorf("export json: expected 2 messages, got %d", len(exported.Messages)) } // Clear messages resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID+"/messages", "")) if resp.StatusCode != http.StatusNoContent { t.Errorf("clear: expected 204, got %d", resp.StatusCode) } resp.Body.Close() // Delete session resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID, "")) if resp.StatusCode != http.StatusNoContent { t.Errorf("delete: expected 204, got %d", resp.StatusCode) } resp.Body.Close() resp, _ = http.DefaultClient.Do(authReq("DELETE", srv.URL+"/api/v1/sessions/"+created.SessionID, "")) if resp.StatusCode != http.StatusNotFound { t.Errorf("delete again: expected 404, got %d", resp.StatusCode) } resp.Body.Close() } func TestSessionsUnavailableWithoutStore(t *testing.T) { auth := NewStubAuthManager() auth.Login("admin", "admin") r := New(Dependencies{AuthMgr: auth}) srv := httptest.NewServer(r) defer srv.Close() resp, _ := http.DefaultClient.Do(authReq("GET", srv.URL+"/api/v1/sessions", "")) if resp.StatusCode != http.StatusServiceUnavailable { t.Errorf("expected 503 without store, got %d", resp.StatusCode) } resp.Body.Close() } func readJSON(t *testing.T, resp *http.Response, v any) { t.Helper() defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(v); err != nil { t.Fatalf("decode response: %v", err) } } func readBody(t *testing.T, resp *http.Response) string { t.Helper() defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } return string(b) }