package search import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" "senlinai-agent/backend/internal/config" "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/models" ) func TestSearchHandlerRejectsBlankQuery(t *testing.T) { router, _ := newSearchHandlerTestRouter(t, 1) req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=%20%20", nil) req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusBadRequest, rec.Code) var payload httpx.ErrorEnvelope require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) require.Equal(t, "invalid_query", payload.Error.Code) require.Equal(t, "请输入搜索关键词", payload.Error.Message) } func TestSearchHandlerReturnsCamelCaseIdentityResults(t *testing.T) { database := newSearchHandlerTestDB(t) owner := createSearchUser(t, database, "owner@example.com") project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "回调平台", Identifier: "CALLBACK"} require.NoError(t, database.Create(&project).Error) task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "回调任务", Description: "检查签名", Status: "open"} require.NoError(t, database.Create(&task).Error) note := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "接口说明", Markdown: "回调验签流程"} require.NoError(t, database.Create(¬e).Error) router := searchHandlerTestRouter(owner.ID) req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=%E5%9B%9E%E8%B0%83", nil) req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) var payload map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) require.ElementsMatch(t, []string{"items"}, searchMapKeys(payload)) items, ok := payload["items"].([]any) require.True(t, ok) require.Len(t, items, 3) byType := make(map[string]map[string]any, len(items)) for _, raw := range items { item, ok := raw.(map[string]any) require.True(t, ok) require.ElementsMatch(t, []string{"id", "type", "title", "projectId", "snippet"}, searchMapKeys(item)) byType[item["type"].(string)] = item } require.Equal(t, project.Identity, byType["project"]["id"]) require.Equal(t, project.Identity, byType["project"]["projectId"]) require.Equal(t, task.Identity, byType["task"]["id"]) require.Equal(t, project.Identity, byType["task"]["projectId"]) require.Equal(t, note.Identity, byType["note"]["id"]) require.Equal(t, project.Identity, byType["note"]["projectId"]) } func TestSearchHandlerOnlyReturnsObjectsVisibleToCurrentUser(t *testing.T) { database := newSearchHandlerTestDB(t) owner := createSearchUser(t, database, "owner@example.com") viewer := createSearchUser(t, database, "viewer@example.com") project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "保密检索词项目", Identifier: "PRIVATE"} require.NoError(t, database.Create(&project).Error) assigned := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, AssigneeID: &viewer.ID, Title: "检索词已指派任务", Status: "open"} require.NoError(t, database.Create(&assigned).Error) privateTask := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词私有任务", Status: "open"} require.NoError(t, database.Create(&privateTask).Error) sharedNote := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词共享笔记", Markdown: "已显式分享"} require.NoError(t, database.Create(&sharedNote).Error) privateNote := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词私有笔记", Markdown: "不得泄漏"} require.NoError(t, database.Create(&privateNote).Error) otherProject := models.SenlinAgentProject{OwnerID: owner.ID, Name: "其他项目", Identifier: "OTHER"} require.NoError(t, database.Create(&otherProject).Error) crossProjectNote := models.SenlinAgentNote{ProjectID: otherProject.ID, CreatedBy: owner.ID, Title: "检索词跨项目笔记", Markdown: "伪造分享也不得泄漏"} require.NoError(t, database.Create(&crossProjectNote).Error) require.NoError(t, database.Create(&models.SenlinAgentTaskShare{ TaskID: assigned.ID, ObjectType: "note", ObjectID: sharedNote.ID, }).Error) // 即使历史数据绕过服务层写入跨项目分享,搜索也必须在读取边界再次校验项目一致性。 require.NoError(t, database.Create(&models.SenlinAgentTaskShare{ TaskID: assigned.ID, ObjectType: "note", ObjectID: crossProjectNote.ID, }).Error) router := searchHandlerTestRouter(viewer.ID) req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=%E6%A3%80%E7%B4%A2%E8%AF%8D", nil) req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) var payload struct { Items []struct { ID string `json:"id"` Type string `json:"type"` ProjectID string `json:"projectId"` } `json:"items"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) require.Len(t, payload.Items, 2) require.ElementsMatch(t, []string{assigned.Identity, sharedNote.Identity}, []string{payload.Items[0].ID, payload.Items[1].ID}) for _, item := range payload.Items { require.Equal(t, project.Identity, item.ProjectID) require.Contains(t, []string{"task", "note"}, item.Type) require.NotEqual(t, privateTask.Identity, item.ID) require.NotEqual(t, privateNote.Identity, item.ID) require.NotEqual(t, crossProjectNote.Identity, item.ID) require.NotEqual(t, project.Identity, item.ID) } } func newSearchHandlerTestRouter(t *testing.T, userID uint) (http.Handler, *gorm.DB) { t.Helper() database := newSearchHandlerTestDB(t) return searchHandlerTestRouter(userID), database } func searchHandlerTestRouter(userID uint) http.Handler { return httpx.NewProtectedRouter( config.Config{Env: "test"}, func(string) (uint, error) { return userID, nil }, NewHandler(NewService()), ) } func newSearchHandlerTestDB(t *testing.T) *gorm.DB { t.Helper() database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) require.NoError(t, models.AutoMigrate(database)) models.DBService = database return database } func createSearchUser(t *testing.T, database *gorm.DB, email string) models.SenlinAgentUser { t.Helper() user := models.SenlinAgentUser{Email: email, DisplayName: email, PasswordHash: "hash"} require.NoError(t, database.Create(&user).Error) return user } func searchMapKeys(values map[string]any) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } return keys }