feat: connect search and project settings

This commit is contained in:
2026-07-21 17:16:45 +08:00
parent 2275d388a7
commit 000de4bcdb
14 changed files with 698 additions and 141 deletions

View File

@@ -99,7 +99,12 @@ func (h *Handler) updateProject(c *gin.Context) {
writeProjectError(c, err)
return
}
c.Status(http.StatusNoContent)
project, err := h.service.GetProject(userID, identity)
if err != nil {
writeProjectError(c, err)
return
}
c.JSON(http.StatusOK, project)
}
func (h *Handler) workspace(c *gin.Context) {

View File

@@ -128,8 +128,16 @@ func TestUpdateProject(t *testing.T) {
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusNoContent, rec.Code)
require.Empty(t, rec.Body.String())
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{"id", "name", "identifier", "icon", "background", "description"}, mapKeys(payload))
require.Equal(t, project.Identity, payload["id"])
require.Equal(t, "Alpha Next", payload["name"])
require.Equal(t, "ALPHA-NEXT", payload["identifier"])
require.Equal(t, "tree", payload["icon"])
require.Equal(t, "#0FC6C2", payload["background"])
require.Equal(t, "更新后的项目说明", payload["description"])
var updated models.SenlinAgentProject
require.NoError(t, models.DBService.First(&updated, project.ID).Error)
require.Equal(t, "Alpha Next", updated.Name)
@@ -161,7 +169,15 @@ func TestUpdateProject(t *testing.T) {
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusNoContent, rec.Code)
require.Equal(t, http.StatusOK, rec.Code)
var payload ProjectDTO
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Equal(t, project.Identity, payload.ID)
require.Equal(t, originalName, payload.Name)
require.Equal(t, originalIdentifier, payload.Identifier)
require.Empty(t, payload.Icon)
require.Empty(t, payload.Background)
require.Empty(t, payload.Description)
var updated models.SenlinAgentProject
require.NoError(t, models.DBService.First(&updated, project.ID).Error)
require.Equal(t, originalName, updated.Name)

View File

@@ -0,0 +1,54 @@
package search
import (
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"senlinai-agent/backend/internal/httpx"
"senlinai-agent/backend/internal/logic/auth"
)
// Handler 将关键词搜索服务注册为受认证的 HTTP 接口。
type Handler struct {
service *Service
}
// SearchResponseDTO 统一包装关键词搜索结果,空结果保持为空数组而不是 null。
type SearchResponseDTO struct {
Items []SearchResultDTO `json:"items"`
}
// NewHandler 创建搜索 HTTP registrar。
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// Register 将搜索接口注册到上层提供的 /api/v1 路由组。
func (h *Handler) Register(router gin.IRouter) {
router.GET("/search", h.search)
}
func (h *Handler) search(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
return
}
query := strings.TrimSpace(c.Query("q"))
if query == "" {
httpx.Error(c, http.StatusBadRequest, "invalid_query", "请输入搜索关键词")
return
}
items, err := h.service.Search(userID, query)
if err != nil {
log.Printf("search failed for user %d: %v", userID, err)
httpx.Error(c, http.StatusInternalServerError, "internal_error", "搜索失败,请稍后重试")
return
}
if items == nil {
items = []SearchResultDTO{}
}
c.JSON(http.StatusOK, SearchResponseDTO{Items: items})
}

View File

@@ -0,0 +1,161 @@
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(&note).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
}

View File

@@ -3,133 +3,126 @@ package search
import (
"strings"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
type Service struct {
db *gorm.DB
}
type Result struct {
const maxSearchResults = 50
// SearchResultDTO 是搜索接口的稳定结果,所有关联均使用公开 identity。
type SearchResultDTO struct {
Type string `json:"type"`
ID uint `json:"id"`
ProjectID uint `json:"project_id"`
ID string `json:"id"`
ProjectID string `json:"projectId"`
Title string `json:"title"`
Snippet string `json:"snippet"`
}
func NewService() *Service {
return &Service{}
func NewService(databases ...*gorm.DB) *Service {
var database *gorm.DB
if len(databases) > 0 {
database = databases[0]
}
return &Service{db: database}
}
func (s *Service) Search(userID uint, query string) ([]Result, error) {
func (s *Service) database() *gorm.DB {
if s.db != nil {
return s.db
}
return models.DBService
}
func (s *Service) Search(userID uint, query string) ([]SearchResultDTO, error) {
query = strings.TrimSpace(query)
if query == "" {
return []Result{}, nil
return []SearchResultDTO{}, nil
}
if models.DBService.Dialector.Name() == "postgres" {
if s.database().Dialector.Name() == "postgres" {
return s.searchPostgres(userID, query)
}
like := "%" + query + "%"
results := []Result{}
like := containsPattern(query)
results := []SearchResultDTO{}
var projects []models.SenlinAgentProject
if err := models.DBService.Where("owner_id = ? AND (name LIKE ? OR description LIKE ?)", userID, like, like).Find(&projects).Error; err != nil {
if err := s.database().Where("owner_id = ? AND (name LIKE ? ESCAPE '!' OR description LIKE ? ESCAPE '!')", userID, like, like).Limit(maxSearchResults).Find(&projects).Error; err != nil {
return nil, err
}
for _, project := range projects {
results = append(results, Result{Type: "project", ID: project.ID, ProjectID: project.ID, Title: project.Name, Snippet: project.Description})
results = append(results, SearchResultDTO{Type: "project", ID: project.Identity, ProjectID: project.Identity, Title: project.Name, Snippet: project.Description})
}
var tasks []models.SenlinAgentTask
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_tasks.project_id").
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_tasks.title LIKE ? OR senlin_agent_tasks.description LIKE ?)", userID, like, like).
if err := s.database().Select("senlin_agent_tasks.*").Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_tasks.project_id").
Where("(senlin_agent_projects.owner_id = ? OR senlin_agent_tasks.assignee_id = ?) AND (senlin_agent_tasks.title LIKE ? ESCAPE '!' OR senlin_agent_tasks.description LIKE ? ESCAPE '!')", userID, userID, like, like).
Limit(maxSearchResults).
Find(&tasks).Error; err != nil {
return nil, err
}
for _, task := range tasks {
results = append(results, Result{Type: "task", ID: task.ID, ProjectID: task.ProjectID, Title: task.Title, Snippet: task.Description})
results = append(results, SearchResultDTO{Type: "task", ID: task.Identity, ProjectID: task.ProjectIdentity, Title: task.Title, Snippet: task.Description})
}
var notes []models.SenlinAgentNote
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_notes.project_id").
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_notes.title LIKE ? OR senlin_agent_notes.markdown LIKE ?)", userID, like, like).
if err := s.database().Select("senlin_agent_notes.*").Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_notes.project_id").
Where(`(senlin_agent_projects.owner_id = ? OR EXISTS (
SELECT 1 FROM senlin_agent_task_shares
JOIN senlin_agent_tasks ON senlin_agent_tasks.id = senlin_agent_task_shares.task_id
WHERE senlin_agent_task_shares.object_type = 'note'
AND senlin_agent_task_shares.object_id = senlin_agent_notes.id
AND senlin_agent_tasks.project_id = senlin_agent_notes.project_id
AND senlin_agent_tasks.assignee_id = ?
)) AND (senlin_agent_notes.title LIKE ? ESCAPE '!' OR senlin_agent_notes.markdown LIKE ? ESCAPE '!')`, userID, userID, like, like).
Limit(maxSearchResults).
Find(&notes).Error; err != nil {
return nil, err
}
for _, note := range notes {
results = append(results, Result{Type: "note", ID: note.ID, ProjectID: note.ProjectID, Title: note.Title, Snippet: note.Markdown})
results = append(results, SearchResultDTO{Type: "note", ID: note.Identity, ProjectID: note.ProjectIdentity, Title: note.Title, Snippet: note.Markdown})
}
var sources []models.SenlinAgentSource
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_sources.project_id").
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_sources.title LIKE ? OR senlin_agent_sources.url LIKE ? OR senlin_agent_sources.content_text LIKE ?)", userID, like, like, like).
Find(&sources).Error; err != nil {
return nil, err
}
for _, source := range sources {
results = append(results, Result{Type: "source", ID: source.ID, ProjectID: source.ProjectID, Title: source.Title, Snippet: source.ContentText})
}
var inboxItems []models.SenlinAgentInboxItem
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id").
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_inbox_items.title LIKE ? OR senlin_agent_inbox_items.body LIKE ?)", userID, like, like).
Find(&inboxItems).Error; err != nil {
return nil, err
}
for _, item := range inboxItems {
results = append(results, Result{Type: "inbox", ID: item.ID, ProjectID: item.ProjectID, Title: item.Title, Snippet: item.Body})
}
var sessions []models.SenlinAgentAISession
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_ai_sessions.project_id").
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_ai_sessions.title LIKE ? OR senlin_agent_ai_sessions.context LIKE ?)", userID, like, like).
Find(&sessions).Error; err != nil {
return nil, err
}
for _, session := range sessions {
results = append(results, Result{Type: "ai_session", ID: session.ID, ProjectID: session.ProjectID, Title: session.Title, Snippet: session.Context})
if len(results) > maxSearchResults {
results = results[:maxSearchResults]
}
return results, nil
}
func (s *Service) searchPostgres(userID uint, query string) ([]Result, error) {
var results []Result
err := models.DBService.Raw(`
SELECT 'project' AS type, p.id, p.id AS project_id, p.name AS title, p.description AS snippet
func (s *Service) searchPostgres(userID uint, query string) ([]SearchResultDTO, error) {
var results []SearchResultDTO
like := containsPattern(query)
err := s.database().Raw(`
SELECT 'project' AS type, p.identity AS id, p.identity AS project_id, p.name AS title, p.description AS snippet
FROM senlin_agent_projects p
WHERE p.owner_id = ?
AND to_tsvector('simple', coalesce(p.name, '') || ' ' || coalesce(p.description, '')) @@ plainto_tsquery('simple', ?)
AND (coalesce(p.name, '') ILIKE ? ESCAPE '!' OR coalesce(p.description, '') ILIKE ? ESCAPE '!')
UNION ALL
SELECT 'task' AS type, t.id, t.project_id, t.title, t.description AS snippet
SELECT 'task' AS type, t.identity AS id, p.identity AS project_id, t.title, t.description AS snippet
FROM senlin_agent_tasks t
JOIN senlin_agent_projects p ON p.id = t.project_id
WHERE p.owner_id = ?
AND to_tsvector('simple', coalesce(t.title, '') || ' ' || coalesce(t.description, '')) @@ plainto_tsquery('simple', ?)
WHERE (p.owner_id = ? OR t.assignee_id = ?)
AND (coalesce(t.title, '') ILIKE ? ESCAPE '!' OR coalesce(t.description, '') ILIKE ? ESCAPE '!')
UNION ALL
SELECT 'note' AS type, n.id, n.project_id, n.title, n.markdown AS snippet
SELECT 'note' AS type, n.identity AS id, p.identity AS project_id, n.title, n.markdown AS snippet
FROM senlin_agent_notes n
JOIN senlin_agent_projects p ON p.id = n.project_id
WHERE p.owner_id = ?
AND to_tsvector('simple', coalesce(n.title, '') || ' ' || coalesce(n.markdown, '')) @@ plainto_tsquery('simple', ?)
UNION ALL
SELECT 'source' AS type, s.id, s.project_id, s.title, s.content_text AS snippet
FROM senlin_agent_sources s
JOIN senlin_agent_projects p ON p.id = s.project_id
WHERE p.owner_id = ?
AND to_tsvector('simple', coalesce(s.title, '') || ' ' || coalesce(s.url, '') || ' ' || coalesce(s.content_text, '')) @@ plainto_tsquery('simple', ?)
UNION ALL
SELECT 'inbox' AS type, i.id, i.project_id, i.title, i.body AS snippet
FROM senlin_agent_inbox_items i
JOIN senlin_agent_projects p ON p.id = i.project_id
WHERE p.owner_id = ?
AND to_tsvector('simple', coalesce(i.title, '') || ' ' || coalesce(i.body, '')) @@ plainto_tsquery('simple', ?)
UNION ALL
SELECT 'ai_session' AS type, a.id, a.project_id, a.title, a.context AS snippet
FROM senlin_agent_ai_sessions a
JOIN senlin_agent_projects p ON p.id = a.project_id
WHERE p.owner_id = ?
AND to_tsvector('simple', coalesce(a.title, '') || ' ' || coalesce(a.context, '')) @@ plainto_tsquery('simple', ?)
LIMIT 50
`, userID, query, userID, query, userID, query, userID, query, userID, query, userID, query).Scan(&results).Error
WHERE (p.owner_id = ? OR EXISTS (
SELECT 1 FROM senlin_agent_task_shares ts
JOIN senlin_agent_tasks t ON t.id = ts.task_id
WHERE ts.object_type = 'note'
AND ts.object_id = n.id
AND t.project_id = n.project_id
AND t.assignee_id = ?
))
AND (coalesce(n.title, '') ILIKE ? ESCAPE '!' OR coalesce(n.markdown, '') ILIKE ? ESCAPE '!')
LIMIT ?
`, userID, like, like, userID, userID, like, like, userID, userID, like, like, maxSearchResults).Scan(&results).Error
return results, err
}
func containsPattern(query string) string {
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(query)
return "%" + escaped + "%"
}

View File

@@ -0,0 +1,61 @@
package search
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
func TestPostgresSearchMatchesChinesePartialKeywords(t *testing.T) {
database := newPostgresSearchTestDB(t)
owner := createSearchUser(t, database, "postgres-search-owner@example.com")
project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "中文回调平台", Identifier: "PG-CALLBACK"}
require.NoError(t, database.Create(&project).Error)
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "回调任务", Status: "open"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "说明", Markdown: "回调验签流程"}).Error)
results, err := NewService(database).Search(owner.ID, "回调")
require.NoError(t, err)
require.Len(t, results, 3)
}
func TestPostgresSearchKeepsAssignmentAndExplicitShareBoundaries(t *testing.T) {
database := newPostgresSearchTestDB(t)
owner := createSearchUser(t, database, "postgres-privacy-owner@example.com")
viewer := createSearchUser(t, database, "postgres-privacy-viewer@example.com")
project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "保密项目", Identifier: "PG-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)
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词私有任务", Status: "open"}).Error)
sharedNote := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词共享笔记"}
require.NoError(t, database.Create(&sharedNote).Error)
require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词私有笔记"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentTaskShare{TaskID: assigned.ID, ObjectType: "note", ObjectID: sharedNote.ID}).Error)
results, err := NewService(database).Search(viewer.ID, "边界词")
require.NoError(t, err)
require.Len(t, results, 2)
require.ElementsMatch(t, []string{assigned.Identity, sharedNote.Identity}, []string{results[0].ID, results[1].ID})
}
func newPostgresSearchTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL is not configured; skipping PostgreSQL search integration test")
}
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
require.NoError(t, err)
transaction := database.Begin()
require.NoError(t, transaction.Error)
t.Cleanup(func() { transaction.Rollback() })
require.NoError(t, models.AutoMigrate(transaction))
return transaction
}

View File

@@ -26,13 +26,14 @@ func TestSearchFindsNoteBody(t *testing.T) {
require.Equal(t, "note", results[0].Type)
}
func TestSearchFindsCoreProjectObjects(t *testing.T) {
func TestSearchOnlyReturnsNavigableProjectTaskAndNoteResults(t *testing.T) {
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
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "支付项目"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "webhook 支付项目"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: 1, CreatedBy: 7, Title: "回调任务", Description: "检查 webhook"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: 1, CreatedBy: 7, Title: "回调笔记", Markdown: "webhook 流程"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentSource{ProjectID: 1, CreatedBy: 7, Kind: "link", Title: "支付文档", URL: "https://example.com/pay", ContentText: "webhook 签名"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: 1, CreatedBy: 7, SourceType: "text", Title: "收集项", Body: "webhook 待整理"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentAISession{ProjectID: 1, CreatedBy: 7, Title: "AI 分析", Context: "webhook 问答"}).Error)
@@ -45,8 +46,25 @@ func TestSearchFindsCoreProjectObjects(t *testing.T) {
for _, result := range results {
types[result.Type] = true
}
require.True(t, types["task"])
require.True(t, types["source"])
require.True(t, types["inbox"])
require.True(t, types["ai_session"])
require.Equal(t, map[string]bool{"project": true, "task": true, "note": true}, types)
require.Len(t, results, 3)
}
func TestSearchTreatsLikeWildcardsAsLiteralKeywords(t *testing.T) {
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
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "100%_完成", Identifier: "SPECIAL"}).Error)
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 2, OwnerID: 7, Name: "普通项目", Identifier: "NORMAL"}).Error)
percentResults, err := NewService().Search(7, "%")
require.NoError(t, err)
require.Len(t, percentResults, 1)
require.Equal(t, "100%_完成", percentResults[0].Title)
underscoreResults, err := NewService().Search(7, "_")
require.NoError(t, err)
require.Len(t, underscoreResults, 1)
require.Equal(t, "100%_完成", underscoreResults[0].Title)
}